mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8a69638b | ||
|
|
5fd07e50a1 | ||
|
|
4efa28a036 | ||
|
|
ac4b70526a | ||
|
|
63a7375d2b | ||
|
|
2cea5cc34c | ||
|
|
78c10eac00 | ||
|
|
5f7e5f8ac4 | ||
|
|
70c6143a1a | ||
|
|
b5ce309f3d | ||
|
|
a63e940d6a | ||
|
|
13d639680c | ||
|
|
bbae974d0b | ||
|
|
60059f404a | ||
|
|
53d1db9e65 | ||
|
|
6da68650a1 | ||
|
|
752a87201b | ||
|
|
0b359b07c4 |
No files matched your search
@@ -20,7 +20,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
-179
@@ -8,185 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.12]
|
||||
|
||||
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
|
||||
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
|
||||
an answer, the answer now goes to an agent that reads it with the question and
|
||||
decides. Answers go to the configured receiving agent or kernel operator when
|
||||
available; otherwise they wait. Setup offers to register you as the team's
|
||||
person. `rig down` no longer stops a terminal it
|
||||
can't show belongs to the seat. There are fixes across delivery, Codex,
|
||||
restore, the queue, Slack and speed, many contributed by people using
|
||||
OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **`rig down` is more careful.** It stops only a tmux session that carries the
|
||||
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
|
||||
OpenRig didn't create, a mismarked one or a replaced one is left running, and
|
||||
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
|
||||
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
|
||||
can ask for hook approval until it's relaunched.
|
||||
|
||||
### Answering your team
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and you can decide directly in the
|
||||
TUI's pairing settings. A pairing request says who it went to and who reads
|
||||
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer such as "only if it's the test file"
|
||||
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup offers to register you as the team's person, and the empty-registry
|
||||
hint is right. A team you start from your own assistant fits your own logins,
|
||||
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
|
||||
### Harnesses and connections
|
||||
|
||||
- A community harness can declare `protocol: pi-rpc`, presets can carry launch
|
||||
wrappers, and a seat can switch between Pi and another runtime. The
|
||||
community-maintained nodecode runner is listed in the reference.
|
||||
([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669), [#2859](https://github.com/mvschwarz/openrig/pull/2859))
|
||||
- A relay connector that can't accept incoming connections can pull pending
|
||||
messages for a person instead of being pushed to: set
|
||||
`OPENRIG_RELAY_MODE=pull` in the relay secrets file. Push stays the default.
|
||||
A person's gateway binding can be unset. ([#2844](https://github.com/mvschwarz/openrig/pull/2844), [#2812](https://github.com/mvschwarz/openrig/pull/2812))
|
||||
- `rig terminal open <view> --provider herdr-app` opens a Rig view inside the
|
||||
herdr-app desktop window. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2956](https://github.com/mvschwarz/openrig/pull/2956), [#2981](https://github.com/mvschwarz/openrig/pull/2981))
|
||||
|
||||
### Queue, scope and seats
|
||||
|
||||
- Queue content can be erased for closed-work retention and account deletion,
|
||||
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
|
||||
- Each send records what's known about its delivery in one
|
||||
`transport.delivery_evidence` event: what was admitted, what reached the
|
||||
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- `rig queue create` advises on an unresolved scope, slice lists show observed
|
||||
queue counts, a proof policy can require every listed judge, the execution
|
||||
ladder shows each slice's recorded status, `rig queue show` names a handed-off
|
||||
row's successor, and a custom blocker ID explains the wait.
|
||||
([#2901](https://github.com/mvschwarz/openrig/pull/2901), [#2902](https://github.com/mvschwarz/openrig/pull/2902), [#2867](https://github.com/mvschwarz/openrig/pull/2867), [#2872](https://github.com/mvschwarz/openrig/pull/2872), [#2874](https://github.com/mvschwarz/openrig/pull/2874), [#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- Every seat's managed guidance points to its skills, help and project context.
|
||||
`rig seat status` shows when guidance was written relative to the session's
|
||||
recorded moments, and what
|
||||
the seat's refocus hook recorded. A rebuilt seat's briefing lists older recaps
|
||||
as optional history. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916), [#2876](https://github.com/mvschwarz/openrig/pull/2876))
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Delivery and drafts:** Claude seats with a custom status line read idle and
|
||||
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
|
||||
Claude's context hint and drawn cursor are read correctly; a deliberate
|
||||
override is delivered when the input can't be read; update notices beside the
|
||||
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
|
||||
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
|
||||
delivered instead of escalated; a working counter is compared only within one pane process; hook
|
||||
trust records are written under smol-toml 1.9, so launches no longer stop at
|
||||
"Hooks need review". ([#2952](https://github.com/mvschwarz/openrig/pull/2952), [#2980](https://github.com/mvschwarz/openrig/pull/2980), [#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **Restore, start and handover:** owed restore input is delivered after a
|
||||
bounded wait on unknown readiness; compaction uses the draft defaults and
|
||||
reports repeated unsent preparation; `rig daemon start` archives a start
|
||||
reservation left from an earlier boot; the kernel restart guidance describes
|
||||
the existing recovery; an earlier occupant can't replace a Claude seat's
|
||||
resume target; token switches keep the Claude launch; rebuild continuity and
|
||||
Codex launch choices are explained.
|
||||
([#2818](https://github.com/mvschwarz/openrig/pull/2818), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2797](https://github.com/mvschwarz/openrig/pull/2797), [#2915](https://github.com/mvschwarz/openrig/pull/2915), [#2965](https://github.com/mvschwarz/openrig/pull/2965), [#2796](https://github.com/mvschwarz/openrig/pull/2796), [#2853](https://github.com/mvschwarz/openrig/pull/2853), [#2906](https://github.com/mvschwarz/openrig/pull/2906))
|
||||
- **Queue and recovery:** a polling hosted member (an outside assistant on the
|
||||
team; experimental and off by default) gets one standing notice; a hosted
|
||||
member's ring that times out reads unknown, and durable recovery doesn't
|
||||
repeat that recorded wake (other recovery paths can still ring it again);
|
||||
unroutable items wake as soon as the seat is reachable; the stuck sweep counts
|
||||
a finding once; an escalation keeps its owner through a handoff; findings route to the
|
||||
seat's own fallback; a park on an outside condition with no wake says so;
|
||||
`rig workflow validate` is read-only.
|
||||
([#2864](https://github.com/mvschwarz/openrig/pull/2864), [#2957](https://github.com/mvschwarz/openrig/pull/2957), [#2790](https://github.com/mvschwarz/openrig/pull/2790), [#2824](https://github.com/mvschwarz/openrig/pull/2824), [#2946](https://github.com/mvschwarz/openrig/pull/2946), [#2865](https://github.com/mvschwarz/openrig/pull/2865), [#2851](https://github.com/mvschwarz/openrig/pull/2851), [#2852](https://github.com/mvschwarz/openrig/pull/2852), [#2817](https://github.com/mvschwarz/openrig/pull/2817), [#2763](https://github.com/mvschwarz/openrig/pull/2763))
|
||||
- **Slack and people:** a Slack reply that can't be read says the message "may
|
||||
have posted"; threads are recovered when needed; an ask settles after its
|
||||
original message is deleted; eligible asks post when quiet hours can't be
|
||||
read, marked when OpenRig can't fully check them; a held ask whose person has
|
||||
no connector closes as no-connector; update delivery is described without
|
||||
assuming Slack. ([#2909](https://github.com/mvschwarz/openrig/pull/2909), [#2788](https://github.com/mvschwarz/openrig/pull/2788), [#2792](https://github.com/mvschwarz/openrig/pull/2792), [#2814](https://github.com/mvschwarz/openrig/pull/2814), [#2805](https://github.com/mvschwarz/openrig/pull/2805), [#2815](https://github.com/mvschwarz/openrig/pull/2815), [#2927](https://github.com/mvschwarz/openrig/pull/2927))
|
||||
- **CLI and config:** `rig context recap-write` writes where every reader looks;
|
||||
config accepts two keys the daemon already resolves; `rig whoami` says the
|
||||
team is still being built during `rig up`; rigs with `@` in the name find
|
||||
attention; a seat waiting on a person names its question; the installer
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
|
||||
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Pi and OMP:** typed edits submit in order on the affected Node releases
|
||||
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
|
||||
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
|
||||
- **Terminals:** a Herdr grid whose blank filler cells have no label is confirmed,
|
||||
instead of reading every seat unconfirmed. ([#2849](https://github.com/mvschwarz/openrig/pull/2849))
|
||||
- **Speed:** queue listings, `rig queue show` and `rig parked` stop re-reading
|
||||
each item;
|
||||
the stuck sweep yields by elapsed time; Markdown section ends are found in one
|
||||
pass. ([#2825](https://github.com/mvschwarz/openrig/pull/2825), [#2926](https://github.com/mvschwarz/openrig/pull/2926), [#2833](https://github.com/mvschwarz/openrig/pull/2833))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Your answer goes to an agent.** Through 0.7.11 some answers acted on the
|
||||
request directly, and some had no receiver when no operator was set. Now these
|
||||
answers use the configured receiving agent or the kernel operator, with the
|
||||
question included. Answers wait when no receiver is available.
|
||||
([#2923](https://github.com/mvschwarz/openrig/pull/2923), [#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **`rig down`** leaves terminals it can't show belong to the seat; see Before
|
||||
you upgrade. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex "Hooks need review" with smol-toml 1.9** no longer needs a manual
|
||||
workaround. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **A daemon start reservation left after a reboot** is archived by
|
||||
`rig daemon start`; don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **Not in this release:** the daemon can pause for up to about a second while
|
||||
reading proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and delivery to a Claude seat
|
||||
showing Claude's auto-update failure footer can stall because that footer
|
||||
isn't recognized ([#2948](https://github.com/mvschwarz/openrig/pull/2948)).
|
||||
- **`rig down`:** a terminal it leaves running stays running until you stop it.
|
||||
It doesn't keep a stored host or boot identity, doesn't guard against another
|
||||
program running as you, and a daemon restart between losing the tmux server
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then end that process. If it runs in tmux,
|
||||
also end the underlying tmux session, including any remaining shell; closing
|
||||
only its viewing window leaves that session running. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
- **Custom blocker IDs** explain the wait, but their progress gives no event
|
||||
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Delivery evidence** records what was known, not that the agent read the
|
||||
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers:** when a person answers a runner's permission
|
||||
request, the answer is kept, and an explicit decision works. In 0.7.12 the
|
||||
runner's confirmation updates fail to record, so no receipt or Feed
|
||||
confirmation appears, and the decision can reach the runner on its next read
|
||||
rather than at once (up to about 21 seconds in our test). To settle a request
|
||||
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
|
||||
answers are separate, and their receipts are kept. Tested on one native daemon
|
||||
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
|
||||
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
|
||||
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
|
||||
again, so the Codex hook note there applies to this upgrade too. They weren't
|
||||
re-checked for this release unless named here.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @oodadoudou, @DeryFerd, @Aummadour, @seanmcn, @w4rc0n,
|
||||
@ziadeh, @vanducng, @smota, @sn1thy, @dajiaohuang and @Vaishnavi220506 for
|
||||
merged contributions, and to @KM199, co-author of [#2947](https://github.com/mvschwarz/openrig/pull/2947) and [#2952](https://github.com/mvschwarz/openrig/pull/2952), which finish
|
||||
their [#2513](https://github.com/mvschwarz/openrig/pull/2513) and [#2515](https://github.com/mvschwarz/openrig/pull/2515).
|
||||
Thanks also for the reports behind these fixes: @nassersala ([#2955](https://github.com/mvschwarz/openrig/pull/2955)), @djuntgen
|
||||
([#2804](https://github.com/mvschwarz/openrig/pull/2804)), @todd-dhango ([#2851](https://github.com/mvschwarz/openrig/pull/2851)), @Kayhusk ([#2796](https://github.com/mvschwarz/openrig/pull/2796)), @pr0xc3nt4ur1 ([#2849](https://github.com/mvschwarz/openrig/pull/2849) and [#2850](https://github.com/mvschwarz/openrig/pull/2850)), @amsayfi
|
||||
([#2776](https://github.com/mvschwarz/openrig/pull/2776)), @oodadoudou ([#2847](https://github.com/mvschwarz/openrig/pull/2847)) and @CaptObvious ([#2933](https://github.com/mvschwarz/openrig/pull/2933) and [#2942](https://github.com/mvschwarz/openrig/pull/2942)).
|
||||
|
||||
## [0.7.11]
|
||||
|
||||
OpenRig 0.7.11 is a patch for people running their own agent teams, cut from
|
||||
|
||||
@@ -328,7 +328,7 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
|
||||
- **Connect** Slack through an app you create in your own workspace; the experimental `rig slack manifest` prints that app's manifest ([setup guide](docs/reference/slack-app-setup.md))
|
||||
- **Evolve** running topologies with `rig grow`, `rig shrink`, `rig launch`, `rig remove`
|
||||
|
||||
Every terminal seat runs in a tmux session you can attach to, inspect, and work with directly. [Hosted members](docs/reference/hosted-members.md) join through OpenRig's MCP server and have no pane.
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
|
||||
## Teams
|
||||
|
||||
@@ -519,9 +519,6 @@ OpenRig is open source and self-hosted, with Claude Code and Codex in the same t
|
||||
|
||||
- **Website**: [openrig.dev](https://openrig.dev)
|
||||
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs) ([documentation index for agents](https://openrig.dev/llms.txt))
|
||||
- **What's new**: [openrig.dev/whats-new](https://openrig.dev/whats-new) (every release, in plain words)
|
||||
- **Features**: [openrig.dev/features](https://openrig.dev/features)
|
||||
- **Integrations**: [openrig.dev/integrations](https://openrig.dev/integrations) (assistants, harnesses and environments, with setup and test status)
|
||||
- **Blog**: [openrig.dev/blog](https://openrig.dev/blog) · [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
|
||||
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
|
||||
- **Videos**: [youtube.com/@openrig](https://www.youtube.com/@openrig)
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.12
|
||||
## Released in 0.7.10
|
||||
|
||||
[0.7.12 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.12): OpenRig 0.7.12 is a patch for people running their own agent teams. When you answer your team, by typing, clicking or qualifying an answer, the answer now goes to an agent that reads it with the question and decides. When no receiving agent is available, your answer waits. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -398,21 +398,6 @@ adapter never closes a session on it and reports `rex_outcome_unknown`.
|
||||
Herdr and Rex build their layout trees from one grid helper
|
||||
(`buildTileGrid`, `tile-layout.ts`).
|
||||
|
||||
`HerdrAppProvider` (`herdr-app-provider.ts`) opens a view in a herdr-app
|
||||
window (one provider per app home, cached in `startup.ts`). In the daemon it is
|
||||
used only when named (`resolveHerdrAppProvider` in `terminal-service.ts`). Its
|
||||
transport (`herdr-app-transport.ts`) sends one JSON line per
|
||||
connection to `<app home>/herdr-app.sock` and reads the window's own "did not
|
||||
answer" as `unknown`. A view is a child of OpenRig's stack in a derived
|
||||
`layouts/rig.json` (`deriveRigLayout`, `herdr-app-layout.ts`), written
|
||||
atomically, with viewers started by `hc run --bg --no-parent` and journalled
|
||||
first (`herdr-app-state.ts`). Removal rewrites the file before a viewer ends,
|
||||
and ends it only after the `rig-strip` mod's acknowledgement
|
||||
(`<app home>/openrig/ack.json`) shows a layout without it. An `hc events`
|
||||
watcher (`startup.ts`) turns a viewer that ended elsewhere into an ended cell.
|
||||
Its four mods ship in `packages/daemon/assets/herdr-app/mods/` and are
|
||||
installed by `herdr-app-install.ts`.
|
||||
|
||||
### Tmux server selection
|
||||
|
||||
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
|
||||
|
||||
@@ -140,11 +140,7 @@ that gateway side too. For the pane side, read:
|
||||
|
||||
- `lastNudgeWireResult`: the wake send's result as stored (for example
|
||||
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it. Each stored
|
||||
form reads as one delivery fact (`readWakeOutcome`, `connection-contract.ts`).
|
||||
A result no reader recognises reads `unknown`, never delivered: the wake ladder
|
||||
treats it as an unconfirmed wake, which escalates past the window without a
|
||||
re-send.
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it.
|
||||
- `lastNudgeResult`: the result as presented. It is the stored result, except
|
||||
that a claimed row whose stored result is `delivered-ack-pending` or
|
||||
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
|
||||
@@ -179,14 +175,8 @@ The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. Unclaimed and undelivered findings go to
|
||||
the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. A wake that failed (`failed:…`)
|
||||
at a recorded agent seat with no `delegates_to` parent or `escalates_to` route is
|
||||
the exception: its finding tries the row's creator, then the operator agent
|
||||
(`agents.operator_session`), skipping an unset, confirmed-missing, self or
|
||||
unaccepted address, a bound terminal seat, and OpenRig's own machinery
|
||||
(`daemon@kernel` or a `@system` actor), so the finding's own wake isn't sent to the seat that just failed to
|
||||
take one. If neither can be used, it stays with the original seat and says why.
|
||||
Overdue claims go to their destination. Repeated detections reuse the existing finding without changing
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
@@ -247,9 +237,7 @@ The sweep reports six finding kinds. Its custody check covers closures that
|
||||
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
|
||||
24 hours, plus older closures with open findings. A seat target is satisfied by
|
||||
a local successor carrying this row's lineage, and any target by a
|
||||
`custody-verified:` note on the closed row: per note, its first word that names one
|
||||
of the row's targets exactly (or a comma list of them) is what it verifies, and later
|
||||
mentions don't count; the finding prints each target's note. A new custody finding for
|
||||
`custody-verified: <target> …` note on the closed row. A new custody finding for
|
||||
a human destination goes to the agent that closed the item, using the closing
|
||||
transition from active or archived history. Later notes and notification
|
||||
receipts do not change that owner. If the closing actor is missing, human, system
|
||||
@@ -290,13 +278,7 @@ canceled, no-follow-on, escalation, superseded}`. The reasons
|
||||
`closure_target` (`:86`):
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner). `rig queue show` on such a row names the row the handoff made in
|
||||
`successors` (rows whose `handedOffFrom` is this row, and a local row its
|
||||
closure target names), or says in `successorNote` why none is named. A
|
||||
cross-host successor is named as recorded (`<id>@<host>`); nothing stored
|
||||
changes. Past 20 such rows the newest 20 are named, with
|
||||
`successorsTruncated: true`. A scoped MCP caller doesn't get these fields;
|
||||
`rig_queue_show`'s scope-checked `successor` answers it instead.
|
||||
new owner).
|
||||
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
|
||||
To wait for a qitem, use `rig queue block <id> --on <child>` or
|
||||
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
|
||||
|
||||
@@ -160,9 +160,8 @@ Restore behavior, each point checked in `restore-orchestrator.ts`:
|
||||
`:780`; if `status === "restoring"` the node is skipped with a warning and
|
||||
reported as `fresh`, `:783–785`; `degraded` only adds a warning).
|
||||
- Reuses the seat's durably bound tmux session name when the prior binding
|
||||
has a usable one, and otherwise falls back to the derived name. A missing
|
||||
binding after the latest session's recorded intentional exit is quiet;
|
||||
unclean/legacy missing bindings and incomplete or invalid binding rows still warn.
|
||||
has a usable one (`:840`), and otherwise falls back to the derived name
|
||||
with a warning (`:869`).
|
||||
- Replays restore-safe startup using persisted startup context;
|
||||
prefilters missing optional artifacts into warnings; **hard-fails a node if
|
||||
a required startup file is missing** (`:1161` — status `"failed"`, error
|
||||
|
||||
@@ -32,12 +32,13 @@ Two independent naming axes meet in this layer. They must not be conflated or
|
||||
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
|
||||
substitution.
|
||||
|
||||
**Axis 1 — MCP tool names are `rig_*`.** Every tool
|
||||
`packages/cli/src/mcp-server.ts` registers is named `rig_*`, among them
|
||||
`rig_rig_nodes`, `rig_send`, `rig_capture`, `rig_chatroom_send`,
|
||||
`rig_chatroom_watch`, the `rig_queue_*` tools and `rig_telemetry_events`.
|
||||
Count them with `grep -c 'server.registerTool(' packages/cli/src/mcp-server.ts`;
|
||||
each registration depends on the caller's access, so a caller can see fewer. No `rigged_*` name other than the tmux keys below remains in product
|
||||
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
|
||||
registers **24** tools, all named `rig_*`
|
||||
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
|
||||
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
|
||||
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
|
||||
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
|
||||
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
@@ -168,19 +169,9 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
posture, one for each mode OpenRig can launch: bypass
|
||||
(`⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents`), accept
|
||||
edits (OpenRig's default), auto, don't ask, plan, and default
|
||||
(`⏸ manual mode on · ← for agents`); between the closing rule and the
|
||||
footer, either no row (OpenRig's statusLine collector prints nothing) or
|
||||
the rows a person's own statusLine prints, with the indicator Claude
|
||||
draws at the right of the first one (`● high · /effort`), trusted only by
|
||||
position, as the composer reader trusts them (#2703, #2798): the closing
|
||||
rule is the first rule above the footer, the frame above it must check
|
||||
out, and no row between them may be blank or look like a menu or dialog
|
||||
(`❯`, `›` or `»` anywhere in the row, a row that starts with a numbered
|
||||
choice such as `1. ` or `2) `, with or without a border or gutter before
|
||||
it, a box corner, or a row framed by side borders); so a statusLine that
|
||||
uses `›`, `»` or `❯` as a separator, prints a blank row or starts a row
|
||||
with a number and a dot reads `unknown`; a blank, effort or tmux-notice
|
||||
row above the box; and above
|
||||
(`⏸ manual mode on · ← for agents`); no statusLine row between the
|
||||
closing rule and the footer (OpenRig's statusLine collector prints
|
||||
nothing); a blank, effort or tmux-notice row above the box; and above
|
||||
that, in the status slot, the completion row of the seat's last turn
|
||||
(`✻ <verb> for <duration> · done <time>` at column 0, matched by the
|
||||
Claude Code 2.1.296 bundle's grammar: one of its eight past-tense verbs,
|
||||
@@ -213,15 +204,13 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
status row, `esc to interrupt`, `paste again to expand`, a queued label,
|
||||
Codex's pending-input headers or `↳` rows, a spinner). Any other frame,
|
||||
including every running, streaming, steer-preview, paste-hint or unknown
|
||||
layout, another seat's name, a second rule drawn under the box, a blank,
|
||||
prompt-, menu- or dialog-shaped row between the closing rule and the
|
||||
footer (so a fullscreen renderer that reserves a blank row for the
|
||||
statusLine reads as before), and a background shell in the footer, reads
|
||||
`seat: not-idle` or `unknown`, and the send reads unconfirmed. This
|
||||
holds when a running turn hides its signal (Codex hides its `• Working`
|
||||
row while it streams; Claude Code shows only `paste again to expand` in
|
||||
its footer for 8 s after a paste, and no `esc to interrupt` under a
|
||||
statusLine). So a send to
|
||||
layout, another seat's name, a statusLine that prints a row, a background
|
||||
shell in the footer and a fullscreen renderer with a statusLine (which
|
||||
reserves a row for it), reads `seat: not-idle` or
|
||||
`unknown`, and the send reads unconfirmed. This holds when a running turn
|
||||
hides its signal (Codex hides its `• Working` row while it streams;
|
||||
Claude Code shows only `paste again to expand` in its footer for 8 s
|
||||
after a paste, and no `esc to interrupt` under a statusLine). So a send to
|
||||
a busy or unknown seat always reads unconfirmed.
|
||||
|
||||
**Versions.** The layouts were recorded from Claude Code 2.1.296 and
|
||||
@@ -283,13 +272,7 @@ Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
characters never widen it. Any other break, and a break inside a word of
|
||||
the text, reads `ambiguous` and the send stays unconfirmed. A collapsed paste label, Claude's
|
||||
queued-messages label or `Try "…"` suggestion, or another draft is never
|
||||
the message. The default `--verify` matcher above is unchanged. Claude
|
||||
Code's box ends at the last rule on the screen; the rows under it up to
|
||||
the footer are a person's statusLine, read by position with the same
|
||||
rule as the seat check above (#2798), so a box with statusLine rows
|
||||
under it reads as one without them, and a blank, prompt-, menu- or
|
||||
dialog-shaped row there leaves it `unrecognized`. The startup Enter
|
||||
guard reads the same region.
|
||||
the message. The default `--verify` matcher above is unchanged.
|
||||
- **The box is empty after Enter on the same pane process.** The capture
|
||||
after Enter must show the box empty, with the pane process
|
||||
(`#{pane_pid}`) and the seat's binding unchanged.
|
||||
|
||||
@@ -84,15 +84,6 @@ Diagnostic-only rows, moved sources, and versions retained for unfinished
|
||||
work have separate paths in `readThrough`, `writeDiagnostic`, and
|
||||
`isPinnedByUnfinishedWork`.
|
||||
|
||||
Only instantiate (and `rig workflow run`, which posts to the same route) and
|
||||
the workflows-folder scan register specs through `readThrough`.
|
||||
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
|
||||
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
|
||||
checks, in memory, with no `workflow_specs` write. A spec that was only
|
||||
validated from outside the workflows folder is not listed by
|
||||
`rig workflow specs` and is not found by its bare name; its path still
|
||||
instantiates (#2726).
|
||||
|
||||
## Durable state
|
||||
|
||||
The migration files record the storage evolution; they are not a count of
|
||||
|
||||
@@ -387,28 +387,14 @@ that selects by mission. A slice's `qitemIds` come first from rows tagged
|
||||
project-scoped indexer only rows tagged `project:<id>` count, so untagged
|
||||
historical rows are not attributed to a selected project.
|
||||
|
||||
The `GET /api/slices` response keeps that legacy `qitemCount` separate from
|
||||
`queueCounts`, eight current-state counts (or `null` when the listed
|
||||
mission/project scope cannot be resolved or catalog/candidate-root discovery is
|
||||
incomplete). `queueCounts` matches exact typed tags in one uncapped queue scan
|
||||
and is recomputed on every request; `?refresh` invalidates the separate listing
|
||||
cache, not a queue-count snapshot. A row may omit `project:` or `mission:` only
|
||||
when its remaining scope tokens identify exactly one physical slice with a
|
||||
resolved project and mission across the configured indexer roots and the
|
||||
catalog projects' missions roots. If catalog/project resolution or a candidate
|
||||
root cannot be read completely, counts stay `null` rather than treating unseen
|
||||
candidates as absent. Ambiguous matches count nowhere, including matches hidden
|
||||
by first-wins listing or a list filter. An explicit mismatched project or
|
||||
mission never counts. Physical candidates are used for counting only and do
|
||||
not change which slice is listed or served.
|
||||
|
||||
> Source: `domain/slices/slice-indexer.ts` (read-only/no-new-state module
|
||||
> contract; `SliceRecord.qitemIds` / `SliceRecord.proofPacket`;
|
||||
> `DEFAULT_CACHE_TTL_MS`; `isReady()`; `invalidate()`;
|
||||
> `reconcileComposition()`; `readSliceLocations()`;
|
||||
> `observedQueueCounts()` physical-candidate matching and the shared queue
|
||||
> scan; `toListEntry()` / `matchQitems()` legacy membership);
|
||||
> `domain/workspace/project-catalog.ts:132–138`
|
||||
> Source: `domain/slices/slice-indexer.ts:2–15` (contract + no new state),
|
||||
> `:238` (TTL), `:297–299` (`isReady`), `:331–336` (`invalidate`),
|
||||
> `:457–472` (`reconcileComposition`), `:476–521` (`readSliceLocations`;
|
||||
> first name wins `:478–483`; nested-vs-flat `:495–517`), `:61–88`
|
||||
> (`SliceRecord`: `missionId` `:65`, `slicePath` `:67`, `qitemIds` `:77`,
|
||||
> `proofPacket` `:81`), `:90–111` (`SliceListEntry`, which carries
|
||||
> `qitemCount` / `hasProofPacket` instead), `:865–875` and `:936–946` (queue
|
||||
> membership; project filter `:870`, `:942`); `domain/workspace/project-catalog.ts:132–138`
|
||||
> (`belongsToProject`); startup `startup.ts:1705`, `:1718` (`OPENRIG_SLICES_ROOT`
|
||||
> first), `:1725–1730`, `:1743–1748`, proof-source watch `:1762–1765`
|
||||
> (`domain/proof/source-watch.ts:16`); default
|
||||
@@ -447,16 +433,7 @@ handlers (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/slices.ts`)
|
||||
`?boundToWorkflow=<name>:<version>` lens narrows to slices bound to a
|
||||
workflow instance (400 `boundToWorkflow_invalid` without the colon). Each
|
||||
slice row carries `readiness`, and a `missions` sidecar carries authored
|
||||
mission status and readiness. The list row also keeps legacy `qitemCount`
|
||||
separate from `queueCounts`: the latter has one count for each supported
|
||||
queue state, or `null` when the row's project/mission scope is unresolved.
|
||||
It counts rows whose exact typed tags match one physical slice with a
|
||||
resolved project and mission across all configured roots. A missing
|
||||
`project:` or `mission:` is accepted only when the remaining tags select
|
||||
exactly one such candidate; ambiguous candidates count nowhere, even if
|
||||
first-wins discovery or the status filter exposes only one
|
||||
listed row. Explicit mismatched qualifiers never count. Counting does not
|
||||
change list or filter behavior. Rows sort by last activity, newest first.
|
||||
mission status and readiness. Rows sort by last activity, newest first.
|
||||
- `POST /refresh` — drops the indexer caches, no daemon restart.
|
||||
- `GET /:name/proof-asset/*` — serves a proof asset from the slice's matched
|
||||
dogfood-evidence directory; rejects `..` (400); `Cache-Control: public,
|
||||
|
||||
@@ -9,24 +9,23 @@ applies-when: |
|
||||
the source that implements a command. Use command help for invocation details.
|
||||
siblings: [README.md, codemap.md]
|
||||
prerequisite-reads: [../reference/help.md]
|
||||
last-verified-against-source: b29a70f14fc11797d46cbd9f12d4e61a523c3d06
|
||||
last-updated: 2026-11-09
|
||||
last-verified-against-source: 34fdcb80543903b054a982cc48bb302ae6871682
|
||||
last-updated: 2026-10-26
|
||||
---
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against the source commit in this page's `last-verified-against-source`
|
||||
frontmatter.
|
||||
Verified against source commit `34fdcb80543903b054a982cc48bb302ae6871682`.
|
||||
The inventory below comes from the actual Commander tree returned by
|
||||
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
|
||||
strings or an installed CLI from a different commit.
|
||||
|
||||
There are **92 top-level registrations**, **440 registered command objects**
|
||||
There are **90 top-level registrations**, **410 registered command objects**
|
||||
below `rig` (including groups and the hidden `restore apply` command), and
|
||||
**1,460 explicitly registered option objects**, including the root version
|
||||
**1,304 explicitly registered option objects**, including the root version
|
||||
option. Aliases do not add command objects; short/long spellings of one
|
||||
option do not add option objects. Commander-generated help is additional.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. Every registered family is inside the snapshot: the `exec` and `environment` families, the `roster` and `telemetry` rows, and the `host edit`, `host remove` and `terminal attach` rows that earlier snapshots listed as later additions are all counted here, as are the herdr-app provider's `terminal close` row and its `--app-home` options.
|
||||
These are counts for that source snapshot, not a claim about a deployed release. The provider-switch and set-runtime rows include the later account-home options; those additions are not included in the snapshot counts. The `environment` section and the `host edit`, `host remove` and `terminal attach` rows were added later from source at `522d635c`, and aren't in the snapshot counts either. With `environment`, main registers 91 top-level commands.
|
||||
|
||||
## Reading this reference
|
||||
|
||||
@@ -189,7 +188,7 @@ the sections that were not read are `null`.
|
||||
|---|---|---|
|
||||
| `rig gateway` | — | — |
|
||||
| `rig gateway human` | — | — |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` (repeatable; without one, the person is reached in the Feed and Steering only)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human add <entityId>` | — | `--display-name <name>` **required**<br>`--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>` **required** (repeatable)<br>`--delivery-class <A\|B\|C\|D>` **required**<br>`--away`<br>`--replace`<br>`--reason <reason>`<br>`--actor <actor>`<br>`--organization <id>`<br>`--group <id>` (repeatable)<br>`--policy <text>`<br>`--policy-file <path>`<br>`--default` |
|
||||
| `rig gateway human list` | — | `--json` |
|
||||
| `rig gateway human quiet` | — | — |
|
||||
| `rig gateway human quiet set <entityId>` | — | `--daily <window>`<br>`--until <time>`<br>`--tz <zone>`<br>`--actor <actor>`<br>`--json` |
|
||||
@@ -227,7 +226,6 @@ the sections that were not read are `null`.
|
||||
`groups`, `warnings` and `participantFile`. `default` with no argument prints the current default and its source
|
||||
(`chosen` or `only-person`), or `null` with the reason. See `docs/reference/gateway-participants.md`.
|
||||
- `human set <entityId> slack-subscription <value>` — sets the person's Slack subscription: `every-ask` (the default) posts every ask to them to Slack; `urgent-only` posts only asks created with `--alert`, and other asks wait on Steering and the TUI; `unset` removes the authored subscription so the default applies. `human show` prints the value with its origin, `authored` or `default` (`slack-subscription: every-ask (default)`); `--json` carries it as `record.slackSubscription` `{ value, source }`.
|
||||
- `human set <entityId> binding.<n> <spec|unset>` — replaces `binding.<n>` with a full `kind:connectorRef:secretsRef:role[:handle=<id>]` spec, or removes that slot when `<value>` is `unset`. Removing a binding keeps the person, their preferences, their other bindings and their open questions, and prints `remainingBindings` (with their shifted `binding.<n>` slot indexes; the primary's slot shifts down too when a lower-index secondary is removed) plus `note`. No replacement `primary` is chosen silently: unsetting a `primary` while secondary bindings remain is refused, teaching how to replace the primary slot in place, promote an existing secondary binding (first save that binding's full spec from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` or `rig gateway human show <entityId> --json` and set it to the saved spec with role `primary` — promotion replaces the former primary binding, and its connector reference and inbound handle are not retained as a secondary), or unset the secondary slots first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>` and finish at `binding.0` when removing every binding. Removing the last binding makes the current delivery episode inactive so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain, and the current delivery-outcome display may disappear while historical receipt notes remain); it does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.
|
||||
|
||||
### parked
|
||||
|
||||
@@ -365,36 +363,23 @@ even when it is bound to a tailnet address and so starts without one: pairing ha
|
||||
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
|
||||
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
|
||||
registered person can approve (none, or several with no default; name one with
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:379-398`).
|
||||
`--human`) (`packages/daemon/src/routes/hosts.ts:324-343`).
|
||||
|
||||
The request stays open on the target for as long as `--timeout` waits (600 seconds by default), and the person's
|
||||
approval says how long and until when, in UTC. A target that reports who was asked (`person`) and who reads their answer (`owner`), or why
|
||||
nobody does yet (`noReader`), has `rig host pair` name them; an older target's reply keeps the general text. With
|
||||
nobody registered on the target, its refusal (409 `pair_human_required`) says to register a person there:
|
||||
`rig gateway human add <entityId> --display-name <name> --delivery-class B --default`. A target older than this CLI ignores the timeout and keeps the request
|
||||
approval says how long and until when, in UTC. A target older than this CLI ignores the timeout and keeps the request
|
||||
10 minutes.
|
||||
|
||||
On a 0.7.11 or later target, its person answers the request in the TUI (`rig tui`, then `attention`: open it), on
|
||||
Steering, or in Slack: with Approve or Deny, or in their own words. Typed words never deny it.
|
||||
- **When the target has a receiving agent** (the one chosen in `agents.operator_session`, else its kernel operator
|
||||
`operator-agent@kernel`), it asks. The person's answer, a click or their words, comes back to it as a reply, and it
|
||||
decides with `rig host approve <code>` or `rig host deny <code>`. It asks the person back when the answer isn't clear.
|
||||
The person is told once that their answer was received. If the agent isn't running, the request and that receipt say
|
||||
so, and they name the direct controls (below). The request stays pending, never granted or denied, until a decision or
|
||||
expiry. If the agent doesn't pick up the answer, the person is told at expiry that it didn't.
|
||||
- **When the target has no receiving agent** (or the queue can't send rows to it, or OpenRig couldn't read which; the
|
||||
request says which, and names a chosen agent it couldn't use), the person's answer, a click or their words, is kept on
|
||||
the request and decides nothing. (A Steering Deny or Drop, which closes the request, still refuses it.) The person
|
||||
gets one receipt naming the direct controls. If a receiving agent is set
|
||||
before the request expires, it gets their answer once and decides. (On 0.7.11, Approve and Deny decided at once here,
|
||||
and words were asked again.)
|
||||
- **The direct controls:** the target's TUI pairing settings (`rig tui`, then Settings, Instance & work, Pairing
|
||||
requests; the Feed's request opens it there), or `rig host approve|deny` below. The TUI lists the open requests from
|
||||
`GET /api/hosts/pair-requests` (code, requester, expiry, status, who reads the answer, and the person's kept answer,
|
||||
so an Approve over a kept Deny is made knowingly; no credential), reads the
|
||||
request again before Approve or Deny, and decides through `POST /api/hosts/pair-decision` with its operator bearer
|
||||
token. A decision it can't confirm (a timeout, a lost connection or a server error) reads "outcome unknown" and is
|
||||
read again, never sent twice.
|
||||
- **When the target has a kernel operator (`operator-agent@kernel`),** it asks. The person's answer, a click or their
|
||||
words, comes back to it as a reply, and it decides with `rig host approve <code>` or `rig host deny <code>`. It asks
|
||||
the person back when the answer isn't clear. The person is told once that their answer was received. If the operator
|
||||
isn't running, the request and that receipt say so, and they name the terminal route. The request stays pending, never
|
||||
granted or denied, until the operator decides or the request expires. If the operator doesn't pick up the answer, the
|
||||
person is told at expiry that it didn't.
|
||||
- **When the target has no kernel operator** (or the queue can't send rows to it, or OpenRig couldn't read which; the
|
||||
request says which), Approve and Deny decide at once. A reply in words, or with none (a file), is kept on the
|
||||
request, and the person is asked again with a new request.
|
||||
- **Either way,** the outcome reaches the person as its own update: approved or denied, and by whom.
|
||||
|
||||
On either target, `rig host approve <request>` or `rig host deny <request>` (the pairing code, or the request's
|
||||
@@ -402,21 +387,14 @@ approval row id), run on the target from an agent seat or a plain terminal, deci
|
||||
token: an agent seat carries `OPENRIG_AUTH_BEARER_TOKEN`, and a person sets it or passes `--bearer <token>`. Without
|
||||
it the daemon answers 401, and nothing is decided. It decides once, and refuses an
|
||||
unknown (404 `pair_request_unknown`), expired (409 `pair_request_expired`) or already-decided (409
|
||||
`pair_request_decided`) request, including one its row already decided before the requester's next check (an agent
|
||||
seat's close naming Approve, or a refusing close). A close never approves over the person's recorded Deny. It records who decided: the seat, or `human@host` from a plain terminal. The person
|
||||
`pair_request_decided`) request, including one its row already decided before the requester's next check (a click on
|
||||
a target with no kernel operator, or a refusing close). It records who decided: the seat, or `human@host` from a plain terminal. The person
|
||||
gets one update with the result. An agent seat's `rig queue update <id> --state done --closure-reason no-follow-on
|
||||
--resolution approve` on an unanswered request still approves, and any other close that carries no answer and isn't
|
||||
the person's own reply, or `--state denied`, refuses. To refuse a pairing, use Deny on the target's pairing settings or on Steering, run
|
||||
the person's own reply, or `--state denied`, refuses. To refuse a pairing, answer Deny on a target with no kernel operator, run
|
||||
`rig host deny`, or let it expire (nothing is persisted). At its deadline a request's ask closes as expired, even when the requester stopped waiting, so a late click there isn't
|
||||
accepted. The decisions live in the daemon's memory, as the requests do, so a daemon restart drops an open request.
|
||||
|
||||
A 0.7.10 or earlier target asks no question. Its person approves with the request's Approve, in the TUI (`rig tui`,
|
||||
then `attention`: open it; the TUI from 0.7.1) or on Steering, and from an agent seat `rig queue update <id> --state done
|
||||
--closure-reason no-follow-on` approves. There, every close that leaves the request `done` approves it, whatever its
|
||||
closure reason: a typed reply, or an agent seat's `rig queue update <id> --state done --closure-reason denied`,
|
||||
included. To refuse a pairing on such a target, let it expire, or from an agent seat set the request `denied`
|
||||
(`rig queue update <id> --state denied`); a `canceled` or `failed` state refuses too.
|
||||
|
||||
Registration makes a host visible in topology; remote FEED attention remains opt-in.
|
||||
The human-readable success output of `host add` and `host pair` now includes the FEED
|
||||
next step alongside the existing `host doctor` suggestion. For an HTTP entry named
|
||||
@@ -456,7 +434,7 @@ do not establish connectivity to a live peer.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig mcp` | — | — |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>`<br>`--front-door`<br>`--task-destination <seat>` |
|
||||
| `rig mcp serve` | — | `--port <port>`<br>`--http`<br>`--bind <host:port>`<br>`--grant-service <url>`<br>`--grant-service-header-file <path>`<br>`--oauth-resource <url>`<br>`--oauth-issuer <url>` |
|
||||
| `rig mcp token` | — | — |
|
||||
| `rig mcp token add <name>` | — | `--seat <session>` **required**<br>`--rig <name...>`<br>`--root <name...>`<br>`--workspace`<br>`--owner` |
|
||||
| `rig mcp token list` | — | `--json` |
|
||||
@@ -541,7 +519,7 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
|
||||
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
|
||||
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--source <name@external>`<br>`--host <id>`<br>`--full`<br>`--json` |
|
||||
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--host <id>`<br>`--full`<br>`--json` |
|
||||
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--host <id>`<br>`--full`<br>`--json` |
|
||||
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
|
||||
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
|
||||
@@ -560,7 +538,6 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| `rig queue inbox-pending <destinationSession>` | — | `--json` |
|
||||
| `rig queue outbox-record` | — | `--sender <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--tags <tags>`<br>`--urgency <urgency>`<br>`--audit <pointer>`<br>`--id <outboxId>`<br>`--json` |
|
||||
| `rig queue outbox-list <senderSession>` | — | `--limit <n>`<br>`--json` |
|
||||
| `rig queue erase` | — | `--members <file>`<br>`--rig <id>`<br>`--ids <file>`<br>`--action <action>` (`delete`, `scrub`)<br>`--routine`<br>`--apply`<br>`--actor <session>`<br>`--json` |
|
||||
|
||||
### slack
|
||||
|
||||
@@ -620,16 +597,13 @@ default-permit record (tag `runner-permission-permit`) opens no row.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig terminal` | — | — |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--app-home <dir>`<br>`--expected-plan <id>`<br>`--follow`<br>`--no-follow`<br>`--max-tiles <count>`<br>`--pin <seats...>`<br>`--unpin <seats...>`<br>`--suppress <seats...>`<br>`--unsuppress <seats...>`<br>`--suppression-ms <ms>`<br>`--grace-ms <ms>`<br>`--focus-retention-ms <ms>`<br>`--json` |
|
||||
| `rig terminal open <view>` | — | `--provider <name>`<br>`--session <name>`<br>`--window`<br>`--expected-plan <id>`<br>`--json` |
|
||||
| `rig terminal views` | — | `--json` |
|
||||
| `rig terminal close <view>` | — | `--app-home <dir>`<br>`--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--app-home <dir>`<br>`--json` |
|
||||
| `rig terminal status` | — | `--provider <name>`<br>`--session <name>`<br>`--json` |
|
||||
| `rig terminal attach <session>` | — | `--read-only` |
|
||||
|
||||
See [named terminal sessions](../reference/terminal-sessions.md) to select a
|
||||
Herdr session per request, including session-bound previews and window reuse.
|
||||
herdr-app views (`--provider herdr-app`, plain `rig terminal open <view>` inside an answering herdr-app terminal versus `--window` for a desktop window, `close`, `--app-home`) are in
|
||||
[herdr-app](../reference/herdr-app.md).
|
||||
|
||||
### watchdog
|
||||
|
||||
@@ -728,9 +702,6 @@ Only Claude and Codex tenures can be woken.
|
||||
| Invocation | Aliases | Declared options |
|
||||
|---|---|---|
|
||||
| `rig context` | — | — |
|
||||
| `rig context reconstruction` | — | — |
|
||||
| `rig context reconstruction inspect` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
|
||||
| `rig context reconstruction view` | — | `--store <absolute-path>`<br>`--store-map <file>`<br>`--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--cwd <path>`<br>`--item <id>`<br>`--category <name>`<br>`--tag <name>`<br>`--check-sources`<br>`--max-bytes <bytes>`<br>`--json` |
|
||||
| `rig context work-install` | — | `--project <id>`<br>`--mission <id>`<br>`--slice <id>`<br>`--deliver`<br>`--runtime <runtime>`<br>`--cwd <path>`<br>`--topology <ids>`<br>`--apply-skills`<br>`--json` |
|
||||
| `rig context trace` | — | `--rig <rig>` **required**<br>`--pod <pod>`<br>`--seat <seat>`<br>`--name <file>` **required**<br>`--json` |
|
||||
| `rig context compose` | — | `--out <ref>` **required**<br>`--from <files...>` **required** |
|
||||
@@ -739,7 +710,7 @@ Only Claude and Codex tenures can be woken.
|
||||
| `rig context preview <name-or-ref>` | — | `--json` |
|
||||
| `rig context get <name-or-ref>` | — | `--json` |
|
||||
| `rig context profile <name-or-ref>` | — | `--situation <situation>` **required**<br>`--runtime <runtime>`<br>`--profile <profile>`<br>`--budget <tokens>`<br>`--rig <rig>`<br>`--seat <seat>`<br>`--mission <mission>`<br>`--slice <slice>`<br>`--json` |
|
||||
| `rig context recap-write` | — | `--rig <rig>` (default: the calling seat's rig)<br>`--seat <seat>` (default: the calling seat; the part of its session name before `@`)<br>`--file <path>` **required** |
|
||||
| `rig context recap-write` | — | `--rig <rig>` **required**<br>`--seat <seat>` **required**<br>`--file <path>` **required** |
|
||||
| `rig context sync` | — | `--json` |
|
||||
| `rig context add <source>` | — | `--name <name>`<br>`--git`<br>`--checkout`<br>`--pack <path>`<br>`--json` |
|
||||
| `rig context source` | — | — |
|
||||
@@ -1093,7 +1064,6 @@ for input and receipt examples, rollback, and snapshot behavior.
|
||||
| `rig seat set-permissions <seat>` | — | `--mode <mode>`<br>`--rules <file>`<br>`--codex-permissions <file>`<br>`--codex-profile <name>`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-effort <seat>` | — | `--effort <level>`<br>`--clear`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-permissions-to <seat>` | — | `--to <person>`<br>`--clear`<br>`--deny`<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat decide-permission <request>` | — | `--option <id>` **required**<br>`--reason <text>`<br>`--json` |
|
||||
| `rig seat effort <seat>` | — | `--json` |
|
||||
| `rig seat set-model <seat>` | — | `--model <id>` **required**<br>`--reason <text>` **required**<br>`--operator <address>`<br>`--json` |
|
||||
| `rig seat set-runtime <seat>` | — | `--runtime <runtime>`<br>`--reason <text>`<br>`--model <id>`<br>`--effort <level>`<br>`--codex-config-profile <name>`<br>`--clear-account-home`<br>`--account-home <path>`<br>`--token-file <path>`<br>`--account-label <label>`<br>`--permission-mode <mode>`<br>`--confirm-timeout <seconds>`<br>`--dry-run`<br>`--confirm <switch-id>`<br>`--note <text>`<br>`--recover`<br>`--json` |
|
||||
@@ -1140,28 +1110,6 @@ off, draft-aware and hold modes, immediate refusal results and held-message insp
|
||||
|---|---|---|
|
||||
| `rig handover <seat>` | — | `--source <source>`<br>`--reason <reason>`<br>`--operator <address>`<br>`--dry-run`<br>`--json` |
|
||||
|
||||
A rebuild starts a new native conversation and primes it from durable artifacts. In the JSON result,
|
||||
`currentStatus.continuityOutcome` describes the executed source (`rebuilt`, `forked`, `fresh`, or unknown
|
||||
for a discovered successor); `sourceOutcome` lists the rebuild artifacts and gaps or the fork origin.
|
||||
The legacy `continuityTransferred` field is always `false`, not a transfer verdict. A complete binding
|
||||
does not prove a lineage write: `sideEffects.provenanceRecordWritten` reads back this handover's occupant
|
||||
tenure. If it is false, inspect the daemon log; do not repeat a handover whose seat is already bound.
|
||||
|
||||
For Codex rebuilds, `sourceOutcome.permissions` distinguishes an emitted predecessor policy, a saved
|
||||
successor selection, and an unverified fallback. A saved `--codex-permissions` envelope wins over the
|
||||
observed predecessor policy and retains the usual launch additions: workspace-write adds Git metadata,
|
||||
seat-folder and pod-state writable roots; read-only adds none. The result describes this launch policy,
|
||||
not an observed exact difference. A supported observed predecessor policy is carried without fresh
|
||||
directory additions when no newer saved selection replaces it.
|
||||
An unreadable or unsupported predecessor policy does not stop the rebuild; without a saved envelope,
|
||||
the ordinary launch settings apply and the result reports the missing comparison. Emitted arguments
|
||||
are not proof of the successor's native enforcement, and the named Codex config profile still loads.
|
||||
|
||||
`sourceOutcome.model.requested` is the seat's configured Codex model pin, or `null` for the runtime's
|
||||
configuration/default. A rebuild reapplies that choice rather than the predecessor's in-pane model;
|
||||
`predecessorCompared: false` explicitly reports that no native model comparison was performed. This
|
||||
reports launch intent, not the model actually used by the successor. Older daemons omit this object.
|
||||
|
||||
### startup-proof
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|
||||
@@ -8,7 +8,7 @@ applies-when: |
|
||||
read it before claiming a stub-agent scenario covers a behaviour.
|
||||
siblings: [arteries.md, README.md, codemap.md]
|
||||
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
|
||||
last-updated: 2026-11-09
|
||||
last-updated: 2026-10-08
|
||||
---
|
||||
|
||||
# Test layers: what to run before you push, and what each layer proves
|
||||
@@ -448,12 +448,9 @@ not as current status.
|
||||
|
||||
## Help wanted: command families without a behavioural scenario
|
||||
|
||||
> This list may be stale. The tables below cover the families that have a proposed first
|
||||
> check: at `b29a70f14`, the Commander tree returned by `createProgram()`
|
||||
> (`packages/cli/src/index.ts`) registers 92 top-level families, 87 of which have a row
|
||||
> below (7 of them marked partial); `roster` and `telemetry` now have rows too. These 5
|
||||
> families still have no row: `acp`, `community-harness`, `edge`, `environment`, `exec`.
|
||||
> Before picking one up, check `packages/test-system/scenarios/`, the cases in
|
||||
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
|
||||
> top-level families. At `e8f0ab34`, `packages/cli/src/index.ts` registers 87 top-level
|
||||
> commands; `roster` and `telemetry` were added since and have no row yet. Before picking one up, check `packages/test-system/scenarios/`, the cases in
|
||||
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
|
||||
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
|
||||
> check only proves the command is discoverable. Existing unit tests are not counted here.
|
||||
@@ -525,8 +522,6 @@ Before you start, two honest constraints:
|
||||
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
|
||||
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
|
||||
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
|
||||
| reads | `roster` | A scratch workspace with two rigs and a rosters folder: `rig roster find` returns the expected specialists, and an empty folder reports none without an error. |
|
||||
| reads | `telemetry` | Deterministic retained-metadata fixtures: one bounded page returns exactly the recorded rows, and a history gap is named rather than skipped silently. |
|
||||
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
|
||||
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
|
||||
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
|
||||
|
||||
@@ -459,8 +459,10 @@ skills:
|
||||
|
||||
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
|
||||
|
||||
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
|
||||
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
A restore keeps the skills a seat was launched with where it can. With `keep_saved`, it puts back a missing copy only
|
||||
while its recorded source still has the saved bytes, and never takes today's bytes from the source: if `rig down`
|
||||
removed an unchanged copy and the source has changed since, the skill stays missing and the restore names it in its
|
||||
warnings. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
|
||||
spec as it is now, using the saved launch recipe and rig folder, and:
|
||||
|
||||
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
|
||||
|
||||
@@ -75,20 +75,20 @@ in the rig's `attentionCount` and the TUI's attention totals.
|
||||
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
|
||||
the seat's activity next turns to running or idle, or when the whole restore completes
|
||||
after the seat already reads running or idle, the daemon checks the live seat: its tmux
|
||||
session is alive, and the agent in its pane was started to resume the conversation the
|
||||
seat records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When the check fails, the restore
|
||||
outcome stays and the seat still needs attention. The check uses launch evidence; it
|
||||
does not prove which conversation is open now.
|
||||
session is alive, and the pane runs the agent resuming the conversation the seat
|
||||
records. When the check passes, the restore outcome becomes `operator_recovered`, so
|
||||
the restore no longer makes the seat need attention. When it fails, the restore outcome
|
||||
stays and the seat still needs attention, which is true when the pane runs another
|
||||
conversation. A check that started before a newer restore of the seat doesn't settle
|
||||
the newer outcome.
|
||||
|
||||
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
|
||||
command says which check failed, and `--reason` doesn't bypass it. If the seat records
|
||||
the wrong conversation and the agent in its pane was started with the right one, set it
|
||||
with `rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again; a token the running agent wasn't started with fails the same
|
||||
check. After a restore of only some seats, or a community harness's assisted startup,
|
||||
`--reason "<what you saw>"` can acknowledge the outcome instead, once any startup text
|
||||
left in the input box is submitted (not sent again) and the seat has responded.
|
||||
command says which check failed, and `--reason` doesn't bypass it. If
|
||||
the seat records the wrong conversation and you know the right one, set it with
|
||||
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
|
||||
`clear-attention` again. After a restore of only some seats, or a community
|
||||
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
|
||||
outcome instead.
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
@@ -204,7 +204,7 @@ Startup content is merged additively through layers. Each layer adds to what the
|
||||
4. Rig layer — from the RigSpec's top-level startup block
|
||||
5. Pod layer — from the pod's startup block
|
||||
6. Member layer — from the member's startup block in the RigSpec
|
||||
7. Operator layer — added by OpenRig: openrig-start.md, openrig-context-entry.md and, on a fresh start, the onboarding pack
|
||||
7. Operator layer — added by OpenRig: openrig-start.md and, on a fresh start, the onboarding pack
|
||||
```
|
||||
|
||||
A member with a `starter_ref` gets that agent starter's files in front of the agent layer. Terminal seats
|
||||
@@ -220,7 +220,7 @@ A member with a `starter_ref` gets that agent starter's files in front of the ag
|
||||
| Rig | Rig spec author | Rig-wide context for all agents | Project documentation |
|
||||
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
|
||||
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
|
||||
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); `openrig-context-entry.md`, a pointer to the skills router, help and declared context, for every seat; on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
|
||||
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
|
||||
|
||||
After the files, OpenRig sends a session-identity message (rig, pod, member, logical ID) once the seat is ready.
|
||||
|
||||
@@ -278,10 +278,7 @@ with an old Claude header or input frame above it and Claude already running. A
|
||||
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
|
||||
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
|
||||
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
|
||||
unavailable; inspect its pane before deciding how to recover. When the launch was already sent, the failure's
|
||||
guidance names that pane and the result carries `replacementStarted` and `successorPane`: the previous agent was
|
||||
already ended, the new one may be working there without being registered as the seat's occupant, and another
|
||||
handover or `rig seat launch <seat> --fresh --stop --reason <why>` ends it if it goes ahead.
|
||||
unavailable; inspect its pane before deciding how to recover.
|
||||
|
||||
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
|
||||
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
|
||||
@@ -405,11 +402,6 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each block.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
@@ -431,30 +423,8 @@ running seat only through the action in the last column.
|
||||
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
|
||||
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi`, `codex` and `claude-code` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry or Claude executable (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed when wrapper support is unavailable; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
|
||||
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
|
||||
| Guidance blocks (`CLAUDE.md`, `AGENTS.md`) | The seat's startup selection, which OpenRig merges into its guidance file as managed blocks at each start and restore | `rig seat status` **Guidance**: when OpenRig wrote each of its blocks, before or after the session's recorded startup finish (for a claimed session, when OpenRig recorded it). That's chronology, not what the agent has: the harness reads the file earlier in startup, Claude Code reloads it after `/compact` or `/clear`, Codex reads it at session start, and the agent can read it, or be sent it, at any time. OpenRig doesn't record what an agent read | Claude: its next compaction, `/clear` or restart. Codex: its next start. Without either, [tell it](#new-guidance-for-a-running-seat) |
|
||||
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
|
||||
|
||||
### New guidance for a running seat
|
||||
|
||||
There are three ways for a seat to pick up new guidance:
|
||||
|
||||
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
|
||||
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
|
||||
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
|
||||
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
|
||||
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
|
||||
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
|
||||
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
|
||||
conversation grows past the hook's size threshold.
|
||||
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
|
||||
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
|
||||
|
||||
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
|
||||
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
|
||||
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
|
||||
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
|
||||
separate facts.
|
||||
|
||||
Per harness: Claude Code's and Codex's permission choices are in the setup reference
|
||||
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
|
||||
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
|
||||
|
||||
@@ -13,9 +13,7 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
earlier cache remains.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
@@ -86,23 +84,11 @@ so its rate-limit hook is attributed to that seat too. This is appropriate when
|
||||
the helper uses the same login, since the quota belongs to the account. A
|
||||
helper on a different login is attributed the same way, so its rate limit can
|
||||
hold pending handoffs to every seat in that seat's Claude pool for one retry
|
||||
interval, even when the seat's own login still has room. A reset time the
|
||||
helper's message states is not used: OpenRig keeps a stated reset only when the
|
||||
hook's Claude `session_id` is the seat's own resume token, so a helper never
|
||||
holds the pool past that one interval.
|
||||
interval, even when the seat's own login still has room. This attribution does
|
||||
not bind the hook to Claude's own native session.
|
||||
|
||||
The event carries no usage percentage or account identity. When the seat's own
|
||||
Claude states when the limit resets (for example `resets Nov 10, 4pm
|
||||
(America/Sao_Paulo)`, `resets in 2h 30m`, or the older `usage limit
|
||||
reached|<epoch>`), the hook relay reads that time on the seat's machine and
|
||||
forwards only the resulting timestamp (`resetsAt`), never the message text. It
|
||||
reads the reset only after the typed `rate_limit` failure; the text never
|
||||
decides that a seat is limited. A reset it can't read completely (an unknown or
|
||||
truncated time zone, an unsupported unit, a date that doesn't exist such as Feb
|
||||
30) or that is in the past or more than eight days away gives no reset. The
|
||||
daemon applies the same eight-day bound to any sender.
|
||||
|
||||
The event marks the Claude provider pool as limited and preserves a current
|
||||
The event carries no usage percentage, account identity or reset timestamp.
|
||||
It marks the Claude provider pool as limited and preserves a current
|
||||
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
|
||||
count as a request for human input; a newer permission prompt still takes
|
||||
precedence in the seat's activity display. Conflicting seat windows retain the
|
||||
@@ -113,15 +99,13 @@ Pending work handled by the wake ladder uses the existing local Claude pool
|
||||
declare different Claude config directories. If reset windows conflict within
|
||||
a proposed shared pool, only the seats reporting exhaustion are held; another
|
||||
seat's pending handoffs can still retry. An exhausted status line window with
|
||||
a known reset supplies the timer, and so does a reset the seat's own typed
|
||||
failure stated (`provider-reset`). Otherwise a typed failure uses
|
||||
a known reset supplies the timer. Otherwise a typed failure uses
|
||||
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
|
||||
existing wake jitter, and records `config-fallback`. That is a retry time, not a
|
||||
claim that the provider's quota will reset then. The timer releases the shared
|
||||
blocker once and retains the existing durable wake delivery behavior.
|
||||
|
||||
Authentication, billing and overloaded errors are not classified as usage
|
||||
limits. Claude limit banners are not scraped from the terminal, and older
|
||||
runtimes or failures that do not emit this hook still depend on status line
|
||||
data. Without either
|
||||
limits. Claude limit banners are not scraped, and older runtimes or failures
|
||||
that do not emit this hook still depend on status line data. Without either
|
||||
source, provider usage remains unknown.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/omo.yaml.
|
||||
# OmO Native (npm omo-ai 5.1.48) on the senpi engine — a Pi fork speaking Pi's RPC protocol.
|
||||
# With protocol: pi-rpc, OpenRig runs OmO through its Pi runner: structured activity, prompt/steer,
|
||||
# transcript isolation, and session file resumption.
|
||||
format: 1
|
||||
name: omo
|
||||
command: omo
|
||||
maintainer: Your name or team
|
||||
protocol: pi-rpc
|
||||
resume: true
|
||||
limits:
|
||||
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
|
||||
- credentials resolved via omo's agent directory or models.json
|
||||
- models declared via provider/id format (e.g. zai/glm-5.3-flash, openrouter/...)
|
||||
@@ -21,7 +21,7 @@ it never reads an unsupported capability as idle, working, ready or verified.
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. A [runner preset](#runner-presets-runner-true) whose runner follows the contract takes `rig send <seat> "/abort" --raw` as its cancel, but not while the seat needs input, and only as a request (see [Cancel](harness-runner-contract.md#6-cancel-requested-isnt-the-same-as-done)). To a plain preset, `/abort` is ordinary input. Say in the preset's `limits` whether the harness has a cancel and how to use it. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
@@ -53,7 +53,6 @@ startup_assisted: true # optional: startup submission needs a person's
|
||||
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
|
||||
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
|
||||
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
|
||||
# launch_wrapper: true # optional: a runner applies OPENRIG_LAUNCH_WRAPPER to its native child (below)
|
||||
```
|
||||
|
||||
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
|
||||
@@ -165,10 +164,6 @@ and reports what it's doing, as OpenRig's own Pi runner does. The seat's activit
|
||||
unavailable.
|
||||
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
|
||||
Enter as one prompt.
|
||||
- **`launch_wrapper: true` declares wrapper support and requires `runner: true`.** A member with a wrapper and a runner preset without this flag is refused at preflight and launch (not schema validation):
|
||||
`launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.` See the
|
||||
[harness and runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch)
|
||||
for the environment handoff, native-child execution and refusal requirements.
|
||||
|
||||
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
|
||||
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
|
||||
@@ -193,37 +188,6 @@ one at a time and in order, without making the agent wait. It finds the address
|
||||
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
|
||||
[harness and runner contract](harness-runner-contract.md).
|
||||
|
||||
## Pi-compatible RPC harnesses (`protocol: pi-rpc`)
|
||||
|
||||
For a community harness that is a fork of Pi or speaks Pi's CLI and JSONL RPC protocol (such as OmO Native on senpi or Prime Agent), set `protocol: pi-rpc`.
|
||||
|
||||
```yaml
|
||||
format: 1
|
||||
name: omo
|
||||
command: omo
|
||||
maintainer: Your name or team
|
||||
protocol: pi-rpc
|
||||
resume: true # only after you've checked a real reopen
|
||||
limits:
|
||||
- checked with omo 5.1.48 (senpi engine 2026.10.25-2)
|
||||
```
|
||||
|
||||
- **OpenRig runs the harness through its existing Pi runner.** Instead of screen scraping, the harness runs under `pi-runner` inside the seat's pane. The runner provides input buffering, paste framing, prompt/steer RPC queueing, clean transcript mirroring, and typed activity hook reporting to the daemon.
|
||||
- **`command` is the executable.** OpenRig resolves `command` (e.g. `omo` or `/usr/local/bin/senpi`) against the seat pane's environment, resolving symlinks and version-manager shims (such as `mise`) safely.
|
||||
- **Session identity and resume come from the runner.** Transcripts and sessions are persisted as JSONL files under `$OPENRIG_HOME/state/pi/<session>/sessions/`, and the fork's `get_state` reports the session file, so `launch_result` and `names_conversation` aren't needed.
|
||||
- **With `resume: true`** declared, resuming or restoring a seat continues that exact session file via `--session <file>`. Without it, the preset is launch-only; a restore that would resume its saved conversation waits for a fresh-start decision.
|
||||
- **A fork that reports no session file** reads as needing attention, and the launch doesn't read ready.
|
||||
- **A resume that comes up on another session file** is refused, and that file's identity isn't recorded, so the seat keeps the one it asked for. The seat reads as needing attention.
|
||||
- **Launch arguments a fork must accept.** When spawned under `pi-runner`, the executable named by `command` must accept Pi's core CLI arguments:
|
||||
- `--mode rpc`
|
||||
- `--session-dir <dir>`
|
||||
- `--name <session-name>`
|
||||
- `--approve` or `--no-approve` (depending on seat trust)
|
||||
- And conditionally when configured or resuming/forking: `--model <provider/id>`, `--thinking <level>`, `--session <sessionFile>`, or `--fork <sessionFile>`.
|
||||
Forks that reject these flags cannot currently run with `protocol: pi-rpc`.
|
||||
- **Skills, guidance, and model passthrough.** Skills are projected into `<agentDir>/skills/` and managed guidance into `AGENTS.md`. For a fork that reads Pi's variables (`PI_CODING_AGENT_DIR`, etc.), declared models are passed as `--model provider/id` with provider API key allowlisting, and effort is passed as `--thinking <level>`. Usage is not reported for a fork, and extension dialogs are handled as Pi's (cancelled when an extension requests interactive confirmation in a managed pane).
|
||||
- **Mutual exclusions:** `protocol: pi-rpc` manages session identity and activity through the Pi runner; it cannot be combined with `runner: true`, `launch_result: true`, `names_conversation: true`, `startup_assisted: true`, or `first_input: true`.
|
||||
|
||||
## An example: Hermes
|
||||
|
||||
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
|
||||
@@ -346,27 +310,9 @@ Its [README](community-harness-examples/dsh/README.md) has the setup and a check
|
||||
and maintain them as your own.
|
||||
|
||||
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner`, `passes_model` or `launch_wrapper` is refused by an older
|
||||
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
|
||||
## Community-maintained runner: nodecode (@smota)
|
||||
|
||||
A runner preset for [nodecode](https://nodecode.ai), a self-modifying Common Lisp agent with a headless JSON-lines mode
|
||||
(`nodecode rpc`). Listed here so it can be found; it isn't certified, shipped with OpenRig or supported by OpenRig's maintainers.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Maintainer | [@smota](https://github.com/smota) |
|
||||
| Repository | [smota/openrig-nodecode](https://github.com/smota/openrig-nodecode), Apache-2.0, no npm dependencies |
|
||||
| Tested with | Unreleased OpenRig `main` at `7078c754d` (its package version reads 0.7.1), built from source without fork-only commits; nodecode `0.1.13+98dd6dc80`; Node 22.23.4. Not tested on a released OpenRig (none knows `launch_wrapper`). |
|
||||
| Needs | an OpenRig that knows `launch_wrapper` (this file documents it). OpenRig 0.7.10 and earlier refuse the preset with "unknown field launch_wrapper". |
|
||||
| Test record | connector `d8dd4e6`: 95 unit checks (fake and native nodecode), `npm run test:live` (6 steps against the author's daemon built from that `main`) and an opt-in native permission-question proof with a seeded `:NOBODY` failure; results are the author's, not reproduced by OpenRig |
|
||||
| Declares | `launch_result`, `runner`, `resume`, `first_input`, `passes_model`, `launch_wrapper` |
|
||||
| Resume | `nodecode rpc <session>`; a session the gateway doesn't know is refused with `error: "missing"` (exit 3) and never replaced. The installer adds `--resume-policy last-completed`: when the session's last turn failed or was cancelled, the runner moves the head back to the last completed turn before taking input, so a resumed seat continues from its last good state; `--resume-policy head` (the bare runner's default) resumes at the head as left. Pass either flag to `scripts/install-preset.mjs` to choose. |
|
||||
| Reports | `active`, `PreToolUse`, `Stop` and `session_identity` to `/api/activity/hooks`; permission questions through `/api/activity/permission-requests` |
|
||||
| Fork commits | none needed. Stock `main` accepts the runner's `session_identity` report (the hook answers ok and the identity event is published) but does not persist a community resume token from it; the seat's identity reads unconfirmed, as for any community harness. Two fork-only daemon commits (`be17fa528`, `cbb109b62`) add that plus model and context readings, and are not part of this entry. |
|
||||
| Limits | no turn cancel; permissions bridged from nodecode's `organism.questions` only, and that proof is driven by a model prompt, so it is opt-in; a question reaches OpenRig only when every choice reads as allow or reject (once or always), others are answered in the pane by typing the exact choice, and only the first four choices are offered through OpenRig; model, provider and effort apply to fresh sessions, a resumed one stays pinned; its gateway keeps running after `rig down`; the installed preset runs each seat in its own nodecode profile (`--seat-profile auto`, sharing the operator's memories and skills), while the bare runner uses the operator's organism unless `--profile` is set; `node` must be on the pane's `PATH` |
|
||||
|
||||
## Pass the first startup input at launch
|
||||
|
||||
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
|
||||
|
||||
@@ -44,7 +44,7 @@ in the case named:
|
||||
| `routeTimings` | With `eventLoop`. |
|
||||
| `slowOperations` | When the slow-operation recorder runs. |
|
||||
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. In `stuckSweep`, `findingsRouted` counts the findings created since the daemon started (an open finding a later pass finds again is not counted again), `findingsOpen` is how many finding rows were still open after the last pass, and `lastOutcome` is `findings` when the last pass created, found again or closed one. |
|
||||
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
|
||||
|
||||
## Bearer tokens
|
||||
|
||||
@@ -210,17 +210,3 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
|
||||
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
|
||||
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
|
||||
across missions, as [Project workspace](project-workspace.md) says.
|
||||
- **Queue state counts on `GET /api/slices`:** every list row has `queueCounts`, a current, uncapped count by
|
||||
`pending`, `in-progress`, `done`, `blocked`, `failed`, `denied`, `canceled` and `handed-off`; it is `null` when
|
||||
the row's project/mission scope cannot be resolved or catalog/candidate-root discovery is incomplete. This is
|
||||
separate from the existing `qitemCount` display membership field. Counts match exact typed `project:`,
|
||||
`mission:` and `slice:` tags in one shared queue scan. Physical candidates include configured indexer roots
|
||||
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
|
||||
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
|
||||
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
|
||||
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
|
||||
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
|
||||
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
|
||||
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
|
||||
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
|
||||
resolution does not change the returned slice list or filters.
|
||||
@@ -44,15 +44,13 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
|
||||
|
||||
`delegates_to` also names a seat's orchestrator for the queue: the source of the `delegates_to` edge into a seat. The
|
||||
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
|
||||
with no incoming `delegates_to` edge has no default orchestrator. A seat with several incoming `delegates_to` edges has
|
||||
no single orchestrator either: its recovery goes to its declared `escalates_to` destination, or keeps the existing
|
||||
fallback, and the recovery says the parents were ambiguous. `spawned_by` plays no part here.
|
||||
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
|
||||
|
||||
### Fallback escalation across rigs
|
||||
|
||||
A seat with **no declared `delegates_to` parent, or several**, may declare one `escalates_to` destination. The wake ladder and
|
||||
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A
|
||||
single stopped or unbound declared parent still takes precedence, preserving the existing parent route and its fallback.
|
||||
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
|
||||
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
|
||||
parent still takes precedence, preserving the existing parent route and its fallback.
|
||||
|
||||
```yaml
|
||||
edges:
|
||||
|
||||
@@ -76,25 +76,6 @@ go to:
|
||||
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
|
||||
never changes `workspace.operator_seat_name`, which keeps its own meaning.
|
||||
|
||||
## The default receiving agent
|
||||
|
||||
The default person is who OpenRig asks. The default receiving agent is the agent that reads what a person answers when
|
||||
nothing else reads it: a request with no owner of its own, such as a host pairing request or a mode's scheduled end, and
|
||||
a Slack message no thread or channel route claims. It is:
|
||||
1. the agent chosen in `agents.operator_session` (`rig config set agents.operator_session <seat>@<rig>`; a person's
|
||||
address there is ignored);
|
||||
2. otherwise the kernel operator, `operator-agent@kernel`, when this host has that seat, running or stopped;
|
||||
3. otherwise none, and the request says no agent reads answers on this host.
|
||||
|
||||
A stopped receiving agent stays the receiver: answers wait for it, and the person is told it isn't running. OpenRig
|
||||
never picks whichever seat happens to be running. The TUI shows the effective agent, where it came from and its state
|
||||
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
|
||||
|
||||
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
|
||||
messages to the default receiving agent, read when the connector starts. With no receiving agent on the host, they go to
|
||||
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
|
||||
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
|
||||
|
||||
## In the TUI: whose queue, and who acts
|
||||
|
||||
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
|
||||
|
||||
@@ -23,19 +23,12 @@ OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Ma
|
||||
## 1. System Topology & Custody Boundaries
|
||||
|
||||
### Outbound Custody & Recovery
|
||||
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL` (in `push` mode) or holding the decision for connector pull (in `pull` mode).
|
||||
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first delivery pass, with no self-imposed outbound 64 KiB cap or multi-part splitting.
|
||||
- **Push mode (`OPENRIG_RELAY_MODE=push`, default)**:
|
||||
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
|
||||
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
|
||||
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`.
|
||||
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
|
||||
- **Pull mode (`OPENRIG_RELAY_MODE=pull`)**:
|
||||
- Client-only connectors that do not bind a listening HTTP server set `OPENRIG_RELAY_MODE=pull`. A pull profile is ready with `OPENRIG_RELAY_INBOUND_TOKEN` alone (`OPENRIG_RELAY_URL` and `OPENRIG_RELAY_OUTBOUND_TOKEN` are optional and unused in pull mode).
|
||||
- Outbound decisions remain in `DispatchBuffer` awaiting connector pull and are never treated as failed push destinations (`relay-owner-notification-transport-failed` is not recorded). While the outbox lists a notice (staged in `DispatchBuffer` and `relay-attempted-decisions.jsonl` for its `connectorRef`), it is not a missing post for up to **24 hours** (`PULL_OUTBOX_NEVER_POSTED_WINDOW_MS`) and reads `never-posted` (`undelivered-human-notice`) if still unacknowledged after that. A notice the outbox does not list (never staged, staging failed, or its `DispatchBuffer` or seen stores are unreadable, so the outbox answers `503`) uses the usual 120-second window (`NEVER_POSTED_WINDOW_MS`). This is reporting only: nothing is held, refused or re-sent.
|
||||
- The connector lists its pending complete envelopes via `GET /api/gateway/relay/outbox` (read-only; no consumer leases, read receipts, or required polling cadences) and acknowledges durable custody via `POST /api/gateway/relay/outbox/ack`.
|
||||
- An acknowledgment on `POST /api/gateway/relay/outbox/ack` has the exact same custody meaning as a push `2xx`: OpenRig records `relay-owner-notification-accepted` (closing informational `update` items as delivered), marks its local outbound seen/delivered guards, and drains the decision from `DispatchBuffer`. Duplicate acknowledgments reconcile idempotently (`alreadyAccepted: true`), and retries after a partial local write failure finish the remaining receipt and drain sequence.
|
||||
- **Custody boundary (both modes)**: Bridge acceptance (via push `2xx` or pull `POST /api/gateway/relay/outbox/ack`) confirms durable custody of the complete envelope by the connector only (projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not mean posted, seen, answered, or approved.
|
||||
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
|
||||
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
|
||||
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
|
||||
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
|
||||
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
|
||||
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
|
||||
|
||||
### Inbound Custody & Resolution
|
||||
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
|
||||
@@ -61,29 +54,21 @@ Each relay connector loads configuration from an absolute file referenced by the
|
||||
|
||||
### Required Secrets Keys
|
||||
|
||||
**Push mode (`OPENRIG_RELAY_MODE=push`, default when omitted):**
|
||||
```ini
|
||||
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
|
||||
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
```
|
||||
|
||||
**Pull mode (`OPENRIG_RELAY_MODE=pull`):**
|
||||
```ini
|
||||
OPENRIG_RELAY_MODE=pull
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
```
|
||||
|
||||
- `OPENRIG_RELAY_MODE`: Optional mode selector (`push` or `pull`; defaults to `push` when omitted). Any other value is rejected as `not-ready`.
|
||||
- `OPENRIG_RELAY_URL`: Required in `push` mode; ignored/unused in `pull` mode (`endpointUrl: null`). When required, must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
|
||||
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Required in `push` mode; ignored/unused in `pull` mode. Non-empty Bearer token sent by OpenRig on outbound HTTP `POST` requests to the bridge.
|
||||
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required in both `push` and `pull` modes to authenticate connector requests (`GET /api/gateway/relay/outbox`, `POST /api/gateway/relay/outbox/ack`, and `POST /api/gateway/relay/inbound`). Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single `push` profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
|
||||
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
|
||||
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
|
||||
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wire Protocol Specifications
|
||||
|
||||
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>` or `GET /api/gateway/relay/outbox`)
|
||||
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -108,13 +93,6 @@ OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
|
||||
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
|
||||
|
||||
### Pull Outbox Endpoints (`GET /api/gateway/relay/outbox` & `POST /api/gateway/relay/outbox/ack`)
|
||||
|
||||
Both endpoints require a direct loopback TCP peer (`127.0.0.1/8` or `::1`), reject browser cross-origin headers (`403`), and authenticate the caller via `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` (`401`). If the human registry, `DispatchBuffer` (`pendingStrict()`), or outbound `SeenStore` cannot be read, both return `503` (`human_registry_unavailable`, `dispatch_buffer_unavailable`, `seen_store_unavailable`, `queue_store_unavailable`, or `receipt_write_failed`) rather than returning an empty `deliveries` array.
|
||||
|
||||
- **`GET /api/gateway/relay/outbox?limit=<1..200>`** (default `50`): Returns `{ "ok": true, "connectorRef": "<ref>", "deliveries": [ <RelayOutboundEnvelope>, ... ] }` with complete pending envelopes from `DispatchBuffer` belonging to the authenticated `connectorRef` in FIFO buffer order. Strictly read-only (no leases or read receipts).
|
||||
- **`POST /api/gateway/relay/outbox/ack`**: Closed JSON object `{ "deliveryId": "<decisionId>" }` accepting any non-empty string `deliveryId` the outbox can list, within the 64 KiB request-body size bound (`413 payload_too_large`); unknown fields or an empty or non-string `deliveryId` return `400 invalid_envelope`. Finishes any remaining `relay-owner-notification-accepted` transition receipts, marks `slack-outbound-seen.jsonl` and `relay-delivered-decisions.jsonl`, releases the in-memory outbound poll guard, and drains `deliveryId` from `DispatchBuffer`. Returns `200 { "ok": true, "deliveryId": "<id>", "connectorRef": "<ref>", "alreadyAccepted": false }` (`"alreadyAccepted": true` on duplicate ack for the same `connectorRef`, or `404 unknown_delivery` if unknown or owned by another connector; duplicate and unknown acks are answered from `relay-delivered-decisions.jsonl` and `relay-attempted-decisions.jsonl`, and the transition receipt scan runs only when those disagree). On any `503` partial failure, a retry with the same `deliveryId` finishes the remaining receipt and drain steps.
|
||||
|
||||
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
|
||||
|
||||
```json
|
||||
@@ -194,10 +172,7 @@ rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/ope
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `rig gateway human set alice binding.<n>` accepts either a full binding specification string or `unset` to remove that binding slot:
|
||||
> - **One-primary rule:** No replacement primary is chosen silently. If removing a `primary` binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (`kind:connectorRef:secretsRef:role[:handle=<id>]`) from `rig gateway human show <entityId> --json`, then unset that secondary slot, then find the primary's current slot in `remainingBindings` (or `rig gateway human show <entityId> --json`, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role `primary` (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from `remainingBindings` or `rig gateway human show <entityId>`, then finish at `binding.0` (once all secondaries are gone, the primary is at `binding.0`).
|
||||
> - **Shifted slot indexes:** Removal returns `remainingBindings` with their new `binding.<n>` slot numbers (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift down after removal, do not blindly retry `binding.0 unset`; inspect `remainingBindings`, the operation receipt, or `rig gateway human show <entityId>` before retrying.
|
||||
> - **What removal does:** Removing a binding keeps the person, their preferences, their other bindings, and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close, or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted. A person with no binding needs a daemon from the release that accepts one (v0.7.9+); do not roll back past it while any fragment has `connectorBindings: []`.
|
||||
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
|
||||
|
||||
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
|
||||
|
||||
@@ -228,11 +203,7 @@ rig daemon status
|
||||
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
|
||||
|
||||
### Step 5: Removing Bindings or Participants
|
||||
To remove a connector binding while keeping the participant, their preferences, their other bindings, and their open questions, unset the binding slot:
|
||||
```bash
|
||||
rig gateway human set alice binding.0 unset --reason "retire relay bridge"
|
||||
```
|
||||
Before removing a participant altogether, ensure any pending queue items and conversations assigned to them are resolved:
|
||||
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
|
||||
```bash
|
||||
rig gateway human remove alice
|
||||
```
|
||||
|
||||
@@ -342,16 +342,9 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
|
||||
it can't resume is reported as waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
@@ -823,19 +816,6 @@ option, the seat's agent spec for the target runtime, or the runtime's own
|
||||
default), what is cleared, and the costs below. Nothing changes until you run it
|
||||
without `--dry-run`.
|
||||
|
||||
Use `--runtime pi` to move a Claude Code or Codex seat to Pi; use `--runtime
|
||||
claude-code` or `--runtime codex` to move back. Pi uses a fresh managed state
|
||||
directory. Select a prepared Pi account with `--account-home /absolute/path`
|
||||
(the directory must contain readable `auth.json`), or use the target's configured
|
||||
login. A source account override or Claude token is cleared only at commit;
|
||||
credentials are never converted between runtimes. Pi-to-Pi account changes keep
|
||||
Pi settings, but cross-runtime switches resolve settings afresh for the target.
|
||||
|
||||
A configured `launch_wrapper` carries across runtime switches among Pi, Codex
|
||||
and Claude Code. It must support each successor executable and preserve that
|
||||
runtime's credential environment. A wrapped Pi seat cannot switch to Claude
|
||||
Code; set `launch_wrapper: []` in the authored spec and re-apply it first.
|
||||
|
||||
The new runtime takes over before the old one stops. Its successor starts beside
|
||||
the current occupant, reads a handover (the seat's recap, its open rows and the
|
||||
predecessor's screen), and confirms it with `rig seat set-runtime <seat>
|
||||
@@ -867,15 +847,12 @@ What it costs:
|
||||
again afterwards to reach whoever holds the seat.
|
||||
- **Leftover files after a failed switch.** The target runtime's prepared seat
|
||||
storage and projections can remain.
|
||||
- **Codex, Claude Code and Pi, single-pane seats.** Startup slash commands aren't
|
||||
- **Codex and Claude Code, single-pane seats.** Startup slash commands aren't
|
||||
run by a switch; the dry run lists them.
|
||||
|
||||
## Stop your teams
|
||||
|
||||
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
|
||||
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
|
||||
is left running: `rig down` names it with the commands to stop it by hand, and
|
||||
exits 2.
|
||||
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
|
||||
`rig down` also prints the rig's recorded agent sessions and their activity
|
||||
before stopping them.
|
||||
@@ -905,12 +882,10 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| --- | --- |
|
||||
| Tool missing or login fails | Use the specific setup/auth hint; recheck that executable in the launch shell. Do not send work to an unready seat. |
|
||||
| "Startup attention" or "Startup details" names a prompt | Answer the login, trust or bypass prompt in that seat's terminal, then run the `rig seat continue <seat>` it shows. |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | After a full restore of a Claude or Codex seat, the daemon clears this by itself once the seat reads running or idle and passes its check. If it still counts, check its `restoreOutcome` in `rig ps --nodes --json` (the RESTORE column): when it is `attention_required`, run `rig seat clear-attention <seat>`, which says which check failed; answering the prompt again or running `rig seat continue` doesn't change it. For a partial restore, submit any startup text left in the input box without sending it again, and wait for the agent to respond before clearing attention. See [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| A restored seat still counts as needing attention after its prompt was answered | Its restore outcome is still `attention_required`, and answering the prompt or running `rig seat continue` doesn't change it. Once the agent is up in its pane, run `rig seat clear-attention <seat>`; see [After a restored seat's prompt](agent-startup-guide.md#after-a-restored-seats-prompt). |
|
||||
| "Readiness timeout after 30s — harness did not become interactive" | The harness took longer than the readiness window. Raise it for the next launch with `rig config set runtime.readiness_timeout_seconds <1-600>`, then relaunch that seat. |
|
||||
| "Claude resume did not finish within Ns; Claude may still be starting, so the launch was kept" | A restored Claude seat was not interactive when the wait ended. Its pane and resume token are kept. Look at the pane; once Claude is up, run `rig seat clear-attention <seat>`. The wait is about `runtime.readiness_timeout_seconds` in total for each seat (default 30 s), counting the first 3.2 s, and the time adds up seat by seat in a restore. Raising the setting for slow fresh launches lengthens it. If `rig up` or `rig launch` times out at 120 s, that is not a rollback: check `rig ps` before running it again. |
|
||||
| Daemon is healthy, kernel is still starting | Read `rig status` and `rig ps --nodes --rig kernel`; kernel readiness is separate. |
|
||||
| `rig status`, `rig terminal open` or `rig daemon start --wait-for-kernel` says a seat "needs attention" or "is waiting on a person" | The daemon flags that seat, and the line gives its recorded reason: a startup that failed or stopped at a sign-in, trust or other gate, a held message, a missing required file, or a question. "Is waiting on a person" means the daemon sees a live question you can answer in that seat's terminal. Look at it with `rig capture <seat>` or in its tile, then follow the row above that matches its message. "Couldn't check" means the check itself failed; it says nothing either way. |
|
||||
| A Codex seat, including the kernel's operator, answers every turn with a model error such as "… not supported when using Codex with a ChatGPT account" | Its Codex sign-in works, but that account can't run the model; the kernel picks its runtimes from the providers chosen at setup and their sign-ins, not from which models an account can run. If Claude Code is signed in, move just that seat: `rig seat set-runtime <seat> --runtime claude-code --reason "<model> isn't available to this Codex account"` (`--dry-run` first). Otherwise give it a model the account can run with `rig seat set-model <seat> --model <id> --reason "…"`, then start it again with `rig seat launch <seat> --fresh --stop --reason "…"` and check the model it reports. |
|
||||
| Kernel seats fail within a second of the first start, and tmux reports "server exited unexpectedly" | Check that your tmux can capture a pane, then start the failed seats again; see the tmux check below this table. |
|
||||
| Shared terminal is absent | Check the kernel's `operator.human` terminal separately from its agents; recover only that seat as described below. Standalone `rig tui` remains available while resolving it. |
|
||||
| Viewing terminal was closed | Open the conversations again with `rig terminal open saved:kernel --window`; `rig tui --shared` is only the dashboard fallback if that window cannot open. Do not relaunch the team. |
|
||||
|
||||
@@ -39,19 +39,14 @@ This chapter covers **which agent runs in a seat and how OpenRig talks to it**.
|
||||
sandbox, another machine) is the [execution platform chapter](execution-platform-contract.md). The two are separate on purpose:
|
||||
the same harness can run in many places, and one place can host many harnesses.
|
||||
|
||||
## 1. Plain preset, runner preset, or OpenRig-hosted protocol runner?
|
||||
## 1. Plain preset or runner preset?
|
||||
|
||||
| | Plain community preset | Runner preset (`runner: true`) | OpenRig-hosted protocol runner (`protocol: pi-rpc`) |
|
||||
|---|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) | OpenRig's own Pi runner, which starts your binary (`command`) in Pi's RPC mode |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input | From the Pi runner's reports, through the same channel as built-in Pi; identity is the session file Pi's `get_state` reports |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt; a message sent mid-turn waits | Pi's input: a submitted block is a prompt, a message sent while busy steers the current turn, `/followup` queues after it and `/abort` cancels it |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably | Your harness is a Pi fork, or speaks Pi's CLI and JSONL RPC protocol |
|
||||
|
||||
**For the third shape, you promise your binary's Pi-RPC compatibility at a version you name in `limits`.** OpenRig doesn't
|
||||
test each fork. It can't be combined with `runner`, `launch_result`, `names_conversation`, `startup_assisted` or
|
||||
`first_input`. The flags it must accept are in
|
||||
[Pi-compatible RPC harnesses](community-harness-presets.md#pi-compatible-rpc-harnesses-protocol-pi-rpc).
|
||||
| | Plain community preset | Runner preset (`runner: true`) |
|
||||
|---|---|---|
|
||||
| **What runs in the pane** | The harness's own terminal app | A small program you maintain, *the runner*, which hosts the agent (for example an ACP agent over stdio) |
|
||||
| **How OpenRig reads activity** | From the screen: a changing screen reads working; a quiet one reads **unknown**, never idle | From the runner's reports: working, idle, needs input |
|
||||
| **How input arrives** | Typed into the app's prompt | Typed into the runner, which turns each submitted message into one agent prompt |
|
||||
| **Choose it when** | The harness has a usable terminal UI (Cursor, Hermes, OpenClaw examples) | The harness speaks a protocol rather than a screen (ACP, JSON-RPC, stream-JSON), or you can report turn boundaries reliably |
|
||||
|
||||
**If your harness works well as a terminal app, a plain preset is enough. Use a runner only when you can report state
|
||||
honestly.** A runner that guesses is worse than a quiet screen read as unknown.
|
||||
@@ -83,20 +78,7 @@ limits:
|
||||
- **A preset without `runner` behaves exactly as today,** and so does a session recorded before the field existed.
|
||||
- **The other fields keep their existing meanings:**
|
||||
- `first_input: true` passes the seat's first startup turn in a file named by `OPENRIG_FIRST_INPUT`;
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`;
|
||||
- `launch_wrapper: true` declares that the runner applies a member's configured wrapper to its native child.
|
||||
|
||||
**Launch wrapper.** `launch_wrapper` is an optional boolean in format 1 and requires `runner: true`. When the member
|
||||
has a wrapper and its runner preset declares `launch_wrapper: true`, OpenRig passes `OPENRIG_LAUNCH_WRAPPER` as a JSON array of resolved argv strings.
|
||||
The runner must exec its native child as `<argv...> <agent> <args>`, preserving argument boundaries, and keep its own
|
||||
process outside the wrapper. The runner must remove `OPENRIG_LAUNCH_WRAPPER` from the environment it passes to the wrapper
|
||||
and native child; the handoff belongs to the runner only. The wrapper must `exec` the agent in place. If wrapping fails,
|
||||
the runner must never fall back to an unwrapped spawn. A runner preset without the flag is refused at preflight and launch (not schema validation) when a wrapper is
|
||||
configured: `launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.`
|
||||
A plain preset needs no flag: OpenRig prefixes its command
|
||||
immediately before the preset's native command, after OpenRig's launcher assignments (for example,
|
||||
`/usr/bin/env OPENRIG_LAUNCH_RESULT=… <wrapper> <preset.command>`), at the same seam as Codex. OpenRig never sets
|
||||
`OPENRIG_LAUNCH_WRAPPER` for a plain preset and removes any inherited value.
|
||||
- `passes_model: true` passes the seat's declared model as `OPENRIG_MODEL`.
|
||||
|
||||
## 3. Identity: report the conversation once, in the launch record
|
||||
|
||||
@@ -241,15 +223,8 @@ permission or input handling in existing built-in runtimes or require every poss
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- **Sending it:** a person types `/abort` in the seat's pane. From another seat or a script,
|
||||
`rig send <seat> "/abort" --raw` pastes exactly `/abort`, with no From/To envelope, and presses Enter, so a runner
|
||||
that follows section 5 gets the control. 0.7.8's native runs typed it in the pane; a raw send hasn't been run
|
||||
natively. An ordinary `rig send` wraps its text in a From/To envelope, so there `/abort` is an ordinary prompt.
|
||||
- **Not while the seat needs input:** a raw send is still refused while the seat reads needs input
|
||||
(`target_needs_input`), for example while the runner holds a permission request for a person (section 7). Type
|
||||
`/abort` in the pane then.
|
||||
- **A sent `/abort` is a request.** A typed control can't name the turn it was meant for: the runner cancels whatever
|
||||
turn is running when it reads it. Whether a turn was cancelled is the runner's `Stop` / `cancelled` below.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
@@ -305,31 +280,9 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
| replies with just a no ("no", "deny", "reject", "stop" …), or denies it in the Feed | the agent's reject (`reject_once`, else `reject_always`), or, with no reject option, cancelled. A person's no is never a grant |
|
||||
| routes or hands it to another person | the ask waits for that person, who gets the same options; their answer counts as if they'd been asked. The handoff itself never permits |
|
||||
| routes or hands it to an agent seat | that seat gets no options. The request waits until that row is closed, by the seat or a person. Closed as done without a choice (including a drop), or as failed, is no answer, so it's permitted. Closed as denied (the Feed's deny, `--closure-reason denied`, or the state `denied`) is a no. A row set to the state `canceled` cancels the request, and a further handoff is followed; a person it reaches gets the same options. Or the request waits until the agent's turn ends |
|
||||
| closes it with no answer at all (a drop) | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| answers in other words that nobody decides on | after 0.7.11, **the request waits**: the words are kept, never the default permit, until an explicit decision (`rig seat decide-permission`) or the turn's end |
|
||||
| closes it without choosing: drop, approve, or other typed words | no answer, so the default applies: **permitted** with the agent's allow, and OpenRig records the permit (below) |
|
||||
| doesn't answer | the request **stays pending and visible** (the seat reads needs input) until the agent's turn ends. It's never timed out into a grant. |
|
||||
|
||||
**After 0.7.11, a person's answer goes to an agent that decides, or waits:**
|
||||
- **Who decides:** this host's receiving agent (`agents.operator_session`, else the kernel operator
|
||||
`operator-agent@kernel`), unless it's the runner that asked (the request's first row, however it was handed on) or
|
||||
a seat this host doesn't have; then the kernel operator, unless it's the runner that asked.
|
||||
- **What happens to the answer:** a click, a plain yes or no, other words, or Steering's Approve or Deny on the request
|
||||
(a yes or a no) is kept on the row it was given on. The request is handed to that agent with the person's whole
|
||||
answer, the question and the options, and the person is told once who decides and in what recorded state.
|
||||
- **The runner keeps reading `pending`.** The agent decides with `rig seat decide-permission <askId> --option <id>
|
||||
--reason "…"` (`POST <base>/api/activity/permission-requests/<askId>/decide` with `{"optionId", "reason"}`).
|
||||
- It accepts only an option the agent offered.
|
||||
- It records an agent's decision: who decided, why, and the person's words.
|
||||
- A request that's no longer pending comes back as it is, and nothing is written.
|
||||
- **A kept answer counts only through an explicit decision.** If the row is closed without one (an agent's or a
|
||||
person's ordinary close), the answer stays kept and the request waits; `rig seat decide-permission` still settles it.
|
||||
A refusing close (denied) is still a no.
|
||||
- **With no agent that can decide,** any answer is kept, the request waits, and the person is told once that the tool
|
||||
hasn't run and how to decide directly (`rig seat decide-permission`) or set up a receiving agent. A handoff that fails
|
||||
leaves the request waiting the same way.
|
||||
- **No timeout refuses a request.** If the turn ends first, nothing is sent.
|
||||
- **The default permit stays** for a request nobody can be asked about (above), and one closed with no answer at all.
|
||||
|
||||
- **An answer reaches only the live request it was asked for,** once. A request from an earlier occupant reads
|
||||
`stale`, and one withdrawn at the turn's end reads `withdrawn`. Neither can take a later answer.
|
||||
- **Text typed into the runner's pane, an agent's own text, and `rig send` are never an answer.** While a request waits
|
||||
@@ -374,9 +327,7 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
|
||||
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
|
||||
`stale`.
|
||||
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome. After 0.7.11, words are never
|
||||
answered with the default permit (they keep the request waiting), so `said` no longer arrives. A runner that handles
|
||||
it keeps working.
|
||||
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
|
||||
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
|
||||
refused (`409 activity_grant_stale`), even during a read's wait.
|
||||
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
|
||||
|
||||
@@ -91,11 +91,7 @@ and `severity=info`. CLI/TUI call this **needs diagnosis**, not a confirmed
|
||||
warning. Enabled diagnosis policy admits one bounded packet even though the
|
||||
product denominator is unknown. Ordinary unavailable/stale detector records
|
||||
remain ineligible. Diagnosis and human-notification traffic are excluded from
|
||||
the numerator and cannot recursively generate another investigation. So is the
|
||||
daemon's own bookkeeping: transitions by system actors (`*@system` and the
|
||||
bare `system` of claim handover) and daemon notes recorded under any actor
|
||||
(`auto-unparked:`, `blocker custody moved `, `park wake `, `parked-owner `).
|
||||
The reported `transitionIds` are the counted transitions.
|
||||
the numerator and cannot recursively generate another investigation.
|
||||
|
||||
The agent reads the actual evidence, including beyond the supplied references,
|
||||
and resolves the semantic outcome granularity and consequence boundary. Use the
|
||||
|
||||
@@ -97,24 +97,6 @@ missing tools or logins, kernel startup, a closed viewing terminal and recovery
|
||||
before choosing an action. A healthy daemon doesn't by itself mean the project's seats are ready: check
|
||||
`rig ps --nodes --rig <rig-name>`.
|
||||
|
||||
If the whole team stopped when a herdr terminal closed, OpenRig or its tmux server was started from inside that
|
||||
terminal: herdr-core ends every process started there when the terminal closes. `rig daemon start` no longer passes a
|
||||
herdr terminal's scope on, but processes started before that keep it until they restart. OpenRig never restarts them
|
||||
for you; restart at a time you choose, from a terminal outside herdr.
|
||||
|
||||
With `terminal.tmux_server` at its default (`inherit`), OpenRig puts seats on the tmux server of the shell that
|
||||
started it, usually your own default server. If that server was already running, and you had started it from a herdr
|
||||
terminal, it carries that terminal's scope. OpenRig can't prevent this, because the server is yours.
|
||||
The same applies when a command like `rig chatroom watch`, run in a herdr terminal, starts the default server. Start
|
||||
tmux outside herdr, or use `terminal.tmux_server isolated` (see `tmux-servers.md`), which gives OpenRig a server it
|
||||
starts itself.
|
||||
|
||||
The daemon keeps `HC_SESSION` and `HC_SOCKET`. The herdr-core they select is the one the herdr-app provider starts
|
||||
viewers in; `rig terminal status --provider herdr-app` names it (`core` and `assumption`), as do open failures
|
||||
(`viewer_start_failed`, `herdr_app_unavailable`) and the note when a window has not confirmed showing a view. If your
|
||||
herdr-app window is attached to a different herdr-core than the daemon's, its viewers will not appear there; start the
|
||||
daemon with that core's `HC_SESSION` and `HC_SOCKET`.
|
||||
|
||||
### The agent is waiting for permission or can't reach the daemon
|
||||
|
||||
Read [Have your agent configure permissions](getting-started.md#have-your-agent-configure-permissions)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# OpenRig views in herdr-app
|
||||
|
||||
herdr-app (Herdr 2, the desktop app on herdr-core) can show OpenRig's terminal views inside its own window. A view
|
||||
opens as a **Rig view**: an equal grid of the view's seats, each with a title row (`<agent> · <slice>`), padded to a
|
||||
full grid. Rig views live in OpenRig's own herdr-app layout, **Rig** (`layouts/rig.json`), beside your tabs. A strip
|
||||
above them switches between **Your tabs** and each view.
|
||||
|
||||
Nothing here needs a desktop display or SSH: the view opens in the herdr-app window the request came from.
|
||||
herdr-app has to run on the same machine as the OpenRig daemon: OpenRig reaches it through its control socket
|
||||
(`<app home>/herdr-app.sock`) and writes its mods and layout into that app home. An app on another machine, connected
|
||||
to this machine's herdr-core over SSH, isn't supported.
|
||||
|
||||
## Set up
|
||||
|
||||
Nothing to install by hand: opening a view installs OpenRig's four mods (rig-strip, rig-cell-header, rig-pad,
|
||||
rig-ended) and updates them when OpenRig is updated.
|
||||
|
||||
- The mods go to `<app home>/mods/rig-*`. The app home is herdr-app's own: `HERDR_APP_HOME`, else
|
||||
`$XDG_CONFIG_HOME/herdr-app`, else `~/.config/herdr-app`. `--app-home <dir>` on `rig terminal open` names another.
|
||||
- OpenRig owns only the files it ships there (recorded in `<app home>/openrig/mods.json`). An update replaces those
|
||||
and nothing else in the folder. Mods you install yourself, from any source, are never touched.
|
||||
- To move between a view's cells from the keyboard, bind the strip's actions in `keys.json`, section `layout:rig`
|
||||
(your agent can add it, as for any herdr-app key):
|
||||
`"layout:rig": { "alt-left": "rig-strip.left", "alt-right": "rig-strip.right", "alt-up": "rig-strip.up", "alt-down": "rig-strip.down" }`.
|
||||
In the Rig layout these replace herdr-app's `global` defaults for the same keys (`pane.go`, `agent.prev`/`agent.next`),
|
||||
which keep working on your other layouts. A key bound in `terminal` wins while a cell has focus, so pick keys that
|
||||
aren't bound there. Once you edit `keys.json`, herdr-app treats it as yours and stops adding new default shortcuts.
|
||||
- An open never replaces a mod you changed: that folder stays as it is, the view opens anyway, and the open's notes
|
||||
name the folder and why. Only a mod a view needs that is missing and can't be installed stops an open.
|
||||
|
||||
## Open a view
|
||||
|
||||
- **From the TUI inside herdr-app:** Terminals, choose a view (Enter previews it), then **Open terminals**. The TUI
|
||||
recognises that it runs in a herdr-core terminal of a herdr-app window on this machine, and the preview and the
|
||||
open both use that window, so the plan you previewed is the one that opens. The preview says the view opens in
|
||||
this window; it shows no desktop or SSH recipe. A shared TUI viewed from a herdr-app terminal does the same. The
|
||||
kernel view opens in its narrow arrangement there (one seat per page): the window's width isn't the TUI's.
|
||||
- **From a shell:** `--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering. `--window` always asks for a new desktop window.
|
||||
|
||||
| Command | Where the view opens |
|
||||
|---|---|
|
||||
| `rig terminal open <view> --provider herdr-app [--app-home <dir>]` | In the herdr-app window this runs in (requires the window to answer). |
|
||||
| `rig terminal open <view>` | Inside a herdr-app terminal when the window is answering, in that herdr-app window; otherwise a desktop window. |
|
||||
| `rig terminal open <view> --window` | Always asks for a new desktop terminal window, even inside a herdr-app terminal. |
|
||||
|
||||
The herdr-app window must be answering on its socket (`<app home>/herdr-app.sock`); otherwise an explicit open fails and says
|
||||
so. Finding the socket file is how OpenRig guesses you are in a herdr-app window; whether the window is reachable is
|
||||
checked by asking it. When a plain `rig terminal open <view>` in a herdr-app terminal finds that the socket does not
|
||||
answer, it says so and opens a desktop window instead; `--provider herdr-app` never
|
||||
falls back to a desktop window or an SSH recipe.
|
||||
|
||||
What an open does, in order:
|
||||
|
||||
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
|
||||
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
|
||||
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over and attaches to the
|
||||
seat with plain tmux. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
|
||||
shell in the cell.
|
||||
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
|
||||
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
|
||||
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
|
||||
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
|
||||
of each local interactive cell, on every page (including pages the strip hasn't shown yet, since the strip moves
|
||||
between pages on its own). These cells take input a fraction of a second later (they check every 0.2 s). Anything
|
||||
that reached a local interactive cell before that, typed or written to its terminal (`hc send`), is dropped, not
|
||||
delivered late. Remote cells run their ssh attachments from the start and don't wait for this gate. The open
|
||||
reports a view herdr-app hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the
|
||||
Rig layout, and its local interactive cells take input once herdr-app shows it. OpenRig keeps watching for 10
|
||||
minutes, across a daemon restart; if herdr-app hasn't shown it by then, each local interactive cell says so, takes
|
||||
no input, and names the command that opens the view again (`rig terminal open <view> --provider herdr-app`).
|
||||
|
||||
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
|
||||
|
||||
## The keyboard
|
||||
|
||||
- An open puts the keyboard on the new view's first live cell.
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- Apart from an open, OpenRig's own rewrites (another view closing, a cell ending) leave the keyboard where it is,
|
||||
whether you are in a view or in your tabs, with one exception: when the cell you were last in ends while its view is
|
||||
shown, the keyboard goes to that view's first live cell, if one is left.
|
||||
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
|
||||
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
|
||||
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
|
||||
start, so an interactive one takes input as soon as it runs. When the seat's session is replaced (a handover, a fresh
|
||||
launch, a restart), the tile follows the same seat to its new session, except an ssh tile on an isolated local tmux
|
||||
server ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)).
|
||||
|
||||
## What OpenRig keeps in the app home
|
||||
|
||||
All under `<app home>/openrig/`:
|
||||
|
||||
| File | What it holds |
|
||||
|---|---|
|
||||
| `mods.json` | The files OpenRig shipped to `mods/rig-*`, so an update replaces only those. |
|
||||
| `ack.json` | Written by the strip: the last layout revision herdr-app shows and the terminals it references. A viewer ends only once this shows its cell is gone. |
|
||||
| `strip.json` | Written by the strip: the cell you were last in per view, and the view shown. herdr-app gives a mod new storage whenever its props change, which is every OpenRig write, so this is kept as a file. |
|
||||
|
||||
## Close a view
|
||||
|
||||
```sh
|
||||
rig terminal close <rv-id> [--app-home <dir>] # the id is in the open result's `space`
|
||||
```
|
||||
|
||||
The view's cells leave `rig.json` first. A viewer ends only after herdr-app confirms it shows a layout without it, or
|
||||
when the Rig layout isn't shown (herdr-app then reads the new file when it is). Only viewers OpenRig started (its
|
||||
record, its labels and its exact command all match) are ended. Seats are never touched. Anything uncertain is left
|
||||
running and named in the result.
|
||||
|
||||
If a viewer ends some other way (killed outside OpenRig, herdr-core restarted), OpenRig rewrites its cell as an
|
||||
ended cell straight away. Views OpenRig recorded keep that owner across a daemon restart: when the daemon starts, it
|
||||
reconciles each app home it has views in (a viewer that ended while it was down is rewritten then) and finishes any
|
||||
pending ends. If `hc events` can't run (herdr-core missing or down), the watcher tries again after 5 s, then waits
|
||||
twice as long each time, up to 5 minutes. Stopping the daemon ends only its own `hc events` watcher and timers; viewers
|
||||
and views stay.
|
||||
|
||||
## Which herdr-core
|
||||
|
||||
```sh
|
||||
rig terminal status --provider herdr-app [--app-home <dir>]
|
||||
```
|
||||
|
||||
`core` and `assumption` in the result name the herdr-core the daemon's `hc` runner starts and ends viewers in: the
|
||||
daemon's `HC_SESSION` (else `default`) and `HC_SOCKET` if set when the daemon started. The herdr-app window must be a
|
||||
client of that same core. Terminal addresses are per core, so a window attached to another core would show other
|
||||
terminals, or none, in the view's cells. Open failures (`herdr_app_unavailable`, `viewer_start_failed`) and the note
|
||||
when a window has not acknowledged a write also name the daemon's core (`HC_SESSION`/`HC_SOCKET`) so a mismatch is
|
||||
visible straight away.
|
||||
|
||||
A status or a preview only reads: it starts no herdr-core process and creates nothing. OpenRig watches herdr-core's
|
||||
events (`hc events`) and creates its viewers folder only when it opens or closes a view, or when it starts with views
|
||||
already recorded.
|
||||
|
||||
## Limits
|
||||
|
||||
- A Rig view is a layout inside herdr-app, not a herdr tab: other windows and other layouts don't show it.
|
||||
- Following a task (`--follow`) isn't available in herdr-app yet.
|
||||
- When a viewer ends unexpectedly, herdr-app removes its block and collapses the layout, which briefly opens the Files
|
||||
panel on that cell's folder until OpenRig's rewrite restores the layout. Viewers start in OpenRig's own empty
|
||||
folder (`$OPENRIG_HOME/private/herdr-app/viewers`). Planned closes never trigger this.
|
||||
- OpenRig doesn't yet check that the window runs on the daemon's herdr-core (`core` in `rig terminal status`); it
|
||||
relies on both using the same session on the same machine.
|
||||
- `POST /api/terminal/open` (and `POST /api/terminal/close`) carries no bearer middleware, matching the daemon's other
|
||||
open routes; an open writes `mods/rig-*`, `openrig/mods.json` and `layouts/rig.json` into the named `--app-home` once
|
||||
`herdr-app` answers a `status` request on `<app home>/herdr-app.sock`.
|
||||
- Runtime-tested on Linux only; macOS and Windows haven't been tried on real machines.
|
||||
@@ -78,25 +78,16 @@ entry. The entry holds secret names, never values:
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects. A ring with no answer by then may still have reached the
|
||||
trigger, so it reads unknown, not failed, and durable recovery doesn't repeat that recorded wake (other recovery paths
|
||||
can still ring it again). The member sees the work when it next checks its rows.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, `indeterminate:webhook …` when it didn't answer in time, or `failed:webhook …`. Accepted means the tool's trigger
|
||||
answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
|
||||
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
|
||||
the operator seat when it's a person, else the rows' creator when they all share one, else the operator agent, else the
|
||||
member itself. A notice to a creator lists only that creator's rows: when another creator's row joins, the notice moves to
|
||||
the next recipient. It says whether the member has read its own pending inbox recently: a check-in, never a receipt or a
|
||||
claim. A member with a declared route (a delegation parent or `escalates_to`) gets one finding per unclaimed row at that
|
||||
route instead.
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`, and OpenRig tells the person: the operator seat when it's a person, else whoever
|
||||
created the row.
|
||||
- It tells the person too when the member was rung but hasn't claimed the work by the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default).
|
||||
- The member sees new work when it checks: when it's rung, when its person asks it to, or on a schedule the tool runs.
|
||||
@@ -104,7 +95,7 @@ These facts stay separate on the row:
|
||||
- **Result:** the closing transition and its resolution, or the handoff.
|
||||
- **Notification:** where a person asked, Slack's posted receipt. A posted notice isn't the person's acknowledgment.
|
||||
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`. With an external grant offering `team/list`, call it without `rigId` to discover the connected team. It selects the grant's sole rig, or returns only its granted names when a choice is needed.
|
||||
**On the team:** `rig_team` lists the rig's hosted members (roster entries marked hosted that aren't one of its nodes), with their capabilities and caveat, no pane, and activity `unknown`.
|
||||
OpenRig doesn't observe a hosted member between its calls, so it never reads idle. `rig roster find` shows it the same way.
|
||||
- **Presence:** each entry carries `presence`, which says what OpenRig last saw of the member. It isn't pickup, and `null` means unknown:
|
||||
- `lastCallAt`: its last authenticated call to this host's `rig mcp serve --http`, recorded at most once a minute in
|
||||
@@ -112,33 +103,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
|
||||
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
|
||||
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
|
||||
|
||||
### Check-in status
|
||||
|
||||
A connected assistant (a roster member marked `hosted: true`) checks in each time it reads its own pending inbox (through the queue list, as the MCP list tool does
|
||||
when it lists the member's own work). Other calls it makes don't count yet. Its status compares its last check-in with
|
||||
what its roster entry states: a cadence (`check_in_minutes`), or that it checks in when a person asks it
|
||||
(`check_in_on_request: true`):
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `checking-in` | its last check-in is no older than twice its stated cadence |
|
||||
| `check-in-overdue` | its last check-in is older than that: its schedule may be off or waiting for an approval |
|
||||
| `no-check-in-yet` | no check-in recorded for it on this daemon, whatever its entry states |
|
||||
| `on-request` | its entry says it checks in when a person asks it |
|
||||
| `disconnected` | reserved for a recorded revocation or removal of its connection. Nothing records one yet, so a revoked assistant reads overdue (or unknown, with no cadence) |
|
||||
| `unknown` | its entry states neither, its statement can't be used, or its check-ins couldn't be read; the reason says which, and its last check-in is shown. OpenRig assumes no cadence, so this never reads idle or overdue |
|
||||
|
||||
Each place it's shown gives the evidence with it, for example `overdue: last check-in 2026-11-12T13:05:00Z, expected
|
||||
every 60 min; its schedule may be off or waiting for an approval`.
|
||||
|
||||
A stated cadence is its person's statement: neither reading proves the assistant's own schedule ran, or that it picked
|
||||
anything up. It's a status only. Work addressed to a member that isn't checking in still queues and is rung as before, a check-in
|
||||
isn't pickup (pickup is its claim), and no check-in doesn't mean it's idle, failed or safe. `GET /api/queue/hosted-members`
|
||||
gives this daemon's host id and each member on this host with its status, last check-in and stated cadence (with the
|
||||
reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`, for a team with or without terminal seats.
|
||||
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
|
||||
line; when that notice is filed doesn't change.
|
||||
|
||||
## Revoke
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
@@ -165,10 +129,6 @@ and nothing else is offered:
|
||||
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
|
||||
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
|
||||
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
|
||||
- **A first task without a seat address:** with `--task-destination <member@rig>` on the same `rig mcp serve`, a
|
||||
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
|
||||
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
|
||||
before.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
@@ -177,8 +137,8 @@ and nothing else is offered:
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
- **Answered:** closed `done`, with the answer in `resolution`.
|
||||
- Reading or listing an item records nothing on the item, and nothing wakes an assistant, so an item stays queued until its
|
||||
receiver claims it. A member listing its own pending inbox records only a check-in time, which the notice above reads.
|
||||
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
|
||||
claims it.
|
||||
|
||||
### A person answering the team's questions through their assistant
|
||||
|
||||
@@ -188,8 +148,7 @@ pair, the person's assistant can read the team's questions waiting on them and s
|
||||
Steering. The caller's seat is never taken for a person: without `person`, the `question` pair offers nothing, and an
|
||||
unusable `person` withholds only these two tools while the grant's other tools keep working.
|
||||
|
||||
- **Register the person** on the instance, once:
|
||||
`rig gateway human add <entity_id> --display-name "<name>" --delivery-class B --default`.
|
||||
- **Register the person** on the instance, once: `rig gateway human add <entity_id> --display-name "<name>" --default`.
|
||||
Without `--binding`, they're asked in the Feed and on Steering only. As the default person, today's asks addressed to no
|
||||
registered person (such as `human@host`) are theirs too.
|
||||
- **`rig_question_list`** shows what Steering shows that person as answerable: an ask addressed to them, or a row parked on
|
||||
@@ -220,6 +179,5 @@ unusable `person` withholds only these two tools while the grant's other tools k
|
||||
## Limits today
|
||||
|
||||
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
|
||||
isn't rung; work it leaves unclaimed past the unclaimed age is listed in one notice instead, which goes to its person, else
|
||||
its rows' shared creator, the operator agent or the member itself.
|
||||
isn't rung, and its person is told instead.
|
||||
- **It can't act as a person:** it works rows addressed to its seat, a person's request to it included. It can't settle an ask addressed to a person, or hand work to one, except through a grant service's grant that names its person, which records that person's own answers (above).
|
||||
@@ -59,9 +59,7 @@ rig queue block <task> --on external:<what you check> --wake-after 1h \
|
||||
```
|
||||
|
||||
`--on` is always required; `external:<what>` names the outside thing you're waiting on. The timer first fires after
|
||||
the interval, not now. Left without `--wake-after` (or `--wake-watchdog`), the task is still set aside, but nothing
|
||||
will wake you, and the reply says so with a `park_without_wake` advisory. Set it aside again with `--wake-after` to add
|
||||
the timer.
|
||||
the interval, not now.
|
||||
|
||||
**4. Say back what you set, in one or two plain lines:** what you'll check, how often, how they'll be told, and when
|
||||
it stops. For example: "I'll check the release PR every hour and message you here once it merges. Then I'll stop."
|
||||
@@ -296,31 +294,3 @@ Unknown: not said yet.
|
||||
delivery guarantee.
|
||||
- **Transcript bytes as "usage".** `context-usage-threshold` measures an agent's transcript, not an account's
|
||||
allowance. For an allowance, see "A usage warning" above.
|
||||
|
||||
## Recurring reminders for agents
|
||||
|
||||
An agent-to-agent reminder can repeat until stopped. This is separate from
|
||||
[a person's one-shot watch](#setting-a-watch), which uses `rig queue block --wake-after`.
|
||||
|
||||
Save this as `periodic-reminder.yaml`, replacing the example session names with your own:
|
||||
|
||||
```yaml
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
```
|
||||
|
||||
Register it with all five required flags:
|
||||
|
||||
```bash
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \
|
||||
--target-session worker@my-team --interval-seconds 600 \
|
||||
--registered-by coordinator@my-team
|
||||
```
|
||||
|
||||
At registration, `--policy` selects the policy; the YAML `policy` field is not the
|
||||
selector. YAML `target.session` takes precedence over `--target-session`, so keep them
|
||||
aligned. If YAML omits the target, the CLI target is the fallback. Put a non-empty
|
||||
`message` in YAML: `--message` is a shortcut only for `context-usage-threshold`.
|
||||
The first reminder is due after the interval; this example repeats every 600 seconds.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Network connector: a local seat as a hub participant
|
||||
|
||||
Source of truth: `packages/daemon/src/domain/network/` (`network-bindings.ts`, `network-connector.ts`, `hub-client.ts`),
|
||||
`packages/daemon/src/routes/network.ts`, `packages/cli/src/commands/network.ts`.
|
||||
|
||||
A hub is another OpenRig queue that its participants reach over MCP, such as a daemon served by `rig mcp serve --http`
|
||||
with one grant per participant. The network connector puts one selected local seat on a hub as one of its participants.
|
||||
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
|
||||
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
|
||||
|
||||
## Connect
|
||||
|
||||
```bash
|
||||
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
|
||||
```
|
||||
|
||||
- `<participant>` is the participant's address in the hub (`member@rig`), and the token file holds its bearer.
|
||||
- The binding follows the seat's node, so a relaunched or renamed seat keeps it.
|
||||
- The token is stored at `<OpenRig home>/network-secrets/<binding>/credential.json` (a 0700 folder and a 0600 file). It
|
||||
is never returned by the API or shown in status. A token in the hub URL is refused.
|
||||
- A plain-http hub URL needs `--allow-http` (see Limits).
|
||||
- One active binding per local seat, and one per hub participant.
|
||||
- On a daemon with an operator bearer configured, `connect` and `disconnect` send `OPENRIG_AUTH_BEARER_TOKEN`, as the
|
||||
`rig host` writes do.
|
||||
|
||||
## What the connector does
|
||||
|
||||
The daemon runs the connector for every active binding. It normally polls every 30 seconds
|
||||
(`OPENRIG_NETWORK_POLL_SECONDS`, at least 5); a failing hub, backoff or a backlog can delay it. Each binding's pass has
|
||||
a deadline of 60 seconds, so one slow hub doesn't hold the others. With no active binding it makes no network call.
|
||||
|
||||
1. It lists the participant's own items on the hub, claims each pending one, and creates a local queue item for the
|
||||
seat's current address. The local item comes from OpenRig itself, and it says where the work came from first: its
|
||||
summary starts "From <requester> via network hub", and its body opens with the requester, the hub and the hub item,
|
||||
before the requester's own words.
|
||||
2. It reports each stage on the hub item, kept apart:
|
||||
- **Queued:** the hub claim means the connector holds the item. The note says "Not picked up yet."
|
||||
- **Picked up:** once the seat claims the local item.
|
||||
- **The result:** once the local item closes, following any local handoffs to the item that finished. The hub item
|
||||
closes the way its queue closes one: done with the resolution and the closure reason (`no-follow-on`, `canceled`
|
||||
or `denied`), or failed when the local item failed. A close with no resolution sends the closer's own note on that
|
||||
close, or "Closed without a stated result." No other note on the item is sent.
|
||||
|
||||
To answer, close the local item with your result:
|
||||
|
||||
```bash
|
||||
rig queue update <local item> --state done --closure-reason no-follow-on --resolution "<your answer>"
|
||||
```
|
||||
|
||||
## Retries, restarts and unknown outcomes
|
||||
|
||||
- Each hub item maps to one local item, under an id derived from the seat's node, the hub, the participant and the hub
|
||||
item. A lost answer, a retry, a daemon restart, or disconnecting and connecting the same seat to the same hub and
|
||||
participant again lands on that same item, so it isn't queued twice. A new binding reports what the earlier one
|
||||
took.
|
||||
- If the local record of an item is gone, or it was handed on without one successor here, the hub item says its outcome
|
||||
is unknown, and it is never run again.
|
||||
- If someone else closed the hub item first (its requester canceled it, say), the local result stays here, the connector
|
||||
doesn't count it as delivered, and `rig network status` (and its `--json`) shows it under the binding as closed
|
||||
elsewhere. The connector checks the hub item just before closing it. It does not write over a close it has already
|
||||
seen, and reads the item back after a close attempt. A hub item someone there handed on counts as closed elsewhere
|
||||
too.
|
||||
- The derived id makes the queue item unique, not the work's outside effects. Anything your agent did outside OpenRig
|
||||
before a crash is neither undone nor deduplicated.
|
||||
|
||||
## Status
|
||||
|
||||
```bash
|
||||
rig network status [--json]
|
||||
```
|
||||
|
||||
Each binding shows its seat, participant, hub, state, and its last contact or last error. `--json` adds the
|
||||
connector's view: each binding's next pass, backoff and last pass.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
- **The seat has no session:** its hub items stay pending and unclaimed, and the last error says why. They're taken
|
||||
once the seat has a session again. A stopped seat still has its session, so it still takes items; they wait for it.
|
||||
- **The hub can't be reached, or a pass runs past its deadline:** the binding stays active, the error is recorded, and
|
||||
retries back off, doubling up to 5 minutes. A failure on this machine is recorded as local, not as the hub's.
|
||||
- **The hub refuses the credential** (HTTP 401): the binding is marked revoked and its credential removed. No new work
|
||||
comes in; local items and results stay.
|
||||
|
||||
## Disconnect
|
||||
|
||||
```bash
|
||||
rig network disconnect <binding-or-seat> [--reason "<why>"]
|
||||
```
|
||||
|
||||
The binding ends here, so no new work comes in through it, and its credential is removed. Local items and results
|
||||
stay. The hub still lists the participant until it's removed there too.
|
||||
|
||||
If the credential can't be removed, the binding still ends, and the error names the
|
||||
`rig network disconnect <binding id>` to run again. The daemon also removes credentials whose binding isn't active
|
||||
each time it starts.
|
||||
|
||||
## Limits
|
||||
|
||||
- The connector polls, so new work normally waits up to one interval before it reaches the seat, and longer when the
|
||||
hub fails or a backlog builds.
|
||||
- If someone else closes a hub item between the connector's check and its close, the hub may keep either outcome, or
|
||||
combine the later state with the earlier resolution. The connector counts its result as delivered only when the
|
||||
readback matches its state, closure reason and resolution; otherwise a closed item is reported as closed elsewhere.
|
||||
An atomic close-if-open needs the hub to offer a conditional close.
|
||||
- After a disconnect or a revocation, work already taken keeps running here, but its pickup and result no longer reach
|
||||
the hub item, which stays at its last reported stage.
|
||||
- **Plain http needs your choice.** `rig network connect` refuses an `http:` hub URL until you add `--allow-http`, because
|
||||
the participant's token and its work then cross the network unencrypted, where they can be read. Use the hub's
|
||||
`https:` URL where it has one. The choice is recorded on that binding, so the connector doesn't ask again, and
|
||||
`rig network status` (and its `--json`) marks the binding unencrypted. OpenRig doesn't judge the address: a private or
|
||||
tailnet address doesn't prove the connection is protected.
|
||||
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
|
||||
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
|
||||
binding to the hub's own URL.
|
||||
@@ -1,161 +0,0 @@
|
||||
# Queue erasure: closed-work retention and account deletion
|
||||
|
||||
A daemon that serves several accounts sometimes needs to remove queue content for good: closed work after its retention window, or everything that belongs to an account when the account is deleted. `rig queue erase` and three retention settings do that.
|
||||
|
||||
Erasure can't be undone. Run every scope as a dry run first, without `--apply`. The dry run prints exactly what `--apply` would erase and changes nothing.
|
||||
|
||||
## What is erased
|
||||
|
||||
A queue row is erased in one of two ways:
|
||||
|
||||
- **Scrub.** The row's content is removed, and the row itself stays, marked `contentErasedAt`. A retried create with the same id still finds the row, so it makes no new work. If anything writes to the row after its scrub (a note, a summary, a reopen and close), the next run erases that content too.
|
||||
- **Delete.** The row and everything linked to it are removed. Only its id is kept, in an erased-id record with no endpoints and no content. A later create that reuses the id is refused with `qitem_erased` (409), so a retried or forwarded create can't bring the work back.
|
||||
|
||||
| A scrub clears | A scrub keeps |
|
||||
|---|---|
|
||||
| body, summary, resolution, tags, blocker text, chain of record, evidence reference, human detail, questions and answers, delegated decision, the input-box reading | id, source and destination, state, priority, tier, times, closure reason, handoff links |
|
||||
| a closure's target when it's a reason or note, in the row, both transition tables and events. That's every target under a reason other than the two at right, and any target under those two that isn't a session address or a queue id | a closure's target that names a successor: under `handed_off_to` or `superseded`, shaped as a session address (`member@rig`, optionally `@host`) or a queue id (`qitem-…`) |
|
||||
| transition notes and recorded principals, live and archived | each transition's state, actor and time |
|
||||
| events' `summary`, `reason` and `error` (the event keeps its type and ids, marked `contentErased`) | |
|
||||
| mission-control audit text and snapshots | |
|
||||
|
||||
**The row's own copies go with it:**
|
||||
- its transitions;
|
||||
- events whose payload names it;
|
||||
- its mission-control audit rows.
|
||||
|
||||
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. Any other copy is kept and listed under `keptCopies`:
|
||||
- `mixed`: one of its sessions is the owner's;
|
||||
- `foreign`: none of them are.
|
||||
|
||||
**A row a workflow still references is never erased.** That covers a step trail, a frontier binding, a failure record, or a live instance's frontier. It's reported under `gaps` with the code `workflow-reference`.
|
||||
|
||||
## Scopes
|
||||
|
||||
Name exactly one.
|
||||
|
||||
| Scope | Erases | Action |
|
||||
|---|---|---|
|
||||
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
|
||||
| `--ids <file>` | Exactly the listed rows. A report's `qitems` entries, or a ledger's lines, keep each entry's own action. A ledger's entries are replayed only for the batches that committed (below) | `--action delete` (default for plain ids) or `--action scrub` |
|
||||
| `--routine` | Closed rows past this daemon's retention windows (below) | scrub, then delete |
|
||||
|
||||
A file is one entry per line (blank lines and `#` comments are skipped; a line starting with `{` is a JSON entry) or a JSON array.
|
||||
|
||||
**Account deletion is exact.**
|
||||
- The members file is the account's recorded membership: the exact session names of its seats, including departed ones. List every address form the account used, such as a host-suffixed form. Names are compared as given, never by pattern, so a row addressed through an unlisted form is neither erased nor reported.
|
||||
- **Mixed rows:** a row with exactly one member endpoint is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
|
||||
- **After the rows:** account deletion also removes outbox and inbox entries between two members that no row owns, and the members' stream items. An entry with one member endpoint, or a stream item something still references, is counted and left.
|
||||
|
||||
**The account's rig.** A rig's own records outlive the rig. Events are kept past rig and node deletion by design, and snapshots, node and seat records and watchdog jobs have no key that cascades. `--rig <id>`, given with `--members`, erases them:
|
||||
- the rig's events, including those of its nodes;
|
||||
- snapshots, operation correlations and bootstrap runs;
|
||||
- seat identity verdicts, delivery-guard changes, environment bindings and usage records for its nodes and member sessions;
|
||||
- watchdog jobs aimed at a member, with their history;
|
||||
- stream subscriptions and thread maps of member sessions.
|
||||
|
||||
Records that name the account but also belong elsewhere are counted under `rig.left` and kept. These are a member's watchdog job aimed outside the account, a workflow instance or spec bound to the rig, classifier leases and custom views.
|
||||
- **While the rig exists,** its sessions and the members must match both ways, or the run is refused (`erase_rig_mismatch`). Every member's session name must carry the rig's name (departed members included), and every current session of the rig must be a member, so the rig holds no one else's seat.
|
||||
- **On that verified run,** the rig's id and name are recorded with a digest of the membership.
|
||||
- **Once the rig is removed,** the same command, with the same membership, uses that record to erase what's left under the rig's id. This includes the events the removal wrote, and the record still lets it count records bound to the rig's name.
|
||||
- **When nothing of the rig is left,** the record becomes a completion receipt: the rig's name is cleared, and its id, the membership digest and the time stay. An identical rerun, after a lost response say, still verifies and reports the same clean result, with `rig.completedAt`. The receipt is kept for good. It holds no content.
|
||||
- **A removed rig's id that was never verified** with this membership erases nothing keyed by it. Those records are counted under `rig.left`, the name-bound tables are listed under `rig.unknown`, and the run isn't clean.
|
||||
|
||||
**Retention windows** (`rig config set …`, read at the daily sweep and at daemon start):
|
||||
|
||||
| Setting | Default | Effect |
|
||||
|---|---|---|
|
||||
| `retention.closed_content_days` | `0` (off) | Scrub closed rows this many days after they closed |
|
||||
| `retention.closed_metadata_days` | `0` (off) | Delete closed rows, keeping only their ids, this many days after they closed |
|
||||
| `retention.erasure_ledger` | empty (none) | An absolute file path, on local disk, since each batch appends to it while it holds the daemon. Every applied erasure, by the sweep or the command, appends each row's id, action and batch id there, one JSON line each, before anything in its batch changes, then a commit line once the batch commits (an abort line if it rolls back). A path that can't be written stops the run before it changes anything |
|
||||
|
||||
- **Closed** means `done`, `canceled`, `denied` or `handed-off`. `failed` and open work are never touched by the windows.
|
||||
- **Age:** a row's age counts from when it entered its final closed state. A note on a closed row doesn't restart it, and a row reopened and closed again counts from its latest close.
|
||||
- **Running them now:** `rig queue erase --routine --apply` runs the windows immediately. `retention.enabled false` turns off the whole sweep, these windows included.
|
||||
- **When the sweep's erasure stops on an error,** that's logged, and the sweep's other passes still run.
|
||||
|
||||
## Account deletion, in order
|
||||
|
||||
1. Revoke the account's access.
|
||||
2. Keep its exact membership list and rig id.
|
||||
3. Run `rig queue erase --members <file> --rig <id>` as a dry run, then with `--apply`. Rerun until `complete` is true.
|
||||
4. Remove the rig.
|
||||
5. Rerun the same command with `--apply`, to erase the records the removal wrote.
|
||||
6. The account is deleted only when the report's `clean` is true. Otherwise its `mixed`, `gaps`, `keptCopies`, `unlinked` and `rig.left` or `rig.unknown` entries need a decision. Rerunning won't resolve them.
|
||||
|
||||
## Output
|
||||
|
||||
The command prints one JSON report (`--json` makes it a single line):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `applied` | `false` for a dry run |
|
||||
| `complete` | the run finished. `false` when it stopped at its batch limit or on an error; rerun the same command to continue |
|
||||
| `clean` | `complete`, and nothing was left: no `mixed` rows, mixed `keptCopies`, `gaps`, `unreadableEvents`, left-over entries, or rig records left or unknown. Only a clean report means the scope is fully erased. It's about this run's scope: a routine or members run's `clean` isn't affected by the ledger's other batches, and only a replay (`--ids` with a ledger) reports a batch it can't show committed, as the gap `ledger-unresolved` |
|
||||
| `error` | why the run stopped (`stage` and `message`). Everything listed in `qitems` was committed before it; the command exits 2 |
|
||||
| `qitems` | each row erased (or that would be), with its `action` and `state` |
|
||||
| `mixed` | members scope: rows with one member endpoint, left untouched |
|
||||
| `keptCopies` | copies of erased rows held with someone else, kept: `table`, `id`, `qitemId`, `ownership` (`mixed` or `foreign`) |
|
||||
| `gaps` | rows not erased and why: `workflow-reference`; `not-found` for an id to scrub that doesn't exist; `changed-during-run` for a row that no longer qualified when its batch ran (reopened, rerouted out of the account, or written to so it left its window) or an absent id created since selection; `unreadable-event` for a row an unreadable event names; `ledger-unresolved` for a replayed entry whose batch can't be shown to have committed |
|
||||
| `skipped` | ids scope, a ledger replay: entries whose batch rolled back, with the batch id; not replayed |
|
||||
| `ledger` | an applied run with a ledger: batches whose missing commit line it wrote from the database's record (`reconciled`) |
|
||||
| `alreadyAbsent` | ids to delete with no row; their erased-id record is written (again), and they're listed once it's committed. They're rechecked as the record is written, so an id created during the run is reported as `changed-during-run` instead |
|
||||
| `unreadableEvents` | events naming a selected row whose payload can't be read: `seq` and the row ids found in its text. They're kept, since what they hold can't be inspected, and so are the rows they name, so every rerun reports them again until the event is repaired or removed. The run isn't clean |
|
||||
| `linked` | counts of the copies erased with the rows |
|
||||
| `unlinked` | members scope: entries and stream items erased that no row owned, plus those left (`mixedEntries`, `referencedStreamItems`) |
|
||||
| `rig` | members scope with `--rig`: `found`, `verified`, records erased per table (`erased`, counted as each chunk commits), records left (`left`), tables that can't be counted (`unknown`), and `completedAt` once the rig's erasure is complete |
|
||||
|
||||
**How a run works:**
|
||||
- **Selection:** a run selects everything first and writes nothing.
|
||||
- **Events:** it reads the events table once, in small windows, to find each row's events.
|
||||
- **Batches:** each batch, of up to 100 rows, is one transaction. It rechecks its rows, writes their ledger lines, then changes their events, copies and rows.
|
||||
- **The recheck drops** a row that a workflow has taken up, or that an unreadable event names. For account deletion, it also drops a row that is no longer between two members. For a routine run, it drops one that was reopened or no longer falls in the same window.
|
||||
- **Each dropped row** is reported in `gaps`.
|
||||
- **The current endpoints** decide whose its copies are.
|
||||
- **How long it holds the daemon:** the batch caps rows, not events or copies, so a row with many of either makes its batch longer. On a copy of a store with 2.4 million events, the longest single hold in a first routine run was about 0.25 s. With a ledger, a run doesn't read the ledger file: it reads the end of the file (one byte) and the database's record of batches still missing a commit line, so the ledger's size doesn't add to the hold.
|
||||
- **Stopping:** a run that stops leaves whole batches done, events included. A rerun picks up the rest, and erasing a row a second time changes nothing.
|
||||
- **Ledger lines and commits:** a batch's lines are written before its first change, so they can name rows that a rolled-back batch never erased. Each batch therefore also records its id in the database, in its own transaction, and appends a commit line (`{"batch": …, "committed": true}`) after it commits, or an abort line (`"committed": false`) when it rolls back.
|
||||
- **A missing commit line:** a run that can't write one stops once the committed batch is reported. The database's record notes when each batch's commit line was written. Each applied run with a ledger writes the commit line for any recorded batch still without one, from the record alone, without reading the ledger file. It lists those under `ledger.reconciled`.
|
||||
- **Neither line nor record:** a batch with neither, such as one whose transaction was cut off by a crash, is never decided from the database: on a restored database, a batch committed after the backup looks exactly like one that rolled back. A replay reports its entries as `ledger-unresolved` and doesn't apply them, until an operator resolves it by appending its commit or abort line once they know which.
|
||||
- **A line cut short:** a full disk, say, can cut a line short. Each run starts the ledger on a fresh line first, so the cut line can't fuse with the next one. A replay refuses the file until that line is deleted. That's safe: an entry line's batch rolled back when its write failed, and a commit line is written again from the record.
|
||||
|
||||
**Refusals**, with nothing erased:
|
||||
|
||||
| Code | Cause |
|
||||
|---|---|
|
||||
| `erase_scope_required` | no scope named |
|
||||
| `erase_scope_conflict` | more than one scope |
|
||||
| `erase_members_invalid` | an empty list, or names with surrounding spaces |
|
||||
| `erase_rig_invalid` | a rig without `--members`, or an empty rig id |
|
||||
| `erase_rig_mismatch` | the rig exists, and its sessions and the members don't match both ways |
|
||||
| `erase_ids_invalid` | an empty ids list, or ids with surrounding spaces |
|
||||
| `erase_action_invalid` | an action other than `delete` or `scrub` |
|
||||
| `erase_routine_off` | both windows are `0` |
|
||||
|
||||
The command records who ran it from `OPENRIG_SESSION_NAME`, or from `--actor <session>` when that isn't set.
|
||||
|
||||
## Restores and backups
|
||||
|
||||
A restored database can bring erased content back: rows, copies, rig records and the erased-id records themselves. It can even bring back a row that was still open when the backup was taken and was closed and erased later. Before a restored daemon serves anyone, replay what was erased after the backup was taken:
|
||||
|
||||
1. **Keep a ledger.** Set `retention.erasure_ledger` to a file outside the backed-up data. Every applied erasure appends its ids, actions and batch there before changing anything, and a commit line once the batch commits. That covers the daily windows, `--routine`, `--ids` and account deletion's rows.
|
||||
2. **Replay the ledger:** `rig queue erase --ids <ledger> --apply`. It replays exactly the batches that committed, each line with its own action, so a scrub is replayed as a scrub, never promoted to a deletion.
|
||||
- **What's replayed:** a row that was open in the backup and erased later is erased by its id, and other open work isn't touched.
|
||||
- **What's skipped:** a batch with an abort line was never erased, and is listed under `skipped`.
|
||||
- **What's left:** a batch with neither line, which the restored database doesn't record either, is reported as `ledger-unresolved` and not replayed. The run isn't clean until an operator decides it.
|
||||
3. **Replay account deletions:** rerun `rig queue erase --members <file> --rig <id> --apply` for each account deleted since the backup, from its membership list and rig id. Keep those outside the backed-up data until every backup taken before the deletion has expired. This also covers the copies and rig records the ledger doesn't list.
|
||||
4. **Rerun the windows:** `rig queue erase --routine --apply`.
|
||||
|
||||
Backup expiry is the deployment's own schedule. Content erased from the live database stays in backups until they expire.
|
||||
|
||||
## What this doesn't cover
|
||||
|
||||
These stores need their own handling where a deployment uses them:
|
||||
- terminal transcripts;
|
||||
- daemon and service logs;
|
||||
- gateway and Slack stores and Slack's own copies;
|
||||
- downloaded files and artifacts;
|
||||
- chat messages (removed with their rig);
|
||||
- events tied neither to a row nor to the account's rig.
|
||||
|
||||
The rig itself (its nodes, sessions and bindings) is removed by removing the rig, between the two runs above.
|
||||
@@ -587,13 +587,9 @@ rig provider switch --seat pi-worker@my-rig --to-account work \
|
||||
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
When switching into Pi with `--clear-account-home`, OpenRig restores the seat's original Pi login only if no effective member or rig Pi account home is configured. A member's `PI_CODING_AGENT_DIR` shadows the rig's value; if the member value is relative, it is ignored and the rig home does not take effect.
|
||||
|
||||
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply.
|
||||
Use `rig seat set-runtime <seat> --runtime <runtime> --reason "Change provider"` with `pi`, `codex` or `claude-code` for cross-runtime switches.
|
||||
Those resolve the target's settings afresh rather than keeping the source model or effort;
|
||||
see [runtime switching](getting-started.md#move-a-seat-to-the-other-runtime).
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
|
||||
and another runtime is not supported by this account-home feature.
|
||||
|
||||
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
|
||||
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
|
||||
@@ -695,7 +691,7 @@ first turn. Check `/status` after a real tool turn to confirm the authentication
|
||||
|
||||
For Codex, a saved native API-key login is selected with the existing `--to-account <name>`
|
||||
path, exactly like a saved subscription login. Prepare it using native `codex login --with-api-key`
|
||||
with the key on stdin, then `CODEX_HOME=<home> rig auth save <name>`; never put a key in argv.
|
||||
with the key on stdin, then `rig auth save --codex-home <home> <name>`; never put a key in argv.
|
||||
Use the seat's existing home. Preparing a login replaces that home's active credentials, so do it
|
||||
before running seats there, or prepare/import a native auth profile separately. Switches preserve
|
||||
an outgoing credential copy and report peers sharing the home. A native keyring or administrator
|
||||
@@ -783,7 +779,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
| `effort` | string | no | — | Reasoning effort override, trimmed. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking` (see "Pi" below). A non-string value is ignored with an advisory. |
|
||||
| `advisor` | string | no | — | Claude-only Advisor model for this seat. Overrides the AgentSpec profile/default; blank or non-string values are ignored with an advisory. |
|
||||
| `claude_mcp` | `none` | no | normal loading | Claude members only. Disable MCP server loading for this agent on every launch and resume. Omit the field to keep Claude's normal configuration; see [Claude MCP loading](#claude-mcp-loading-runtime-claude-code). |
|
||||
| `launch_wrapper` | string[] | no | — | Local `pi`, `codex`, `claude-code` and community-harness members; runner presets must declare wrapper support. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
|
||||
| `launch_wrapper` | string[] | no | — | Local built-in `pi`, `codex` and `claude-code` members only. Literal argument prefix applied to the native executable and its arguments on fresh launch, relaunch, rebuild, resume, fork, restore and handover; `[]` explicitly clears a stored wrapper when re-applying an authored spec, while omitting the key leaves an existing seat's stored choice unchanged. See [Launch wrapper](#launch-wrapper-runtime-pi-and-codex). |
|
||||
| `permission_policy` | string | no | — | Member-level permission policy; overrides the rig's. Not valid on terminal members. |
|
||||
| `env` | map | no | — | Launch environment for this seat, laid over the rig's `env`. Same rules as the rig-level field. Valid on terminal members too. See "Launch environment" below. |
|
||||
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
|
||||
@@ -921,11 +917,9 @@ This requires a Claude Code version that supports `--advisor`. OpenRig never wri
|
||||
choose different Advisors. When Advisor is unset, no flag is emitted and Claude's
|
||||
own defaults/settings apply. Other runtimes ignore this field.
|
||||
|
||||
<a id="launch-wrapper-runtime-pi-and-codex"></a>
|
||||
### Launch wrapper (`runtime: pi` and `codex`)
|
||||
|
||||
### Launch wrapper (`runtime: pi`, `codex` and `claude-code`)
|
||||
|
||||
Set `launch_wrapper` on a local `pi`, `codex`, `claude-code` or community-harness member to prefix its native executable and arguments with a literal argv vector:
|
||||
Set `launch_wrapper` on a local built-in `pi`, `codex` or `claude-code` member to prefix its native executable and arguments with a literal argv vector:
|
||||
|
||||
```yaml
|
||||
members:
|
||||
@@ -937,7 +931,7 @@ members:
|
||||
launch_wrapper: ["launcher", "exec", "--profile", "seat-a", "--"]
|
||||
```
|
||||
|
||||
- **Scope and literal argv:** Local `pi`, `codex`, `claude-code` and supported community-harness members support `launch_wrapper`; legacy flat nodes support it only on `pi`, `codex` and `claude-code`. Other runtimes, including the maintained `openshell-claude` environment path, reject it, including `[]`. Community preset capability and execution requirements are defined in the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch). Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
|
||||
- **Scope and literal argv:** Local built-in `pi`, `codex` and `claude-code` members, including legacy flat nodes, support `launch_wrapper`; other runtimes reject it, including `[]`. Each fork uses its target member's own selection. Arguments are literal strings without NUL, CR or LF; spaces, quotes, metacharacters and empty non-executable arguments are preserved.
|
||||
- **Executable resolution:** `launch_wrapper[0]` must be an absolute path or bare command name. Preflight and launch resolve it using the daemon's launch `PATH` and working directory, before adding the seat's `rig` bin directory. Relative paths containing `/`, aliases and functions are unsupported for the wrapper itself.
|
||||
- **Native child and `exec` contract:** The wrapper prefixes the resolved Codex entry, prefixes the resolved Pi child executable inside the host runner, or prefixes the real Claude executable and native arguments. Managed Claude applies it after rebuilding the environment; pinned continuations retain their executable. The wrapper must `exec` its child in place, preserving working directory, environment, signals, exit status and TTY stdio (Claude/Codex) or RPC pipes (Pi). Supervising wrappers are unsupported.
|
||||
- **Classic Claude lookup:** Wrapped classic launch supports bash, zsh, sh and dash and requires `command -v claude` to resolve to an executable file; aliases and functions refuse with `launch_wrapper unavailable; no agent started.` To keep custom flags or environment, put them in a `#!` script named `claude` on `PATH`; the wrapper runs that script ([#1477](https://github.com/mvschwarz/openrig/issues/1477)).
|
||||
@@ -1021,7 +1015,7 @@ This is human-authored (you choose the pod/member IDs) and system-validated (the
|
||||
| `spawned_by` | Source was spawned by target; the target launches first. Constrains launch order. | Child → parent in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a single declared delegation parent (none, or several). Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
| `escalates_to` | Fallback queue escalation for a seat without a declared delegation parent. Does NOT constrain launch order. | Local member or same-daemon `pod-member@rig` address |
|
||||
|
||||
A named `escalates_to` target may be in another rig on the same daemon and need not exist at import time.
|
||||
See [fallback escalation across rigs](edge-types.md#fallback-escalation-across-rigs) for precedence, single-hop delivery,
|
||||
@@ -1088,20 +1082,6 @@ and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports n
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
Managed Claude compaction also uses the delegation parent for two diagnostic notices: repeated
|
||||
unsent preparation attempts and repeated restore-delivery failures. If the seat has no recorded
|
||||
parent session (including when it is outside the recorded topology), these notices go to the
|
||||
configured registered human operator, or the default registered person when no operator is set.
|
||||
If no registered person can be selected, OpenRig creates no notice rather than inventing a
|
||||
destination: `rig compact <seat> --state` reports a restore-delivery notice failure, while a
|
||||
preparation notice is tried again on later polls. A parent is the source of a `delegates_to` edge
|
||||
to the seat; declare one when an agent should receive these notices. With multiple parents, the
|
||||
parent session with the latest creation time is selected; `escalates_to` is not used for these
|
||||
notices. The restore-delivery notice to a person omits local paths and
|
||||
points to `rig compact <seat> --state` on the host; the preparation notice includes its state-file
|
||||
evidence reference and attempt details. Treat these notices and any chat relay accordingly. Existing
|
||||
notices retain their destination when the topology changes.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
@@ -1378,7 +1358,7 @@ spec can pass `rig spec validate` and then fail at preflight or import. Run
|
||||
24. Startup action `idempotent` is a required boolean.
|
||||
25. Non-idempotent actions must not include `restore` in `applies_on`.
|
||||
26. `applies_on` values must be from: `fresh_start`, `restore`.
|
||||
27. `launch_wrapper` is valid on `pi`, `codex`, `claude-code` and supported community-harness members (`[]` included); see the [runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) for community capability requirements. It must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
|
||||
27. `launch_wrapper` is valid only on `pi`, `codex` and `claude-code` members (`[]` included); it must be an array of strings without NUL/CR/LF, and `launch_wrapper[0]` (when non-empty) must be an absolute path or a bare command name on the daemon `PATH`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -92,8 +92,3 @@ A member marked `"hosted": true` is a hosted member: a tool outside the terminal
|
||||
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
|
||||
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
|
||||
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
|
||||
It can also declare `check_in_minutes`, how often it's expected to check its queue (a positive whole number of minutes;
|
||||
an hourly schedule is `60`), or `check_in_on_request: true` when it checks in only when a person asks it. That's what
|
||||
its person set up, not something OpenRig observes. With neither, its check-in status reads unknown: OpenRig doesn't
|
||||
assume a cadence. A value that can't be used doesn't stop the roster loading: that member's check-in
|
||||
status reads unknown, with the reason. See [Check-in status](hosted-members.md#check-in-status).
|
||||
@@ -13,8 +13,6 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
|
||||
- **`--name`** is your preset's name, which your posts carry as `runtime`.
|
||||
- **`--resume` and `--first-input`** say your preset declares `resume: true` and `first_input: true`. Without them,
|
||||
those cases are skipped, because OpenRig wouldn't pass that input.
|
||||
- **`--launch-wrapper`** says your preset declares `launch_wrapper: true` and enables case 10 from the
|
||||
[runner contract](../harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch).
|
||||
- **`--cases 2,4`** runs only some cases. `--json` prints a machine-readable report. `--keep` keeps each launch's
|
||||
folder: the agent's message log, the launch record and the scenario.
|
||||
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
|
||||
@@ -64,7 +62,6 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
|
||||
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
|
||||
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
|
||||
| 9 | 9 | `OPENRIG_FIRST_INPUT`'s exact bytes, newlines and spaces included, are the first prompt |
|
||||
| 10 | 2 | The native child runs through the configured wrapper, preserving literal arguments, without inheriting the runner's wrapper handoff |
|
||||
|
||||
## What it doesn't check
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
// - a small local endpoint stands in for OpenRig's activity route, and records what your runner posts;
|
||||
// - input arrives the way `rig send` delivers it: one bracketed paste with raw newlines, then Enter.
|
||||
//
|
||||
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input] [--launch-wrapper]
|
||||
// node check-runner.mjs --runner '<command>' [--name <preset name>] [--resume] [--first-input]
|
||||
// [--cases 1,2,4] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]
|
||||
//
|
||||
// `{agent}` in the command is replaced with the fake agent's path, an executable that takes any arguments your runner
|
||||
// adds. For example: --runner 'my-runner --agent-command {agent}'. --resume and --first-input say your preset declares
|
||||
// `resume: true` and `first_input: true`; --launch-wrapper declares `launch_wrapper: true`. Without a declaration, its case is skipped.
|
||||
// `resume: true` and `first_input: true`; without them those cases are skipped, since OpenRig wouldn't pass that input.
|
||||
// The runner gets a clean environment: PATH, HOME, USER, SHELL, LANG and TMPDIR, the OpenRig variables, and any
|
||||
// --pass-env names. Exit status: 0 when nothing fails, 1 when a case fails, 2 for a usage or setup problem.
|
||||
//
|
||||
@@ -27,10 +27,10 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--launch-wrapper] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
|
||||
const usage = "usage: node check-runner.mjs --runner '<command with {agent}>' [--name <preset>] [--resume] [--first-input] [--cases 1,2] [--wait-ms 8000] [--pass-env NAME] [--json] [--keep]";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, launchWrapper: false, json: false, keep: false, waitMs: 8000 };
|
||||
const opts = { name: "runner", cases: undefined, passEnv: [], resume: false, firstInput: false, json: false, keep: false, waitMs: 8000 };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i];
|
||||
const value = () => { if (i + 1 >= argv.length) throw new Error(`${flag} needs a value`); return argv[++i]; };
|
||||
@@ -41,7 +41,6 @@ function parseArgs(argv) {
|
||||
else if (flag === "--wait-ms") opts.waitMs = Number(value());
|
||||
else if (flag === "--resume") opts.resume = true;
|
||||
else if (flag === "--first-input") opts.firstInput = true;
|
||||
else if (flag === "--launch-wrapper") opts.launchWrapper = true;
|
||||
else if (flag === "--json") opts.json = true;
|
||||
else if (flag === "--keep") opts.keep = true;
|
||||
else throw new Error(`unknown option ${flag}`);
|
||||
@@ -179,7 +178,7 @@ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const launches = [];
|
||||
async function launch(label, { scenario = {}, env = {}, asks, launchWrapper = false } = {}) {
|
||||
async function launch(label, { scenario = {}, env = {}, asks } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), `runner-check-${label}-`));
|
||||
const cwd = join(dir, "work");
|
||||
mkdirSync(cwd);
|
||||
@@ -197,13 +196,6 @@ async function launch(label, { scenario = {}, env = {}, asks, launchWrapper = fa
|
||||
const agent = join(dir, "agent");
|
||||
writeFileSync(agent, `#!/bin/sh\nexec ${sh(process.execPath)} ${sh(join(here, "fake-acp-agent.mjs"))} --scenario ${sh(join(dir, "scenario.json"))} --log ${sh(h.agentLog)} "$@"\n`);
|
||||
chmodSync(agent, 0o755);
|
||||
h.agent = agent;
|
||||
if (launchWrapper) {
|
||||
h.wrapperLog = join(dir, "wrapper.json");
|
||||
const wrapper = join(dir, "wrapper.sh");
|
||||
writeFileSync(wrapper, `#!/bin/sh\nrecord=$1\ntag=$2\nseparator=$3\nshift 3\n${sh(process.execPath)} -e 'const [file, tag, separator, ...argv] = process.argv.slice(1); require("node:fs").writeFileSync(file, JSON.stringify({tag, separator, argv, launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null}));' "$record" "$tag" "$separator" "$@"\nexec "$@"\n`);
|
||||
env = { ...env, OPENRIG_LAUNCH_WRAPPER: JSON.stringify(["/bin/sh", wrapper, h.wrapperLog, "literal ' $ ; space", "--"]) };
|
||||
}
|
||||
const vars = {
|
||||
PATH: process.env.PATH, HOME: process.env.HOME, USER: process.env.USER, SHELL: process.env.SHELL, LANG: process.env.LANG,
|
||||
TMPDIR: process.env.TMPDIR, TERM: "xterm-256color",
|
||||
@@ -789,22 +781,9 @@ const cases = {
|
||||
if (!opts.keep) rmSync(dir, { recursive: true, force: true });
|
||||
return r;
|
||||
},
|
||||
async "10"() {
|
||||
const r = result("10", "Launch wrapper: the runner wraps its native child and scrubs the handoff environment");
|
||||
if (!opts.launchWrapper) return r.skip("the preset doesn't declare launch_wrapper (--launch-wrapper): OpenRig passes no OPENRIG_LAUNCH_WRAPPER");
|
||||
const h = await launch("10", { launchWrapper: true });
|
||||
r.check("the wrapped child opens its conversation", await ready(h), "no launch record");
|
||||
const seen = existsSync(h.wrapperLog) ? JSON.parse(readFileSync(h.wrapperLog, "utf8")) : undefined;
|
||||
r.check("the wrapper receives literal arguments and the native child command", seen?.tag === "literal ' $ ; space" && seen?.separator === "--"
|
||||
&& seen?.argv?.[0] === h.agent, JSON.stringify(seen ?? null));
|
||||
r.check("OPENRIG_LAUNCH_WRAPPER is absent from the wrapper and child environment", seen?.launchWrapper === null
|
||||
&& h.events("started")[0]?.launchWrapper === null, "wrapper handoff leaked to native child");
|
||||
return r;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9", "10"];
|
||||
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
|
||||
const selected = opts.cases ?? order;
|
||||
const unknown = selected.filter((id) => !order.includes(id));
|
||||
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
|
||||
|
||||
@@ -42,7 +42,7 @@ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, me
|
||||
const exit = (status, why) => { log({ dir: "event", event: "exit", status, why }); process.exit(status); };
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2), launchWrapper: process.env.OPENRIG_LAUNCH_WRAPPER ?? null });
|
||||
log({ dir: "event", event: "started", pid: process.pid, cwd: process.cwd(), argv: process.argv.slice(2) });
|
||||
// Ctrl-C is the runner's to handle, so the agent never takes it as a signal. A stubborn agent also outlives a hangup and
|
||||
// its input closing, as some real agents do: only the runner ending it (SIGTERM or SIGKILL) stops it.
|
||||
process.on("SIGINT", () => log({ dir: "event", event: "signal", signal: "SIGINT" }));
|
||||
|
||||
@@ -161,25 +161,10 @@ or `pending`, `draft`. A word that matches none of these counts as `active`, so
|
||||
slice marked `complete` counts as `active`. A slice with no `status:` counts as
|
||||
`draft`.
|
||||
|
||||
`rig view show execution` shows the slice's own `status:` word (from its
|
||||
`SPEC.md`, or a legacy `README.md`) and that word's bucket beside each slice's
|
||||
ladder as `recorded_status` (`word: null` when there's none). Like the scopes
|
||||
views, it never reads `PROGRESS.md`; `GET /api/slices` lets a `status:` in
|
||||
`PROGRESS.md` frontmatter override the node file's. It's the team's record, not
|
||||
verified, so it never turns a rung true or makes a slice next-up.
|
||||
The ladder's rungs read Part B records: plan lock, `candidate:<sha>` rows, review
|
||||
artifacts and an EC-3 `worktree_path=` row. On this flow they read `INDETERMINATE`
|
||||
(plan lock reads `false`), and `q4_records` says once for the mission that no
|
||||
build is recorded. Built means a Git commit, so work that isn't a commit never
|
||||
reads built.
|
||||
|
||||
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
|
||||
record an attributed accept, reject or withdraw decision on a contract item,
|
||||
and `rig proof show` to read derived readiness up through slice, mission and
|
||||
project. The nearest slice, mission or project policy selects authorized judges.
|
||||
By default an item's latest judgment from any of them decides it; with
|
||||
`proofPolicy.everyJudge: true`, every listed judge's own current accept is
|
||||
required, and `rig proof show` lists each judge's own latest judgment either way.
|
||||
project. The nearest slice, mission or project policy selects authorized judges;
|
||||
`rig proof --help` describes setup, selectors and evidence requirements. In a
|
||||
multi-project workspace catalog, name the project with `--project <id>` or a
|
||||
`<id>:` scope prefix (`alpha:m0/slices/01-t001`); unqualified scopes use the
|
||||
|
||||
@@ -153,16 +153,12 @@ drive a recognized native menu as before. Otherwise, with the delivery guard
|
||||
attached, it reads the visible composer under the seat's lease immediately before
|
||||
pasting, after buffer preparation. Empty input proceeds. A recognized dim
|
||||
suggestion is displayed separately and counts as empty; typed or pasted text
|
||||
(author unknown) stops the send with `override_input_present`: single-send HTTP
|
||||
(author unknown) stops the send with `override_input_present`. An unreadable input
|
||||
stops with `override_input_unknown`, never an empty-input claim. Both return single-send HTTP
|
||||
409, `sent: false`, and caller-only `existingInput` evidence (visible rows, suggestion
|
||||
rows, cursor/dimensions and target binding). A collapsed paste shows its label,
|
||||
not the hidden contents. OpenRig neither holds nor retries this stopped override.
|
||||
|
||||
An input that can't be read doesn't stop the override. The answer is typed and
|
||||
nothing is cleared. The result reports `existingInput` with classification
|
||||
`unreadable`, never empty, and a warning that the answer may join or submit
|
||||
existing text.
|
||||
|
||||
Inspect the shown input in the native terminal, deliberately clear the prompt with
|
||||
native/tmux controls, then issue the send again. Manual clearing runs outside
|
||||
OpenRig's lease and observation checks and is not recorded. For a deliberate
|
||||
@@ -360,10 +356,7 @@ accounting. Historical outbox receipts do not suppress a lone reminder or preven
|
||||
message tells its recipient that delivery may repeat after an unconfirmed attempt. Its job history records the
|
||||
current transport result; an unknown write is not proof that nothing arrived.
|
||||
|
||||
The transport still protects approval/question prompts and recipient identity. Under the default
|
||||
`transport.draft_delivery` (`deliver`), an ordinary send or work notice that meets a person's draft at a Claude Code or
|
||||
Codex prompt is pasted into it and the combined input is submitted, unless the seat's typing guard holds input
|
||||
(`draft-aware`, `openrig-urgent`, `urgent-only` or `hold`). With `refuse`, a draft refuses the send. Scheduler timing,
|
||||
The transport still protects approval/question prompts, human drafts and recipient identity. Scheduler timing,
|
||||
current parked-row eligibility and stop checks still apply. This changes message retries, not the idempotence of
|
||||
structured continuity actions such as creating a cutover baton.
|
||||
|
||||
|
||||
@@ -66,14 +66,7 @@ not fill in; if so, follow the form.
|
||||
asked at RECORD stays on Steering and the Feed without a post. Running it again reuses each ask's
|
||||
one decision rather than making another, so an ask whose post is confirmed isn't posted again. As
|
||||
with any Slack post, one whose earlier attempt Slack couldn't confirm may post once more, marked as
|
||||
a possible repeat. An ask OpenRig can't fully check still posts, once per gateway run, through the
|
||||
person's delivery rules, so a readable quiet window still holds it. That's an ask whose attempt
|
||||
record can't be read, whose state can't be checked, or whose person's quiet hours can't be read.
|
||||
The Slack post can show "Delivery may repeat after an earlier unconfirmed attempt.", "OpenRig
|
||||
couldn't check that this ask is still open." and "OpenRig couldn't read your quiet hours." These
|
||||
supplementary notes are omitted if they do not fit. The row and command also report when the
|
||||
person's quiet-hours setting couldn't be read. An ask that is no longer open isn't posted. `--wait <seconds>` (default 10) is how long it waits for
|
||||
Slack receipts.
|
||||
a possible repeat. `--wait <seconds>` (default 10) is how long it waits for Slack receipts.
|
||||
|
||||
## What the app asks for
|
||||
|
||||
@@ -384,18 +377,11 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
|
||||
wake-ladder alerts. These reach the person exactly as they would outside quiet. An ask below the posting level is only
|
||||
logged, as at any time.
|
||||
- **When quiet ends,** each held ask that never started posts once, on its own, with no mention. One that no longer applies
|
||||
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, its person has no
|
||||
connector any more (it stays on Steering and the Feed), or it's now below the posting level.
|
||||
isn't posted, and its row says why: it closed, a newer notification replaced it, its recipient changed, or it's now below
|
||||
the posting level.
|
||||
- **A post that may already be in Slack**, confirmed or only partly sent, isn't held: it finishes as itself, in its own
|
||||
thread.
|
||||
- **If quiet hours can't be read,** that isn't a quiet window. An ask that would post goes to its usual destination with
|
||||
no mention, and the post says "OpenRig couldn't read your quiet hours."; the ask's row records it. The person's other
|
||||
delivery settings still decide: an escalation, a digest, an ask below the posting level, and a quiet window that can be
|
||||
read work as before. This covers ordinary posts and digests, and asks already held for quiet hours: while the quiet
|
||||
hours can't be read, the next release check (within about a minute) posts each of them, with no mention and the same
|
||||
line, even if the window may still be on. The line is supplementary: a post at Slack's text or block limit leaves it
|
||||
out, and the ask's row and `rig slack post-open-asks` still say the quiet hours couldn't be read. A person reached
|
||||
through a relay waits until their quiet hours can be read, as before, because that post can't carry the line.
|
||||
- **If quiet hours can't be read,** nothing is posted to that person until they can.
|
||||
|
||||
## Reconnect recovery and status
|
||||
|
||||
@@ -419,11 +405,9 @@ budget window when exhausted. Slack's per-method `Retry-After` is retained acros
|
||||
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
|
||||
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
|
||||
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
|
||||
an archived channel or an uneditable message) remain unfinished. A deleted original is recorded
|
||||
on the row and never asked about again; a new post of the same ask is tried afresh. The others,
|
||||
which a scope, membership, a different bot token or an upgrade can fix, are retried on the next
|
||||
connector start. A message is uneditable when the configured token didn't post it. They do not keep consuming edit capacity. These edit calls have their own budget,
|
||||
separate from missed-message scanning below.
|
||||
an archived channel or an uneditable message) remain unfinished and are retried only on the next
|
||||
connector start. They do not keep consuming edit capacity. These edit calls have their own
|
||||
budget, separate from missed-message scanning below.
|
||||
|
||||
The baseline already includes public-channel history access and `chat:write`. Private-channel
|
||||
reads may require `groups:history`, which the baseline does not grant; a refused read never
|
||||
@@ -470,18 +454,9 @@ not covered by that margin. A corrupt or unreadable checkpoint leaves recovery u
|
||||
and preserves the existing file; live inbound continues.
|
||||
|
||||
Each pass admits at most four history calls, 100 entries and 15 seconds of work, with a
|
||||
five-second request ceiling. Channels and threads take turns within that one budget. Each tracked
|
||||
thread is scanned once after each start and after each Socket Mode reconnect that followed a gap
|
||||
(not a refresh Slack overlaps with the old connection), which catches replies sent while no
|
||||
connection was up; replies while connected arrive live. A thread the pass at the connect itself
|
||||
reaches is scanned once more, since that pass stops five seconds short of the connect. Those scans go in need order: a thread
|
||||
leaving the seven-day window within six hours first, since once it leaves a missed reply is never
|
||||
recovered; then threads whose ask still waits on the person (its queue row is pending, in progress
|
||||
or blocked) before the rest, each newest first. The order comes from the saved checkpoints, so a
|
||||
restart starts with the newest open ask, not the oldest thread. After that a thread is scanned again
|
||||
only as a backstop, hourly while its ask waits on the person and daily otherwise. A machine the hub
|
||||
relays to can't see a gap in relayed events, so there a settled thread's backstop is three hours. `rig slack status` shows how far behind the oldest waiting thread is and how
|
||||
many threads are settled. Work already started on a message, such as
|
||||
five-second request ceiling. Channels and threads take turns within that one budget, so with many
|
||||
tracked threads each thread is scanned every few passes; `rig slack status` shows how far behind
|
||||
the oldest waiting thread is. Work already started on a message, such as
|
||||
an attachment transfer, is not cancelled by these limits or by a stop, and a new
|
||||
pass waits for it to finish. Rate limits retain Slack's Retry-After across restart, and hold back
|
||||
only the method Slack limited (channel history or thread replies).
|
||||
@@ -490,8 +465,8 @@ When Slack reports a plan history limit, recovery still lands the available page
|
||||
and advances normally, retaining an older-history limitation in status across restarts.
|
||||
Missing bot credentials, history scope, membership, retention limits and API
|
||||
failures appear as recovery limitations. A tracked thread Slack can't find stays incomplete in
|
||||
status and is asked again at most once a day, across restarts; that isn't proof the thread was
|
||||
deleted. No additional scope is required to keep
|
||||
status, with no further call, until its thread mapping changes or the daemon restarts or rewires
|
||||
the connector; that isn't proof the thread was deleted. No additional scope is required to keep
|
||||
live delivery working. Threads nobody tracks, older open threads, threads closed more than seven
|
||||
days ago and other channels remain outside this recovery scope; global chronological order is
|
||||
not promised. This change does not replay intervals already checkpointed before broadcast-reply
|
||||
|
||||
@@ -74,11 +74,7 @@ New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
to 30 seconds for the same session name and managed seat identity to return,
|
||||
then show the new session with the original read-only setting. This uses the
|
||||
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
|
||||
In-place handover and restart keep showing the same session. The exception is
|
||||
an SSH tile when the local tmux server is isolated: it runs
|
||||
`rig terminal attach` on the seat's host (`--read-only` for a read-only tile),
|
||||
which attaches with tmux once (`tmux attach -r` for a read-only tile). It
|
||||
doesn't follow a replaced session or a restarted tmux server; reopen the view.
|
||||
In-place handover and restart keep showing the same session.
|
||||
|
||||
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
|
||||
the agent's PTY under tmux's normal client sizing policy, and resizing the
|
||||
@@ -193,10 +189,6 @@ timed out, OpenRig closes nothing: the result says `rex_outcome_unknown`, or
|
||||
adds a note when other pages opened, and names the session to check in Rex
|
||||
before retrying. `rig terminal status` gives Rex about a second to answer.
|
||||
|
||||
## herdr-app
|
||||
|
||||
`--provider herdr-app` opens the view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the same when that window is answering (finding the socket file is how OpenRig guesses you are in a herdr-app window, and whether the window is reachable is checked by asking it). `--window` always asks for a new desktop window. See [OpenRig views in herdr-app](herdr-app.md).
|
||||
|
||||
## HTTP API
|
||||
|
||||
- `POST /api/terminal/open`: `{ view, provider?: "herdr", session?: string, expectedPlan?: string }`.
|
||||
@@ -366,25 +358,20 @@ labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
A tmux window has one size, and a tmux client narrower than it sees only the
|
||||
left part of each line. So a read-only tile shows its seat without attaching to
|
||||
it: about once a second it reads the seat's screen with `tmux capture-pane`,
|
||||
redraws only after tmux records new activity, and lets the tile's own terminal
|
||||
wrap each line. A read-only tile narrower than the agent's window still shows
|
||||
whole lines, and showing a seat never changes its size. A rule drawn across the
|
||||
window is cut to the tile's width. A read-only tile has no scrollback of its
|
||||
own. (A read-only SSH tile on an isolated local server attaches read-only
|
||||
instead; see [Replacing a seat's session](#replacing-a-seats-session).) A
|
||||
writable tile attaches with plain tmux when it opens (see
|
||||
[Replacing a seat's session](#replacing-a-seats-session)): keys and paste go
|
||||
through tmux as in any attached client, tmux's prefix works (prefix then `[` to
|
||||
scroll back, for example), and the tile sizes the window while it's the active
|
||||
client. If the seat's tmux server restarts, a tile shows the seat again only
|
||||
when the same managed seat returns. The printed manual attach commands are
|
||||
`tmux attach -t` for a writable tile and `tmux attach -r -t` for a read-only
|
||||
one, with a local seat's tmux server (`-L <name>` or `-S <path>`) when it
|
||||
isn't tmux's default socket. For a seat on another host they run over `ssh -t`, and
|
||||
when the local tmux server is isolated, as `rig terminal attach` on that host.
|
||||
Observers of a Herdr terminal do not themselves resize
|
||||
left part of each line. So a tile shows its seat without attaching to it: about
|
||||
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
|
||||
after tmux records new activity, and lets the tile's own terminal wrap each
|
||||
line. A tile narrower than the agent's window still shows whole lines, and
|
||||
showing a seat never changes its size. A rule drawn across the window is cut to
|
||||
the tile's width. A read-only tile only shows the seat. In a writable tile, the
|
||||
first thing you type or paste attaches the tile and goes through tmux as it
|
||||
would in any attached client: a paste arrives as one paste, and tmux's prefix
|
||||
works (prefix then `[` to scroll back, for example). The tile sizes the window
|
||||
while you use it, as any client you type in does; after 30 seconds without a
|
||||
key it detaches and shows the seat again. A tile that's only showing has no
|
||||
scrollback of its own. If the seat's tmux server restarts, a tile shows the
|
||||
seat again only when the same managed seat returns. The printed manual attach commands
|
||||
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
|
||||
it; a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
|
||||
@@ -109,10 +109,7 @@ To move a team that runs on an older server:
|
||||
shares your default server, which runs the new version only after every session
|
||||
on it, yours included, has ended.
|
||||
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
|
||||
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
|
||||
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
|
||||
the old server.
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are.
|
||||
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
|
||||
`rig terminal status --json` should show `migrationPending: false`. If it's still
|
||||
`true`, `blockingSessions` names the sessions still on the old server.
|
||||
@@ -167,8 +164,8 @@ or removing its state when isolation or a deferred migration pins it. Ordinary
|
||||
an old advisory socket is not used to redirect it. Cleanup kills only registered
|
||||
session names on the selected server.
|
||||
|
||||
Dedicated selection requires POSIX tmux. On native Windows (not a supported platform;
|
||||
the [README](../../README.md) covers WSL2), psmux retains its existing server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
|
||||
server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
|
||||
## Two instances on one machine
|
||||
|
||||
|
||||
+703
-178
@@ -1,94 +1,194 @@
|
||||
# What changed in OpenRig 0.7.1
|
||||
# What changed in OpenRig 0.7.11
|
||||
|
||||
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.11.
|
||||
OpenRig 0.7.11 sends ordinary messages and work notices into a person's typed
|
||||
draft by default instead of refusing them, lets a person reach the team from
|
||||
their own coding agent (the `openrig` plugin) or an ACP app (`rig acp serve`),
|
||||
holds a prepared managed compaction until the seat is idle, and clears a
|
||||
restore that stopped at a prompt. On Linux, a daemon run as a systemd user
|
||||
service keeps its seats when its unit restarts. It adds five database
|
||||
migrations. On OpenShell 0.1.6 the worker's maintained cleanup now works, but
|
||||
the worker still isn't end to end. If you upgraded
|
||||
from 0.7.9 or earlier, also read "What changed in OpenRig 0.7.10" below, and
|
||||
from 0.7.8 or earlier, "What changed in OpenRig 0.7.9" 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.
|
||||
- **Back up the database, then restart the daemon.** Five migrations run on
|
||||
the daemon's first start: 150 (node launch wrapper), 151 (Pi account
|
||||
switches), 152 (workflow instance spec), 153 (events retention marks) and 154
|
||||
(workflow instance root objective). If `$OPENRIG_DB` isn't backed up yet,
|
||||
copy that file (`rig config get db.path` shows it) with the daemon stopped,
|
||||
then `rig daemon start`. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **New settings:** `transport.draft_delivery` (`deliver`), `acp.to` (empty),
|
||||
`daemon.seat_poll_interval_ms` (1000) and `retention.events_days` (`0`, keeps
|
||||
every event). `rig config` shows each with its source.
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Managed refresh preserves replaced
|
||||
files under `$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits
|
||||
you still need from the preserved copy.
|
||||
- **Linux, with the daemon as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh; until then,
|
||||
stopping or restarting the daemon's unit still takes them down.
|
||||
- **Tell the person sends now join their drafts** (below). To keep the refusal,
|
||||
run `rig config set transport.draft_delivery refuse`; it applies from the
|
||||
next send, with no restart.
|
||||
- **Pi seats:** a Pi seat that declares an absolute `PI_CODING_AGENT_DIR` in its
|
||||
`env:` now uses it as its account home and needs a readable `auth.json`
|
||||
there, or the launch stops. Prepare one, or remove the declaration to keep
|
||||
the managed seat login. Ask the person before changing their spec.
|
||||
- **herdr-core terminals:** new starts no longer inherit `HC_SCOPE`,
|
||||
`HC_TERMINAL_ID` or `HC_TERMINAL_SOCKET`; a daemon or tmux server already
|
||||
running with them keeps them until it's restarted.
|
||||
- **Claude seats that load openrig-core themselves** lose OpenRig's unchanged
|
||||
copies of its skills at their next launch, so each skill loads once; the
|
||||
launch result warns with the copies removed or kept.
|
||||
|
||||
## New things you can do
|
||||
## Sends and drafts
|
||||
|
||||
- **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.
|
||||
- **Under `deliver` (the default),** an ordinary `rig send` or work notice to a
|
||||
seat whose prompt holds a person's typed draft pastes the message at the
|
||||
cursor and submits the combined input as one prompt. The result says whether
|
||||
submission was confirmed and, when OpenRig saw the draft, that it joined it.
|
||||
An unset, unreadable or invalid value follows `deliver`.
|
||||
- **Still refused or held under either value:** menus, permission prompts, a
|
||||
seat's typing guard (`draft-aware`, `hold`), managed compaction's sends and
|
||||
`--wait-for-idle`.
|
||||
- **Under `refuse`,** a send is refused, and a work notice retried once the
|
||||
prompt is clear, where OpenRig detects the draft. Claude Code and Codex sends
|
||||
now also read the input box before pasting, so a draft only the box shows
|
||||
refuses too (`target_needs_input` / `prompt_draft`).
|
||||
- **The draft-aware typing guard** reads a Claude composer with a statusLine or
|
||||
the background task panel, and Claude Code 2.1.319's bare
|
||||
`✔ Update installed · Restart to update` row in a narrow pane, so an empty box
|
||||
gets the wake instead of `draft_input_unknown`.
|
||||
- **Held messages coalesce:** a sender's newest held message at a seat retires
|
||||
its earlier ones. Read them with `rig seat held-messages <seat>` (each with
|
||||
`--id <id>`); an older backlog clears at that sender's next held message or
|
||||
with `rig seat retire-held-message`.
|
||||
- **Stopped seats aren't woken at each daemon start:** their rows stay owed, and
|
||||
the start-up log says `waiting=N (not replayed: …)`. That isn't delivery.
|
||||
|
||||
## Behaviour that changed
|
||||
## Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction is held, not re-requested.** When the map is
|
||||
complete but `/compact` couldn't go in, the attempt stays `waiting`, marked
|
||||
`prepared`, and sends `/compact` at the next idle moment with an empty box.
|
||||
`rig compact <seat> --state` shows it; a manual `rig compact` reads
|
||||
`already_in_progress`; `--cancel` disarms it. It ends on a context dip, a
|
||||
conversation change, a stale map (`prepared_map_stale`: 15 points of growth or
|
||||
2 hours) or a map that stops reading ready.
|
||||
- **The final `/compact` waits out the preparation's own fresh activity** (up
|
||||
to about 23 s, against 9 s), so an idle seat that ran out of context compacts
|
||||
instead of being refused; a busy seat can wait up to about 14 s longer.
|
||||
- **A queue wake refused at a prompt** no longer goes in right after
|
||||
"Compacted" while the compaction still owes its turn boundary and restore
|
||||
request; it goes in at the next idle moment after that step clears.
|
||||
- **A full restore stopped at a prompt clears itself** once the seat runs the
|
||||
conversation it was restoring. If a seat still reads `attention_required`, run
|
||||
`rig seat clear-attention <seat>`, with `--reason` after a partial restore or
|
||||
a community harness's assisted startup. `proof_superseded` means the restore
|
||||
changed mid-check.
|
||||
- **A Codex helper inside a Claude seat** no longer takes the seat's resume
|
||||
token (`transcript_not_this_session`).
|
||||
- **A Claude rebuild handover may report its priming packet unconfirmed.**
|
||||
Submit the existing packet once, only if it's still staged. Never resend it
|
||||
blindly or rerun the handover.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source on restore; `keep_saved` is the default.
|
||||
|
||||
## Working with the team from outside
|
||||
|
||||
- **The `openrig` plugin** lets a person use their own Claude Code or Codex
|
||||
with their local team: hand it work, follow it, answer the team's questions
|
||||
in their own words, and read the result. Install it from the package
|
||||
(`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`) or from this repository on
|
||||
GitHub. Everyone using the app on a machine acts as the seat it names.
|
||||
- **VS Code, Copilot CLI and Cursor** start its server with no seat: add your
|
||||
own `rig mcp serve --front-door` server with `OPENRIG_SESSION_NAME` set and
|
||||
turn the plugin's off. A seatless app can create tasks, send and post as
|
||||
`unknown-mcp-<8 hex>`, but can't claim, update, hand off or answer.
|
||||
- **ACP apps:** `rig acp serve --to <member@rig>` (or the `acp.to` setting)
|
||||
makes the team an agent in an app that launches a custom ACP agent, such as
|
||||
Zed or T3 Code. Nothing runs until an app starts it, and it uses the daemon
|
||||
`rig` uses. A question left unanswered in the app stays open in the TUI and
|
||||
Slack. The `@openrig/acp` launcher is packed beside the CLI at the same
|
||||
version. See `docs/reference/acp-agents.md`.
|
||||
- **Assistants through a grant service** are experimental, unsupported and off
|
||||
by default: they need your own internet-reachable `rig mcp serve --http
|
||||
--grant-service <url>` and an outside grant service. A registered person can
|
||||
answer the team's questions from one, and `--task-destination <member@rig>`
|
||||
names where its tasks go (`docs/reference/hosted-members.md`).
|
||||
- **The `relay` gateway connector** is off unless you add a relay binding. It
|
||||
hands questions and updates to a bridge the operator runs on loopback;
|
||||
"Accepted by bridge" isn't delivery (`docs/reference/gateway-relay.md`).
|
||||
|
||||
## OpenShell workers
|
||||
|
||||
On OpenShell 0.1.6 the maintained cleanup (`delete-owned`) now works for a
|
||||
custom image, and recreate and an exact resume passed in our native run. A
|
||||
restored guest still receives its startup text again, so strict recall after a
|
||||
restore isn't met, and the worker isn't end to end. The CA fix holds on 0.1.6.
|
||||
"Never grant the guest access to the daemon's main API
|
||||
port (normally 7433)."
|
||||
|
||||
## Other changes to act on
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Rows on another host:** `rig queue show`, `list`, `transitions`, `update`
|
||||
and `block` take `--host <id>`; `claim` doesn't, since only the destination
|
||||
seat on its own host claims. `--host` never follows `rig host select`.
|
||||
- **A seat's unclaimed backlog:** a seat with no escalation route or delegation
|
||||
parent gets one `unclaimed-backlog` notice. To route its findings to a
|
||||
watching seat:
|
||||
`rig edge add <rig> <root seat's logical id> <watching seat> --kind escalates_to`.
|
||||
- **Claims read `working`** for up to 60 minutes while a Claude owner has a
|
||||
live background shell, and 20 while a managed restore is owed.
|
||||
- **A cross-host handoff that overrides your claim** wakes you with the
|
||||
successor's id: stop and coordinate with that seat.
|
||||
- **Code results:** set an `openrig.code-result/v1` manifest as `evidenceRef`;
|
||||
`rig queue artifact <qitemId>` and `rig_artifact_read` return the commit pair.
|
||||
- **Workflows:** runs keep their own spec; `rig workflow unregister
|
||||
<name>[@<version>]` (or `--prune-missing`) removes registered versions;
|
||||
`rig workflow trace <id> --full` returns the whole trail; `show`, `trace` and
|
||||
`status` print `objective:`.
|
||||
- **`rig stream emit` timeouts:** repeat with the same `--id`, or check with
|
||||
`rig stream list` before emitting again.
|
||||
- **Launch and setup:** `launch_wrapper` works for local Claude Code, Pi and
|
||||
Codex seats; `rig provider switch --seat <pi seat> --to-account <name>
|
||||
--account-home <dir> --reason "<why>"` switches a Pi seat's account (`--dry-run`
|
||||
first), and a Pi seat with a declared account home starts again; `rig config set` says when
|
||||
an environment variable masks the saved value.
|
||||
- **Linux seats:** when the daemon runs as a systemd user service, the seats'
|
||||
tmux server starts in its own user scope (`openrig-tmux-<id>.scope`), so the
|
||||
daemon's unit can stop or restart without ending them. Otherwise the server
|
||||
starts as before, with one log line giving the reason.
|
||||
- **Writable terminal tiles** attach as in 0.7.10: the screen in use sizes the
|
||||
seat and text reflows on resize. Read-only tiles keep showing without attaching
|
||||
or resizing the seat. Web fixed geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default). As in 0.7.10, Enter or Ctrl-C while
|
||||
the first attach is in flight can close that viewer; the agent keeps running,
|
||||
so reopen the view. Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11
|
||||
need a manual refresh: close only the owned viewer and reopen with
|
||||
`rig terminal open`; for `--follow`, use `--no-follow` first, then open a fresh
|
||||
view. The agent and conversation are preserved; no automatic migration runs.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **A Claude seat launched with `--settings` JSON written with spaces** can be
|
||||
verified again.
|
||||
- **`view.changed` is no longer stored,** and `/api/events` no longer sends it.
|
||||
- **Old history-only events can be pruned, if you choose to.** `retention.events_days` (default `0`, which keeps every
|
||||
event) turns on a pass in the retention sweep (at daemon start and daily) that deletes `view.changed`,
|
||||
`snapshot.created`, `watchdog.evaluation_fired` and the `queue.created`, `queue.updated`, `queue.claimed`,
|
||||
@@ -107,105 +207,530 @@ after an upgrade it can still hold the previous version's note.
|
||||
- **With pruning on, `rig telemetry events` reports the pruned ranges.** A page that spans pruned events inside the
|
||||
retained window has a `retained_sequence_gap` for each missing seq range and `coverage.status` `partial` (it used to be
|
||||
`retained_window`); events pruned from the start of the log show up as `before_retained_floor`.
|
||||
- **Large fleets:** `daemon.seat_poll_interval_ms` (up to 2000) lowers CPU at
|
||||
the cost of activity freshness; keep 1000 if tmux is slow.
|
||||
- **Terminals:** Herdr follow views work on macOS (protocol 22) and open a
|
||||
snapshot (`follow_unsupported`) where live following isn't supported.
|
||||
|
||||
- **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.
|
||||
## Limits to keep in view
|
||||
|
||||
## Output that changed shape
|
||||
- **OpenShell:** a restored guest gets its startup text again, strict recall
|
||||
isn't met, and the worker isn't end to end (above).
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead; under `deliver`, a result may not mention a draft.
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A refused queue wake now waits for that step,
|
||||
but recovery at daemon start and a new handoff's wake don't check yet.
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept.
|
||||
- **Terminal tiles** each run one tmux command a second while showing (8 tiles
|
||||
used about 6–8% of one core on an Apple Silicon Mac mini).
|
||||
- **A message held as `draft_input_unknown`** isn't retried, and whether
|
||||
managed compaction gets past the update banner wasn't retested.
|
||||
- **Claude provider switch:** the "probe pane returned to a shell" failure in
|
||||
[#2341](https://github.com/mvschwarz/openrig/issues/2341) isn't shown fixed;
|
||||
the launcher-script rebuild timeout and `--no-chrome` are fixed.
|
||||
- **ACP apps** were checked in T3 Code (Beta) 0.1.7 and with a scripted client;
|
||||
the unanswered-question notice hasn't run in a live app. A cancel stops
|
||||
waiting; the team's work carries on.
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. **Grant-service assistants** still aren't woken.
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed.
|
||||
- **Going back to 0.7.10** isn't established; an older permission hook also
|
||||
gives named-policy Claude sessions no decision until they're relaunched.
|
||||
- **The other 0.7.10 limits below still apply:** `rig exec` cancels,
|
||||
permission requests in seat status, Codex hooks (including
|
||||
`rig seat restart --keep-conversation` refusing a Codex seat launched with
|
||||
developer instructions), smol-toml 1.9.x, Slack, Claude Code 2.1.319's auto
|
||||
mode, Codex snapshot restores and `AGENTS.md`, unretried reminders, SSH host
|
||||
status, advisories on a native resume, seats handed over before 0.7.9, `rig`
|
||||
in the TUI's shell after a project-local install, the "Startup prompt still
|
||||
staged" message, and a partial install from two npm installs at once.
|
||||
0.7.10's bare-banner and launcher-script limits are fixed, and its OpenShell
|
||||
limit is replaced by the one above.
|
||||
|
||||
- **`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.
|
||||
---
|
||||
|
||||
## What to stop doing
|
||||
## What changed in OpenRig 0.7.10
|
||||
|
||||
- **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.
|
||||
The changes from 0.7.9 to 0.7.10, published on 2026-11-08, for an install
|
||||
upgrading from 0.7.9 or earlier. 0.7.10 added `rig exec` for finite runs on
|
||||
Claude Code or Codex, sent a held post-compaction step after two idle polls that
|
||||
can't read the input box, left every tmux mode before a delivery, and showed
|
||||
agent spec advisories at launch. Its OpenShell line below is corrected.
|
||||
|
||||
## Known gaps in 0.7.1
|
||||
### After the upgrade
|
||||
|
||||
- **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`.
|
||||
- **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))
|
||||
|
||||
### Finite runs: `rig exec`
|
||||
|
||||
`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.
|
||||
|
||||
### Compaction and delivery
|
||||
|
||||
- **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))
|
||||
|
||||
### OpenShell workers
|
||||
|
||||
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)."
|
||||
|
||||
*Corrected after 0.7.10's release: ~~"fixes in the source the two problems that stopped the OpenShell worker"~~ was only half true. The CA fix holds on OpenShell 0.1.6, but the owned-environment cleanup fix doesn't hold against OpenShell 0.1.6, as the published 0.7.10 notes say. See 0.7.11's OpenShell workers section above.*
|
||||
|
||||
### Other changes to act on
|
||||
|
||||
- **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.
|
||||
|
||||
### Limits to keep in view
|
||||
|
||||
- **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.**
|
||||
Generated
+5
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7298,7 +7298,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7336,12 +7336,11 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"hono": "^4.7.0",
|
||||
"smol-toml": "^1.8.0",
|
||||
@@ -7380,7 +7379,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/acp",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"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.1"
|
||||
"@openrig/cli": "0.7.11"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "openrig",
|
||||
"name": "OpenRig",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"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]"
|
||||
"package": "@openrig/[email protected]1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.11",
|
||||
"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": [
|
||||
|
||||
@@ -437,9 +437,9 @@ export class DaemonClient {
|
||||
// DaemonResponseError carrying the status + a bounded snippet, instead of a
|
||||
// raw SyntaxError bubbling to a cryptic (json) or silent (human) CLI exit.
|
||||
// A well-formed non-2xx body still parses and returns {status,data}; 204 has none.
|
||||
if (res.status === 204) return { status: res.status, data: undefined as T, headers: res.headers };
|
||||
if (res.status === 204) return { status: res.status, data: undefined as T };
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) as T, headers: res.headers };
|
||||
return { status: res.status, data: JSON.parse(text) as T };
|
||||
} catch {
|
||||
throw new DaemonResponseError(res.status, text);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,6 @@ Keys:
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
ui.terminal.max_live_terminals
|
||||
cap on simultaneously-live web-client terminals (effective default 2)
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
@@ -103,20 +101,12 @@ Keys:
|
||||
permission prompts still reach a person,
|
||||
choice prompts follow this setting; unavailable human delivery stays a visible row;
|
||||
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
|
||||
retention.* enabled, transitions_days, watchdog_days, usage_samples_days,
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
|
||||
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
|
||||
the telemetry export, the log and the feed, and can't be brought back. Read at the next sweep
|
||||
(daily and at daemon start)
|
||||
retention.closed_content_days remove the content of closed queue work (done, canceled, denied, handed-off) N
|
||||
days after it closed; the row stays, without content. 0 keeps it (default). Can't be undone
|
||||
retention.closed_metadata_days delete closed queue rows N days after they closed, keeping only their ids so a retry
|
||||
can't recreate them. 0 keeps them (default). Both read at the next sweep; "rig queue erase
|
||||
--routine --apply" runs them now
|
||||
retention.erasure_ledger an absolute file where queue erasure appends each erased id and its action, one JSON
|
||||
line each, before it commits them; keep it outside backups and replay it with "rig queue
|
||||
erase --ids <file> --apply" after a restore. Empty keeps no ledger (default)
|
||||
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
|
||||
|
||||
@@ -42,8 +42,6 @@ import {
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { resolveRecapSeat, type RigSeat } from "../recap-seat.js";
|
||||
import { parseSessionName } from "../session-name.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { reconstructionCommand } from "./context-reconstruction.js";
|
||||
|
||||
@@ -426,28 +424,6 @@ Examples:
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
/** A rig's seats as the daemon records them, for mapping a recap's --seat by identity. Never starts the daemon. */
|
||||
async function rigSeats(rig: string): Promise<RigSeat[] | { unavailable: string }> {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const rigs = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
|
||||
if (rigs.status !== 200 || !Array.isArray(rigs.data)) return { unavailable: `the daemon's rig list returned HTTP ${rigs.status}` };
|
||||
const ids = rigs.data.filter((entry) => entry.name === rig).map((entry) => entry.rigId);
|
||||
if (ids.length !== 1) return { unavailable: ids.length ? `${ids.length} rigs are named ${rig}` : `the daemon has no rig named ${rig}` };
|
||||
const nodes = await client.get<Array<{ logicalId: string; podNamespace?: string | null; canonicalSessionName: string | null }>>(
|
||||
`/api/rigs/${encodeURIComponent(ids[0]!)}/nodes`);
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return { unavailable: `the daemon's seat list returned HTTP ${nodes.status}` };
|
||||
return nodes.data.flatMap((node) => {
|
||||
const session = node.canonicalSessionName ? parseSessionName(node.canonicalSessionName) : undefined;
|
||||
if (session?.kind !== "canonical") return [];
|
||||
const pod = node.podNamespace;
|
||||
return [{ logicalId: node.logicalId, member: pod && node.logicalId.startsWith(`${pod}.`) ? node.logicalId.slice(pod.length + 1) : node.logicalId, folder: session.member }];
|
||||
});
|
||||
} catch (err) {
|
||||
return { unavailable: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// OPR.0.5.3.6 — the productized chain-file trace. Daemon-independent by
|
||||
// design: the walk is a config read + filesystem reads, so it works on a
|
||||
// box whose daemon is down (orientation is exactly when that happens).
|
||||
@@ -810,21 +786,14 @@ Examples:
|
||||
// tree (a sandboxed seat) hands the same recap to the daemon, which owns it.
|
||||
cmd.command("recap-write")
|
||||
.description("Write this seat's authored RECAP (decisions-with-rationale) at the handover boundary; supersedes the previous recap into the seat's chain")
|
||||
.option("--rig <rig>", "Rig name (the rigs/<rig> altitude; default: this seat's rig, from OPENRIG_SESSION_NAME)")
|
||||
.option("--seat <seat>", "The seat's folder: the part of its session name before @, e.g. dev-qa (default: this seat). A seat's recorded logical id or member id maps to its folder when the daemon can check it")
|
||||
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
|
||||
.requiredOption("--seat <seat>", "Seat id (the seats/<seat> altitude)")
|
||||
.requiredOption("--file <path>", "Markdown file containing the recap content")
|
||||
.action(async (opts: { rig?: string; seat?: string; file: string }) => {
|
||||
.action(async (opts: { rig: string; seat: string; file: string }) => {
|
||||
try {
|
||||
const { writeSeatRecapAt, validateRecapContract } = await import("@openrig/daemon/seat-recap-store");
|
||||
const content = readFileSync(opts.file, "utf-8");
|
||||
const topologyRoot = String(new ConfigStore().resolveWithSource("topology.root").value);
|
||||
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim();
|
||||
const own = sessionName ? parseSessionName(sessionName) : undefined;
|
||||
const rigName = opts.rig ?? (own?.kind === "canonical" ? own.rig : undefined);
|
||||
// A named seat is checked against the rig's recorded seats when the daemon answers; a failed check doesn't stop the write.
|
||||
const seats = opts.seat !== undefined && rigName ? await rigSeats(rigName) : undefined;
|
||||
const target = resolveRecapSeat({ rig: opts.rig, seat: opts.seat, topologyRoot, sessionName, seats });
|
||||
if ("error" in target) { console.error(target.error); process.exitCode = 1; return; }
|
||||
for (const f of validateRecapContract(content)) {
|
||||
console.error(f.kind === "no-decisions-section"
|
||||
? "ADVISORY no-decisions-section: the authoring contract asks for decisions WITH rationale — conclusions alone are the lossy handoff shape."
|
||||
@@ -832,7 +801,7 @@ Examples:
|
||||
}
|
||||
let written: { path: string; superseded: number; daemonUrl?: string };
|
||||
try {
|
||||
written = writeSeatRecapAt({ topologyRoot, rig: target.rig, seat: target.seat, content });
|
||||
written = writeSeatRecapAt({ topologyRoot, rig: opts.rig, seat: opts.seat, content });
|
||||
} catch (err) {
|
||||
if (!WRITE_REFUSED_CODES.has((err as NodeJS.ErrnoException).code ?? "")) throw err;
|
||||
// The daemon owns the topology tree (it primes RECAP.md at the next rebuild): it writes the same recap through
|
||||
@@ -840,13 +809,12 @@ Examples:
|
||||
// that can't be reached is reported with its reason.
|
||||
console.error(`Note: this process can't write the topology tree (${(err as Error).message}), so the daemon writes the recap instead.`);
|
||||
const client = await getClient();
|
||||
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: target.rig, seat: target.seat, content });
|
||||
const res = await client.post<{ path?: string; superseded?: number; message?: string }>("/api/context-packs/seat-recap", { rig: opts.rig, seat: opts.seat, content });
|
||||
if (res.status !== 200 || typeof res.data.path !== "string") throw new Error(res.data.message ?? `Daemon returned HTTP ${res.status}`);
|
||||
written = { path: res.data.path, superseded: res.data.superseded ?? 0, daemonUrl: client.baseUrl };
|
||||
}
|
||||
console.log(`Recap written: ${written.path}${written.superseded > 0 ? ` (${written.superseded} superseded predecessor${written.superseded === 1 ? "" : "s"} retained)` : ""}`
|
||||
+ (written.daemonUrl ? `, by the daemon at ${written.daemonUrl}` : ""));
|
||||
for (const note of target.notes) console.error(`Note: ${note}`);
|
||||
} catch (err) {
|
||||
// A daemon write that timed out says its outcome is unknown, so it's checked before a retry supersedes it again.
|
||||
if (!renderCaughtTransportError(err)) console.error((err as Error).message);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { acquireDaemonStartLock, bootedAtMs, currentBootId } from "../daemon-start-lock.js";
|
||||
import { acquireDaemonStartLock } from "../daemon-start-lock.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { fetchWithTimeout } from "../fetch-with-timeout.js";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
statusGuardMessage,
|
||||
} from "../daemon-lifecycle.js";
|
||||
|
||||
interface ProcessAliveDeps {
|
||||
@@ -38,16 +37,14 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
|
||||
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
|
||||
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
|
||||
* "not-permitted" (uninspectable), never as a dead process. */
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +94,7 @@ export function realDeps(): LifecycleDeps {
|
||||
|
||||
return {
|
||||
processLiveness,
|
||||
acquireStartLock: () => {
|
||||
const lock = acquireDaemonStartLock(OPENRIG_DIR, { bootId: currentBootId(), bootedAtMs: bootedAtMs(), processLiveness });
|
||||
if (lock.archived) console.error(`A start reservation from before this boot was left behind; archived it as ${lock.archived}`);
|
||||
return lock;
|
||||
},
|
||||
acquireStartLock: () => acquireDaemonStartLock(OPENRIG_DIR),
|
||||
spawn: (cmd, args, opts) => spawn(cmd, args, opts as Parameters<typeof spawn>[2]),
|
||||
fetch: async (url) => {
|
||||
const res = await fetchWithTimeout(globalThis.fetch, url, {}, {
|
||||
@@ -162,11 +155,6 @@ export function realDeps(): LifecycleDeps {
|
||||
isProcessAlive,
|
||||
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
|
||||
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
|
||||
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
|
||||
pidNamespace: () => {
|
||||
if (process.platform !== "linux") return "host";
|
||||
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +165,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
cmd
|
||||
.command("start")
|
||||
.description("Start the daemon")
|
||||
.addHelpText("after", "\nStartup reserves this local instance before initialization and verifies the spawned child PID on every required listener.\nMissing/mismatched identity or child exit fails startup; failed publication withdraws only this launch's matching state. Use a matching CLI/daemon installation.\nA concurrent start fails without spawning another child. Inspect daemon-start.lock for launcher/child PIDs after an interrupted start;\nonly archive an abandoned reservation after proving both processes absent. A failed cleanup retains it and reports the child PID.\nA reservation from an earlier boot is archived as daemon-start.lock.abandoned-<time> and the start goes on.\n")
|
||||
.addHelpText("after", "\nStartup reserves this local instance before initialization and verifies the spawned child PID on every required listener.\nMissing/mismatched identity or child exit fails startup; failed publication withdraws only this launch's matching state. Use a matching CLI/daemon installation.\nA concurrent start fails without spawning another child. Inspect daemon-start.lock for launcher/child PIDs after an interrupted start;\nonly archive an abandoned reservation after proving both processes absent. A failed cleanup retains it and reports the child PID.\n")
|
||||
.option("--port <port>", "Port to listen on")
|
||||
.option("--host <host>", "Host to bind on")
|
||||
.option("--db <path>", "Database path")
|
||||
@@ -279,19 +267,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
// need a kernel-ready signal opt in here.
|
||||
if (opts.waitForKernel) {
|
||||
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
|
||||
const { attentionLines, checkKernelAttention } = await import("../seat-attention.js");
|
||||
const { DaemonClient } = await import("../client.js");
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
const baseUrl = `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
|
||||
const heldLines = result.kernelState !== "ready" && result.kernelState !== "skipped"
|
||||
? attentionLines(await checkKernelAttention(new DaemonClient(baseUrl)), "pane").map(line => ` ${line}`) : [];
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
for (const line of heldLines) console.log(line);
|
||||
} else if (result.kernelState === "skipped") {
|
||||
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
|
||||
console.error(skippedKernelMessage(result.detail));
|
||||
@@ -305,7 +287,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
"\n" +
|
||||
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
|
||||
);
|
||||
for (const line of heldLines) console.error(line);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -377,12 +358,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
|
||||
console.log(` sibling: ${status.siblingHint.siblingHome}`);
|
||||
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
|
||||
} else if (status.pidUnseen) {
|
||||
// #2860: the same words as the command guard.
|
||||
const unseen = statusGuardMessage(status);
|
||||
console.log(unseen.fact);
|
||||
console.log(` ${unseen.consequence}`);
|
||||
console.log(` ${unseen.action}`);
|
||||
} else if (!status.startPending) {
|
||||
// A pending start's probe was refused, not inconclusive; its line above says what is known.
|
||||
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
|
||||
|
||||
@@ -281,7 +281,7 @@ Examples:
|
||||
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
|
||||
.option("--json", "Complete record(s) as JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const { listHumans, NO_HUMAN_CONFIGURED } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
|
||||
const res = listHumans();
|
||||
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
|
||||
const humans = await Promise.all(res.humans.map(async (record) => {
|
||||
@@ -290,7 +290,7 @@ Examples:
|
||||
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
|
||||
}));
|
||||
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
|
||||
if (res.humans.length === 0) { console.log(NO_HUMAN_CONFIGURED); return; }
|
||||
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
|
||||
for (const h of humans) {
|
||||
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
|
||||
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
|
||||
@@ -567,14 +567,9 @@ Examples:
|
||||
|
||||
human
|
||||
.command("set <entityId> <field> <value>")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n> (a full kind:connectorRef:secretsRef:role[:handle=<id>] spec, or unset to remove that binding); and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.description("Edit one field through the verb (same validation as add; re-projection immediate). Fields: display-name, delivery-class, away, availability (available|focus|away|off, or unset), slack-subscription (the person's Slack subscription: every-ask, the default, posts every ask to Slack too; urgent-only posts only asks created with --alert, the rest wait on Steering and the TUI; or unset), binding.<n>; and, kept in the participant side file: organization (an id, or unset), groups (comma-separated ids, or unset), policy (text, @<path> to read a file, or unset)")
|
||||
.option("--reason <reason>", "Reason recorded with connector binding changes", "update human binding")
|
||||
.option("--actor <actor>", "Named operator when outside a managed seat")
|
||||
.addHelpText("after", `
|
||||
Removing a connector binding (binding.<n> unset):
|
||||
- The one-primary rule stays: no replacement primary is chosen silently. If removing a primary binding would leave other bindings, the command refuses. To replace the primary in place, set its slot to the new primary spec; to promote an existing secondary binding, first save that binding's full spec (kind:connectorRef:secretsRef:role[:handle=<id>]) from rig gateway human show <entityId> --json, then unset that secondary slot, then find the primary's current slot in remainingBindings (or rig gateway human show <entityId> --json, since the primary's slot shifts down when a lower-index secondary is removed) and set it to the saved spec with role primary (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary); to remove every binding, unset the secondary bindings first using their current slot indexes from remainingBindings or rig gateway human show <entityId>, then finish at binding.0 (once all secondaries are gone, the primary is at binding.0).
|
||||
- Remaining bindings are printed with their shifted binding.<n> slot indexes (the primary's slot shifts down too when a lower-index secondary is removed). Because indexes shift after removal, do not blindly retry 'binding.0 unset'; check remainingBindings, the receipt, or 'rig gateway human show <entityId>' before retrying.
|
||||
- Removing a binding keeps the person, their preferences, their other bindings and their open questions. Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain); the current delivery-outcome display may disappear too, while historical receipt notes remain. It does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted.`)
|
||||
.action(async (entityId: string, field: string, value: string, opts: { reason: string; actor?: string }) => {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
if (field === "organization" || field === "groups" || field === "policy") {
|
||||
@@ -607,19 +602,7 @@ Removing a connector binding (binding.<n> unset):
|
||||
return { value: res, after: { digest }, effect: digest === prior ? "no-op" : "applied" };
|
||||
},
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
entityId,
|
||||
field,
|
||||
path: result.value.path,
|
||||
receipt: result.receipt,
|
||||
...(value === "unset"
|
||||
? {
|
||||
remainingBindings: result.value.remainingBindings ?? [],
|
||||
note: result.value.note,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
console.log(JSON.stringify({ ok: true, entityId, field, path: result.value.path, receipt: result.receipt }));
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { execFile } from "node:child_process";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
@@ -167,13 +168,32 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const conf = new Map(stdout.split("\n").map((line) => {
|
||||
const i = line.indexOf(" ");
|
||||
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
|
||||
}));
|
||||
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
|
||||
const hostname = conf.get("hostname");
|
||||
const port = Number(conf.get("port"));
|
||||
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against an HTTP host's URL host:port; passive SSH entries report
|
||||
* `unknown` without running `ssh` or opening a raw TCP socket (explicit SSH
|
||||
* verification lives in `rig host doctor`). Coarse by
|
||||
* against the host's transport endpoint (ssh: the host and port ssh itself
|
||||
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
|
||||
* design (the kubectl STATUS / docker context ls precedent): reachable |
|
||||
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
|
||||
* failure = unreachable, probe error/unparseable endpoint = unknown). */
|
||||
@@ -183,12 +203,17 @@ export async function probeHostStatus(
|
||||
timeoutMs = 1500,
|
||||
): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
return "unknown";
|
||||
const endpoint = await resolveSshEndpoint(host, timeoutMs);
|
||||
if (!endpoint) return "unknown";
|
||||
({ hostname: target, port } = endpoint);
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
}
|
||||
const u = new URL(host.url);
|
||||
const target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
const r = await tcpProbe(target, port, timeoutMs);
|
||||
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
|
||||
} catch {
|
||||
@@ -837,8 +862,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
// retry loop forever. Values that work today keep their wait. The target
|
||||
// keeps the request open as long as this side waits, and its approval says so.
|
||||
const timeoutMs = resolvePairTimeoutMs(opts.timeout);
|
||||
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string;
|
||||
person?: string; owner?: string | null; ownerNote?: string; noReader?: string };
|
||||
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
|
||||
try {
|
||||
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
|
||||
requester: `${userInfo().username}@${osHostname()}`,
|
||||
@@ -860,14 +884,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
if (!opts.json) {
|
||||
console.log(`Pairing code: ${issued.code}`);
|
||||
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
|
||||
// A target that says who was asked and who reads the answer: name them. An older target keeps the general text.
|
||||
if (issued.person) {
|
||||
console.log(`The request went to ${issued.person} on the target.`);
|
||||
console.log(issued.owner
|
||||
? `${issued.owner} reads their answer and decides${issued.ownerNote ? ` (${issued.ownerNote}, so ${issued.owner} reads it instead)` : ""}.`
|
||||
// The target's own words: none reads answers, or it couldn't tell. Never a categorical "nobody" over an unknown.
|
||||
: `On the target: ${issued.noReader ?? "it didn't say whether an agent reads answers there"}. The request names how to decide directly on the target.`);
|
||||
} else console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log("On the target, its person answers the pairing request in the TUI (rig tui, then attention), on Steering, or in Slack: with Approve or Deny, or in their own words. On a 0.7.11 or later target with a kernel operator, the operator reads the answer and decides (a stopped operator's answer waits for it); on a target with no kernel operator, Approve and Deny decide, and typed words are kept and the person is asked again. On an older target any typed reply approves.");
|
||||
console.log(`On a 0.7.11 or later target, anyone at its terminal with its operator bearer token can also decide: rig host approve ${issued.code} (or rig host deny ${issued.code}), with OPENRIG_AUTH_BEARER_TOKEN set or --bearer <token>.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type LaunchResponse = {
|
||||
// operator must act). These are NOT successful launches — never print "Launched" for
|
||||
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
|
||||
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed", "left_attached"]);
|
||||
export function launchStatusRunning(status: string): boolean {
|
||||
function launchStatusRunning(status: string): boolean {
|
||||
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { DaemonClient, operatorAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
export interface NetworkDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => Pick<DaemonClient, "get" | "post">;
|
||||
readTokenFile?: (path: string) => string;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
id: string; localSession: string; hubUrl: string; hubSeat: string; state: string; stateReason: string | null;
|
||||
lastContactAt: string | null; lastError: string | null; unencrypted?: boolean;
|
||||
/** Hub items someone else closed before this binding's result reached them. */
|
||||
closedElsewhere?: Array<{ hubItem: string; localItem: string }>;
|
||||
}
|
||||
|
||||
/** The participant's token, read from the file the account gave the person. A file others can read is used, with a note. */
|
||||
function defaultReadTokenFile(path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile()) throw new Error(`${path} isn't a regular file.`);
|
||||
if ((stat.mode & 0o077) !== 0) console.error(`Note: ${path} can be read by other users of this machine; OpenRig keeps its own private copy.`);
|
||||
return readFileSync(path, "utf8").trim();
|
||||
}
|
||||
|
||||
function line(b: Binding): string {
|
||||
const contact = b.lastContactAt ? `last contact ${b.lastContactAt}` : "no contact yet";
|
||||
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
|
||||
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
|
||||
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}`;
|
||||
}
|
||||
|
||||
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
|
||||
* another OpenRig hub), see where each connection stands, and disconnect one. */
|
||||
export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
const cmd = new Command("network")
|
||||
.description("Connect selected local seats to a remote hub, see their status, and disconnect them");
|
||||
const getDeps = (): NetworkDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
const client = async () => {
|
||||
const deps = getDeps();
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(daemon)) return null;
|
||||
return deps.clientFactory(getDaemonUrl(daemon));
|
||||
};
|
||||
const finish = (status: number, data: unknown, json: boolean | undefined, ok: (data: Record<string, unknown>) => string) => {
|
||||
if (status >= 400) {
|
||||
const error = data as { error?: string; message?: string };
|
||||
console.error(json ? JSON.stringify(data) : `${error.message ?? "The daemon refused this"} (${error.error ?? status})`);
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
console.log(json ? JSON.stringify(data, null, 2) : ok(data as Record<string, unknown>));
|
||||
};
|
||||
|
||||
cmd.command("connect")
|
||||
.description("Connect a local seat to a hub participant, using that participant's token")
|
||||
.argument("<seat>", "The local seat to connect (its address, member@rig)")
|
||||
.requiredOption("--hub <url>", "The hub's MCP URL")
|
||||
.requiredOption("--as <participant>", "The participant's address in the hub (member@rig)")
|
||||
.requiredOption("--token-file <path>", "A file holding the participant's token")
|
||||
.option("--allow-http", "Accept a plain-http hub for this binding: its token and work cross the network unencrypted")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (seat: string, opts: { hub: string; as: string; tokenFile: string; allowHttp?: boolean; json?: boolean }) => {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${opts.as} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
|
||||
});
|
||||
|
||||
cmd.command("status")
|
||||
.description("List the connections and where each stands")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.get<{ bindings: Binding[] }>("/api/network/bindings");
|
||||
finish(res.status, res.data, opts.json, (d) => {
|
||||
const bindings = (d.bindings ?? []) as Binding[];
|
||||
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
|
||||
});
|
||||
});
|
||||
|
||||
cmd.command("disconnect")
|
||||
.description("Disconnect a seat: no new work comes in through it; its local work and results stay")
|
||||
.argument("<binding-or-seat>", "The binding id, or the connected seat's address")
|
||||
.option("--reason <text>", "Why, for the record")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (ref: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Disconnected ${line(b as unknown as Binding)}\nRemove the participant on the hub too, so the account stops listing it.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -137,7 +137,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
conflicts?: Array<{ existingPath: string; reason: string }>;
|
||||
verification?: { passed: boolean };
|
||||
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
|
||||
notes?: string[];
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
code?: string;
|
||||
@@ -205,11 +204,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.notes && res.data.notes.length > 0) {
|
||||
console.log("Left as it is:");
|
||||
for (const note of res.data.notes) console.log(` ${note}`);
|
||||
}
|
||||
});
|
||||
|
||||
// rig package rollback <installId>
|
||||
|
||||
@@ -143,11 +143,6 @@ export function proofCommand(): Command {
|
||||
The selected daemon owns show/judge. Scope addresses are mission/slices/slice#item.
|
||||
The project owner selects proofPolicy: { judges: [exact-seat-address] } in the
|
||||
owning slice.yaml, mission.yaml or project.yaml (nearest wins; no default gate roles).
|
||||
By default an item's latest judgment from any listed judge decides it. Add
|
||||
everyJudge: true to require every listed judge's own current accept: one judge's
|
||||
accept then can't hide another's reject, withdraw, missing judgment or stale
|
||||
evidence. Turning it on is a policy change, so earlier judgments must be recorded
|
||||
again. rig proof show lists each judge's own latest judgment either way.
|
||||
The contract is the authored ## Proof contract. A sole item needs no # selector.
|
||||
|
||||
Example (existing evidence, no copied hashes or operation key):
|
||||
@@ -168,17 +163,9 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
if (cmd.opts().workspace) throw new Error("--workspace applies to proof add. Select the judgment daemon using OPENRIG_URL; proof show reports its scope basis.");
|
||||
return new DaemonClient();
|
||||
};
|
||||
// A path_escape response names the supported routes to another workspace's tree.
|
||||
const projectSelected = (project: unknown, scope: unknown) => Boolean(project) || /^[^/:]+:/.test(typeof scope === "string" ? scope : "");
|
||||
const escapeHint = (project: boolean) => project
|
||||
? "\nThe scope is outside that project's missions root; name a mission/slice path inside it."
|
||||
: "\nThe scope is outside the selected daemon workspace. For a catalog project, pass --project <id> or prefix the scope with <project>:."
|
||||
+ " To use another daemon's workspace, select that daemon with OPENRIG_URL.";
|
||||
const response = async (r: { status: number; data: unknown }, project = false): Promise<Record<string, any>> => {
|
||||
const response = async (r: { status: number; data: unknown }): Promise<Record<string, any>> => {
|
||||
const data = r.data as Record<string, any>;
|
||||
if (r.status >= 400) {
|
||||
throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}${data.error === "path_escape" ? escapeHint(project) : ""}`);
|
||||
}
|
||||
if (r.status >= 400) throw new Error(`${data.error ?? r.status}: ${data.message ?? "Read the named source and retry"}`);
|
||||
return data;
|
||||
};
|
||||
const projectHelp = "Workspace-catalog project id; resolves the scope under that project's missions root (same as a <project>:<scope> prefix)";
|
||||
@@ -188,7 +175,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
.action(async (scope, opts) => {
|
||||
try {
|
||||
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), ...(scope ? { scope } : {}) }).toString();
|
||||
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`), projectSelected(opts.project, scope));
|
||||
const data = await response(await client().get(`/api/proof${query ? `?${query}` : ""}`));
|
||||
console.log(opts.json ? JSON.stringify(data) : JSON.stringify(data, null, 2));
|
||||
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
|
||||
});
|
||||
@@ -209,7 +196,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
const refs = [...new Set<string>([...opts.evidence, ...(opts.comparison ? [opts.comparison] : [])])];
|
||||
const query = new URLSearchParams({ ...(opts.project ? { project: opts.project } : {}), scope });
|
||||
for (const ref of refs) query.append("evidence", ref);
|
||||
const c = client(), view = await response(await c.get(`/api/proof?${query}`), projectSelected(opts.project, scope));
|
||||
const c = client(), view = await response(await c.get(`/api/proof?${query}`));
|
||||
const items = view.items as Array<{ id: string; text: string; index: number; revision: string; judgment: { id: string } | null }> | undefined;
|
||||
const item = selector ? items?.find(i => i.id === selector || i.text === selector || String(i.index) === selector) : items?.length === 1 ? items[0] : undefined;
|
||||
if (!item) throw new Error("Select one current item with scope#item; rig proof show lists IDs, text and indices");
|
||||
@@ -222,7 +209,7 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
operationId: opts.operationId, replace: opts.replace === true, ...(opts.project ? { project: opts.project } : {}),
|
||||
// Pin the project root the prepared read resolved; the daemon refuses (409 project_changed) if it moved.
|
||||
...((view.project as { root?: string } | undefined)?.root ? { projectRoot: (view.project as { root: string }).root } : {}) };
|
||||
const result = await response(await c.post("/api/proof/judge", body), projectSelected(opts.project, scope));
|
||||
const result = await response(await c.post("/api/proof/judge", body));
|
||||
console.log(opts.json ? JSON.stringify(result) : JSON.stringify(result, null, 2));
|
||||
} catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exitCode = 1; }
|
||||
});
|
||||
|
||||
@@ -605,7 +605,7 @@ and native credential-store requirements are unchanged. No fresh account home or
|
||||
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
|
||||
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
|
||||
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
|
||||
a launch_wrapper. To change runtime, use rig seat set-runtime with --runtime pi, codex or claude-code.
|
||||
a launch_wrapper; changing a seat between Pi and another runtime is not supported.
|
||||
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
|
||||
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
|
||||
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
|
||||
|
||||
@@ -43,7 +43,7 @@ interface PsEntry {
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeEntry {
|
||||
interface NodeEntry {
|
||||
/** Served daemon attention, never re-derived here. Missing on an older daemon. */
|
||||
needsAttention?: boolean;
|
||||
arbitratedNeedsInput?: boolean | null;
|
||||
@@ -716,26 +716,6 @@ function startedWithoutLine(files: string[]): string {
|
||||
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
|
||||
}
|
||||
|
||||
/** Why a seat that needs attention does, from what the daemon recorded: a missing session, a startup error, a held
|
||||
* message, a missing required file, or the served needs-input reason. Otherwise "reason unavailable". */
|
||||
export function attentionReason(n: NodeEntry): string {
|
||||
return missingSessionGuidance(n) ?? n.latestError ?? (n.heldReason ? `held: ${n.heldReason}` : null)
|
||||
?? (n.startedWithoutRequired?.length ? startedWithoutLine(n.startedWithoutRequired) : null)
|
||||
?? questionReason(n) ?? "reason unavailable";
|
||||
}
|
||||
|
||||
/** Whether the daemon serves a live question for the seat, with its own precedence: the arbitrated needs-input answer,
|
||||
* and the hook only when there's none. A served "no" beats a stale hook. */
|
||||
export function servesQuestion(n: NodeEntry): boolean {
|
||||
return n.arbitratedNeedsInput ?? (n.agentActivity?.state === "needs_input");
|
||||
}
|
||||
|
||||
/** The served question's reason, with the daemon's precedence: the arbitrated answer, and the hook only without one. */
|
||||
export function questionReason(n: NodeEntry): string | null {
|
||||
return n.arbitratedNeedsInput === true ? n.activityState?.needsInput.reason ?? null
|
||||
: n.arbitratedNeedsInput == null && n.agentActivity?.state === "needs_input" ? n.agentActivity.reason : null;
|
||||
}
|
||||
|
||||
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
|
||||
return nodes.map((n) => {
|
||||
const keepDiagnostics = n.needsAttention !== false;
|
||||
@@ -1390,12 +1370,8 @@ async function handleNodes(
|
||||
if (useCompact) {
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
// With the daemon's attention flag set, a live question shows its own reason rather than a recorded error. Without
|
||||
// that flag (an older daemon), the order stays as it was, so a startup failure isn't hidden by a hook. The question's
|
||||
// reason is shown whole, as the activity reason always was: it's what a person reads to know what to answer.
|
||||
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
|
||||
? (n.needsAttention === true && servesQuestion(n) ? questionReason(n) ?? "reason unavailable"
|
||||
: n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
|
||||
: n.agentActivity?.reason ?? "—")
|
||||
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
|
||||
console.log(padCompactNodeRow(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Command, InvalidArgumentError } from "commander";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError, remoteDaemonClient, type DaemonResponse } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard, resolveOriginSelfHostId } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { sessionRigOf, isHumanSeatSessionRef, parseSessionName } from "../session-name.js";
|
||||
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { enumArg, positiveIntArg } from "../cli-error.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -544,11 +544,7 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
|
||||
/** Both reconciliation and --verify read the daemon that holds the row, with its own bearer. */
|
||||
async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: string | undefined) {
|
||||
let remote = remoteHostOf(hostId);
|
||||
const requestedRemote = remote;
|
||||
const selfHostId = requestedRemote ? await daemonSelfHostId(client) : undefined;
|
||||
if (remote && selfHostId === remote) remote = undefined;
|
||||
const remoteConfirmed = requestedRemote !== undefined && typeof selfHostId === "string"
|
||||
&& selfHostId.trim().length > 0 && selfHostId !== requestedRemote;
|
||||
if (remote && await daemonSelfHostId(client) === remote) remote = undefined;
|
||||
let reader = client;
|
||||
let options: { timeoutMs: number; headers?: Record<string, string> } = { timeoutMs: CREATE_READ_BACK_TIMEOUT_MS };
|
||||
if (remote) {
|
||||
@@ -563,7 +559,7 @@ async function queueReadTarget(deps: QueueDeps, client: DaemonClient, hostId: st
|
||||
reader = deps.clientFactory(match.host.url);
|
||||
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
|
||||
}
|
||||
return { reader, options, remote, remoteConfirmed };
|
||||
return { reader, options };
|
||||
}
|
||||
|
||||
/** Where a verb that names an existing row (show, transitions, list, claim, update, block) sends its request with
|
||||
@@ -627,7 +623,7 @@ function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: stri
|
||||
|
||||
/** A create whose answer didn't arrive, or arrived unreadable, may still have committed. Read the row back once by the ID it
|
||||
* was sent with, from the daemon that holds it. Only this create's row counts; it never sends the create again. */
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string; remoteConfirmed?: boolean }> {
|
||||
async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Promise<{ row: Record<string, unknown> | null; note: string }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
const target = await queueReadTarget(deps, client, hostId);
|
||||
@@ -642,8 +638,7 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
|
||||
if (differs) {
|
||||
return { row: null, note: `Row ${qitemId}${where} exists but isn't this create (its ${differs} differs), so the outcome is still unknown. Don't reuse this ID for other work.` };
|
||||
}
|
||||
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).`,
|
||||
remoteConfirmed: target.remoteConfirmed };
|
||||
return { row: res.data, note: `The create committed after all: row ${qitemId}${where} (read back by its ID).` };
|
||||
}
|
||||
if (res.status === 404) return { row: null, note: `No row ${qitemId}${where} yet (read back once by its ID); it may still commit.` };
|
||||
return { row: null, note: `Reading row ${qitemId}${where} back answered ${res.status}, so the outcome is still unknown.` };
|
||||
@@ -895,54 +890,6 @@ function requireSeatSession(ignoredFlag: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The flags `rig queue update` takes that write more than a note. `--source` records a note only (#2866). */
|
||||
const UPDATE_STATE_WRITE_FLAGS = [
|
||||
["state", "--state"], ["reopen", "--reopen"], ["closureReason", "--closure-reason"], ["closureTarget", "--closure-target"],
|
||||
["closureRequiredAt", "--closure-required-at"], ["resolution", "--resolution"], ["blockedOn", "--blocked-on"],
|
||||
["wakeWatchdog", "--wake-watchdog"], ["wakeAfter", "--wake-after"], ["summary", "--summary"], ["evidenceRef", "--evidence-ref"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* #2866: who appends a `rig queue update` note. Inside a managed seat it is the seat (the X-OpenRig-Session header the
|
||||
* DaemonClient stamps), whatever --source says. Outside one, a routine can name itself with --source <name>@external:
|
||||
* it rides the body as the declared actor, which the daemon records as claimed:v1 because no header names a seat, so
|
||||
* the note is attributed to an external actor and never to a seat, and OPENRIG_SESSION_NAME is never forged. The
|
||||
* external path records a note only, on the local daemon (the caller refuses a --host that resolves to another host).
|
||||
* Returns the body actor to send (undefined = the header decides), or false after printing why nothing was sent.
|
||||
*/
|
||||
function resolveUpdateActor(source: string | undefined, opts: Record<string, unknown>): { actorSession?: string } | false {
|
||||
const refuse = (message: string): false => { console.error(message); process.exitCode = 1; return false; };
|
||||
// Trimmed like the DaemonClient's header (senderIdentityHeaders): a blank env is no seat, so the two never disagree.
|
||||
const seat = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")?.trim() || undefined;
|
||||
if (source !== undefined) {
|
||||
const trimmed = source.trim();
|
||||
if (parseSessionName(trimmed).kind !== "external") {
|
||||
return refuse(`--source must be <name>@external (got '${source}'): it names a routine outside any seat, never a seat. Nothing was sent.`);
|
||||
}
|
||||
const writes = UPDATE_STATE_WRITE_FLAGS.filter(([key]) => opts[key] !== undefined).map(([, flag]) => flag);
|
||||
if (opts.note === undefined || writes.length > 0) {
|
||||
return refuse(`--source records a note only: pass --note and nothing else that writes${writes.length > 0 ? ` (got ${writes.join(", ")})` : ""}. ` +
|
||||
"Changing a row needs a seat. Nothing was sent.");
|
||||
}
|
||||
if (seat) {
|
||||
process.stderr.write(`warning: --source ${trimmed} is ignored: this runs in the seat ${seat}, which is the note's actor.\n`);
|
||||
return {};
|
||||
}
|
||||
return { actorSession: trimmed };
|
||||
}
|
||||
if (seat) return {};
|
||||
const noteOnly = opts.note !== undefined && UPDATE_STATE_WRITE_FLAGS.every(([key]) => opts[key] === undefined);
|
||||
return refuse(noteOnly
|
||||
? "notes need a seat; an external routine can pass --source <name>@external. OPENRIG_SESSION_NAME is not set, so " +
|
||||
"there's no seat to act as: run this from a managed seat, or add --source <name>@external to record the note as " +
|
||||
"that external routine (claimed, never a seat). --actor is ignored: the actor is the seat's own identity."
|
||||
: "OPENRIG_SESSION_NAME is not set, so there's no seat to act as. Run this from a managed seat, or set " +
|
||||
"OPENRIG_SESSION_NAME to the seat's session name (for example dev-impl@my-rig). --actor is ignored: the actor is " +
|
||||
"the seat's own identity. Changing a row needs a seat, and notes need a seat; an external routine can pass " +
|
||||
"--source <name>@external for a note only. A person answering an ask addressed to them (a host pairing approval, " +
|
||||
"say) does it in the TUI (rig tui, then attention) or in Steering, not with this command.");
|
||||
}
|
||||
|
||||
function extractRigName(sessionName: string): string | undefined {
|
||||
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
|
||||
return sessionRigOf(sessionName);
|
||||
@@ -1235,51 +1182,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
}, crossHostWriteOptions(hostResolved.hostId)).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
const sent: SentCreate = { destinationSession: hostResolved.destination, claimedSource: managedSource === undefined ? source : undefined, body: resolvedBody,
|
||||
remoteHost: remoteHostOf(hostResolved.hostId) };
|
||||
const emitScopeAdvisories = (created: Record<string, unknown>) => {
|
||||
const advisories = Array.isArray(created?.advisories) ? created.advisories : [];
|
||||
for (const value of advisories) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
||||
const advisory = value as { code?: unknown; message?: unknown };
|
||||
if (typeof advisory.message !== "string") continue;
|
||||
switch (advisory.code) {
|
||||
case "scope_check_skipped":
|
||||
console.error(`Note: ${advisory.message}`);
|
||||
break;
|
||||
case "unresolved_scope":
|
||||
case "ambiguous_scope":
|
||||
case "empty_scope":
|
||||
case "conflicting_scope":
|
||||
case "scope_check_unavailable":
|
||||
console.error(`Warning: ${advisory.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const emitRecoveredScopeAdvisory = (recoveredRemote: boolean) => {
|
||||
const recoveredTags: string[] = [];
|
||||
for (const raw of tags ?? []) {
|
||||
if (recoveredTags.length >= 8) break;
|
||||
if (!raw.startsWith("mission:") && !raw.startsWith("slice:")) continue;
|
||||
const prefix = raw.startsWith("mission:") ? "mission:" : "slice:";
|
||||
const body = raw.slice(prefix.length).replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 120);
|
||||
const sanitized = body ? `${prefix}${body}` : prefix;
|
||||
if (!recoveredTags.includes(sanitized)) recoveredTags.push(sanitized);
|
||||
}
|
||||
if (recoveredTags.length === 0) return;
|
||||
// Only a confirmed different host ID justifies "forwarded";
|
||||
// all other recovered outcomes remain neutral.
|
||||
if (recoveredRemote) {
|
||||
console.error(`Note: queue create scope check skipped for cross-host write (${recoveredTags.join(", ")}); row was forwarded.`);
|
||||
} else {
|
||||
console.error(`Warning: queue create scope check outcome unavailable after response recovery (${recoveredTags.join(", ")}): original scope check result was lost with the create response; row was created.`);
|
||||
}
|
||||
};
|
||||
// Every create that's answered with a row ends here: its warning, --verify's delivery, and the output.
|
||||
const finish = async (created: Record<string, unknown>, status: number, recovered?: "neutral" | "remote") => {
|
||||
if (status < 400) {
|
||||
if (recovered) emitRecoveredScopeAdvisory(recovered === "remote");
|
||||
else emitScopeAdvisories(created);
|
||||
}
|
||||
const finish = async (created: Record<string, unknown>, status: number) => {
|
||||
const createWarning = created?.createWarning as { code?: unknown; message?: unknown } | undefined;
|
||||
if (status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
|
||||
console.error(`Warning: ${createWarning.message}`);
|
||||
@@ -1309,7 +1213,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
|
||||
console.error(late.note);
|
||||
if (late.row) {
|
||||
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
|
||||
await finish(late.row, 200);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1326,7 +1230,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const late = await readBackCreate(deps, client, hostResolved.hostId, qitemId, sent);
|
||||
console.error(late.note);
|
||||
if (late.row) {
|
||||
await finish(late.row, 200, late.remoteConfirmed ? "remote" : "neutral");
|
||||
await finish(late.row, 200);
|
||||
return;
|
||||
}
|
||||
// Preserve that failure and expose the same recovery ID.
|
||||
@@ -1395,20 +1299,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
|
||||
The note can accompany the closing update or be appended later; a separate note is not required.
|
||||
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
|
||||
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
|
||||
`)
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
actor?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
reopen?: boolean;
|
||||
closureReason?: string;
|
||||
@@ -1443,21 +1338,11 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
// P21 I3 reconcile: the actor is DERIVED from the seat env (X-OpenRig-Session, stamped by
|
||||
// DaemonClient) — --actor is deprecated + ignored, no body actorSession. Verify the env (the
|
||||
// header source) or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal). Matches `resolve`.
|
||||
// #2866: outside a seat, --source <name>@external names the routine appending a note (the declared body actor).
|
||||
const actor = resolveUpdateActor(opts.source, opts);
|
||||
if (!actor) return;
|
||||
if (!requireSeatSession("actor")) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (local) => {
|
||||
const target = await queueHostTarget(deps, local, opts.host, true);
|
||||
if ("error" in target) return emitHostResolutionError(target, opts.json ?? false);
|
||||
if (actor.actorSession !== undefined && target.remote) {
|
||||
// #2866: the other host would record the routine bare, indistinguishable from a routine of its own (a
|
||||
// forwarded create stamps the origin; this path doesn't). A --host that resolves locally is fine.
|
||||
console.error(`--source with --host ${target.remote} is not supported: that host would record ${actor.actorSession} ` +
|
||||
"without the host it ran on. Run the routine on the host that holds the row. Nothing was sent.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const res = await target.client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
state: opts.state,
|
||||
reopen: opts.reopen,
|
||||
@@ -1471,7 +1356,6 @@ For a new cross-host transfer, prefer rig queue handoff --host <registered-host>
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
...actor,
|
||||
}, target.options);
|
||||
await printQueueItemResult(target.client, qitemId, opts.json ?? false, res.data, res.status, writeReceipt(opts.full, { verb: "update" }), target.remote);
|
||||
}, remoteHostOf(opts.host) !== undefined, remoteHostOf(opts.host));
|
||||
@@ -2115,100 +1999,6 @@ Examples:
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("erase")
|
||||
.description("Erase queue rows in one exact scope: an account's rows, a list of ids, or the closed-work retention windows; a dry run unless --apply")
|
||||
.option("--members <file>", "Account deletion: the account's recorded session names (exact, departed seats included), one per line or a JSON array")
|
||||
.option("--rig <id>", "With --members: the account rig's id. Also erases the rig's own records that outlive it (events, snapshots, node and seat records); refused unless every member is a session of it")
|
||||
.option("--ids <file>", "These qitem ids, one per line or a JSON array; a report's qitems entries, or an erasure ledger's JSON lines, keep each one's action")
|
||||
.option("--action <action>", "With --ids: delete (default) removes each row and keeps only its id; scrub removes its content and keeps the row", enumArg(["delete", "scrub"]))
|
||||
.option("--routine", "Run this daemon's closed-work windows now (retention.closed_content_days, retention.closed_metadata_days)")
|
||||
.option("--apply", "Erase. Without it the command reports what would be erased and changes nothing")
|
||||
.option("--actor <session>", "Who is erasing, when OPENRIG_SESSION_NAME isn't set (recorded as a claimed identity)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", [
|
||||
"",
|
||||
"Name exactly one scope. What each one erases:",
|
||||
" --members every row whose source and destination are both members, in any state, deleted with its transitions,",
|
||||
" outbox and inbox copies, stream actions, events and audit rows; then member-to-member outbox and inbox",
|
||||
" entries no row owns, and the members' stream items. A row with one member endpoint is reported under",
|
||||
" mixed and left untouched. With --rig, also the rig's own events, snapshots and node and seat records.",
|
||||
" --ids the listed rows, deleted or scrubbed (a report's qitems entries keep their action); a deleted id",
|
||||
" that's already gone keeps (or regains) its id marker.",
|
||||
" --routine closed rows (done, canceled, denied, handed-off) past the windows, aged from when they closed.",
|
||||
"A deleted row keeps only its id, and a create that reuses that id is refused (qitem_erased). A row a workflow still",
|
||||
"references is reported under gaps and left. Output is JSON: clean (true only when nothing was left), complete,",
|
||||
"qitems, mixed, gaps, alreadyAbsent, linked, unlinked and rig. Refusals: erase_scope_required, erase_scope_conflict,",
|
||||
"erase_members_invalid, erase_rig_invalid, erase_rig_mismatch, erase_ids_invalid, erase_action_invalid,",
|
||||
"erase_routine_off. A run that stops on an error returns what it committed, with error.stage, and exits 2.",
|
||||
"Reference: docs/reference/queue-erasure.md",
|
||||
].join("\n"))
|
||||
.action(async (opts: { members?: string; rig?: string; ids?: string; action?: string; routine?: boolean; apply?: boolean; actor?: string; json?: boolean }) => {
|
||||
const list = (flag: string, path: string): unknown[] | undefined => {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(path, "utf8");
|
||||
} catch (err) {
|
||||
console.error(`--${flag} ${path}: can't be read (${err instanceof Error ? err.message : String(err)}). Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
if (text.trimStart().startsWith("[")) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch { /* reported below */ }
|
||||
console.error(`--${flag} ${path}: not a JSON array. Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
// One entry per line: a plain id or session, or (an erasure ledger's line) a JSON object.
|
||||
const entries: unknown[] = [];
|
||||
for (const line of text.split(/\r?\n/).map((entry) => entry.trim()).filter((entry) => entry.length > 0 && !entry.startsWith("#"))) {
|
||||
if (!line.startsWith("{")) {
|
||||
entries.push(line);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
entries.push(JSON.parse(line));
|
||||
} catch {
|
||||
console.error(`--${flag} ${path}: a line isn't valid JSON (${line.slice(0, 60)}). Nothing was erased.`);
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
const body: Record<string, unknown> = { apply: opts.apply === true };
|
||||
if (opts.members !== undefined) {
|
||||
const members = list("members", opts.members);
|
||||
if (!members) return;
|
||||
body.members = members;
|
||||
}
|
||||
// Sent as given: the daemon refuses a rig without members (erase_rig_invalid), never drops it.
|
||||
if (opts.rig !== undefined) body.rig = opts.rig;
|
||||
if (opts.ids !== undefined) {
|
||||
const ids = list("ids", opts.ids);
|
||||
if (!ids) return;
|
||||
body.ids = ids;
|
||||
if (opts.action) body.action = opts.action;
|
||||
}
|
||||
if (opts.routine) body.routine = true;
|
||||
if (!readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME")) {
|
||||
if (!opts.actor) {
|
||||
console.error("OPENRIG_SESSION_NAME is not set and --actor wasn't given, so there's no one to record as erasing. Nothing was erased.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
body.actorSession = opts.actor;
|
||||
}
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/erase", body);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Inbox subcommands ----
|
||||
|
||||
cmd
|
||||
|
||||
@@ -5,10 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
|
||||
import { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
|
||||
|
||||
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
|
||||
@@ -21,23 +17,6 @@ export interface RosterInventory {
|
||||
sources: Row[];
|
||||
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
|
||||
localHostId?: string | null;
|
||||
/** The local daemon's hosted members with their check-in status, and that daemon's own host id (the host whose
|
||||
* check-ins these are); null when it couldn't be read. */
|
||||
checkIns?: { hostId: string | null; members: Row[] } | null;
|
||||
}
|
||||
|
||||
/** Each hosted member's check-in status, from the local daemon. Null when the daemon isn't running or doesn't answer
|
||||
* (an older daemon has no such read): every hosted member's status then reads unknown. */
|
||||
export async function readHostedCheckIns(): Promise<RosterInventory["checkIns"]> {
|
||||
try {
|
||||
const status = await getDaemonStatus(realDeps());
|
||||
if (status.state !== "running") return null;
|
||||
const res = await new DaemonClient(getDaemonUrl(status)).get<unknown>("/api/queue/hosted-members");
|
||||
return res.status < 400 && object(res.data) && Array.isArray(res.data.members)
|
||||
? { hostId: text(res.data.hostId), members: res.data.members.filter(object) } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
|
||||
@@ -70,30 +49,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
|
||||
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
|
||||
}
|
||||
}
|
||||
return { readAt: new Date().toISOString(), nodes, sources, localHostId, checkIns: await readHostedCheckIns() };
|
||||
}
|
||||
|
||||
function hostedCheckIn(member: RosterMember, inventory: RosterInventory): Row {
|
||||
const unknown = (reason: string) => ({ status: "unknown", lastCheckInAt: null, expectedMinutes: member.check_in_minutes ?? null,
|
||||
onRequest: member.check_in_on_request === true, reason });
|
||||
// The daemon names its own host with its check-ins, so a team with no terminal seat still matches its members.
|
||||
if (!inventory.checkIns) return unknown("this host's daemon gave no check-in status");
|
||||
if (!inventory.checkIns.hostId || member.host !== inventory.checkIns.hostId) return unknown("it's listed on another host, whose check-ins this host doesn't hold");
|
||||
const row = inventory.checkIns.members.find((m) => m.seat === member.seat);
|
||||
return row && typeof row.status === "string" && row.status in CHECK_IN_DISPLAY ? row
|
||||
: unknown("this host's daemon gave no status for it");
|
||||
}
|
||||
|
||||
/** A connected assistant's check-in in one human line with its evidence, in the daemon's own words
|
||||
* (`describeCheckIn`): "overdue: last check-in 13:05, expected every 60 min; its schedule may be off or waiting for an
|
||||
* approval". A status this CLI doesn't know reads unknown. */
|
||||
export function formatCheckIn(checkIn: Row): string {
|
||||
const known = typeof checkIn.status === "string" && checkIn.status in CHECK_IN_DISPLAY;
|
||||
return describeCheckIn({ status: known ? checkIn.status as CheckInStatus : "unknown",
|
||||
lastCheckInAt: typeof checkIn.lastCheckInAt === "string" ? checkIn.lastCheckInAt : null,
|
||||
expectedMinutes: typeof checkIn.expectedMinutes === "number" ? checkIn.expectedMinutes : null,
|
||||
onRequest: checkIn.onRequest === true,
|
||||
...(known && typeof checkIn.reason === "string" ? { reason: checkIn.reason } : {}) });
|
||||
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
|
||||
}
|
||||
|
||||
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
|
||||
@@ -137,9 +93,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
// is in rig_team.
|
||||
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
|
||||
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
|
||||
// Whether it's checking its queue, for a member on this host; another host's member, or a status this daemon
|
||||
// couldn't give, reads unknown.
|
||||
...(member.hosted ? { checkIn: hostedCheckIn(member, inventory) } : {}),
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -181,7 +134,7 @@ export function rosterCommand(deps: Deps = {}): Command {
|
||||
else {
|
||||
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
|
||||
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}\n Check-in: ${formatCheckIn(m.live.checkIn ?? {})}` : ""}\n ${m.live.reason}`);
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
|
||||
if (!members.length) console.log("No matching members.");
|
||||
const obs = result.observations as { sources: Row[] };
|
||||
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
|
||||
|
||||
@@ -2186,17 +2186,6 @@ function sharedSliceNameFinding(missionsRoot: string, missionName: string, slice
|
||||
};
|
||||
}
|
||||
|
||||
/** Show the inherited workspace route because Commander lists a parent option only in the parent's help. */
|
||||
export function scopeWorkspaceHelp(tier: "slice" | "mission", verb?: string): string {
|
||||
return `
|
||||
Workspace: the parent option --workspace selects the missions root this command reads.
|
||||
rig scope --workspace <project root> ${tier} ${verb ?? "<verb>"} ...
|
||||
A project root uses its project.yaml missions.root, else its missions/ folder; a
|
||||
folder named missions is used as is. Use it for a catalog project, for example
|
||||
$(rig config get workspace.projects_root)/<project>. Without --workspace,
|
||||
OPENRIG_WORK_ROOT applies, then the configured workspace.slices_root.`;
|
||||
}
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
@@ -2227,10 +2216,6 @@ export function scopeCommand(): Command {
|
||||
mission.addCommand(buildMissionVerifiedCommand());
|
||||
mission.addCommand(buildApproveCommand("mission"));
|
||||
cmd.addCommand(mission);
|
||||
for (const [tier, group] of [["slice", slice], ["mission", mission]] as const) {
|
||||
group.addHelpText("after", scopeWorkspaceHelp(tier));
|
||||
for (const verb of group.commands) verb.addHelpText("after", scopeWorkspaceHelp(tier, verb.name()));
|
||||
}
|
||||
cmd.addCommand(buildResolveNotesCommand());
|
||||
cmd.addCommand(buildAuditCommand());
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ async function defaultReadStdin(): Promise<string> {
|
||||
}
|
||||
|
||||
interface SeatStatusResponse {
|
||||
/** What OpenRig's own refocus hook has recorded for this seat (Claude and Codex seats). */
|
||||
refocus?: { lastRecordedAt: string | null; currentConversation: boolean | "unknown"; noIdentity?: true; detail: string };
|
||||
permission_stop?: { state: "unavailable"; command: "unavailable"; origin: "unknown" }
|
||||
| { state: "request_observed" | "completed"; command: string; origin: "harness";
|
||||
source: "PermissionRequest"; observed_at: string; actor: "main" | "subagent"; pending: "unknown" | false;
|
||||
@@ -78,9 +76,6 @@ interface SeatStatusResponse {
|
||||
ignored?: { source: string; value: string };
|
||||
};
|
||||
claude_token?: { kind?: "api-key"; label: string; token_file: string; file: "readable" | "unusable"; problem?: string; notice?: string };
|
||||
/** A running Claude or Codex seat: when OpenRig wrote each block in its guidance file, before or after a recorded session
|
||||
* moment (its startup finish, or when a claimed session was recorded). Chronology only, never what the agent read. */
|
||||
guidance?: { file: string; detail: string; blocks: Array<{ id: string; writtenAt: string | null; written: "before" | "after" | "unknown"; reason?: string }> };
|
||||
session_reason?: string | null;
|
||||
startup_status: string | null;
|
||||
occupant_lifecycle: string;
|
||||
@@ -145,7 +140,6 @@ interface SeatHandoverMutationResult {
|
||||
ok: true;
|
||||
dryRun: false;
|
||||
mutated: true;
|
||||
/** Legacy compatibility field, always false. Read currentStatus.continuityOutcome and sourceOutcome instead. */
|
||||
continuityTransferred: false;
|
||||
seat: SeatHandoverPlan["seat"];
|
||||
source: {
|
||||
@@ -174,8 +168,7 @@ interface SeatHandoverMutationResult {
|
||||
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
|
||||
sourceOutcome?:
|
||||
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string };
|
||||
model?: { requested: string | null; predecessorCompared: false } };
|
||||
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
|
||||
/** The restore or rebuild priming packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
|
||||
restorePacket?: { status: "unconfirmed"; reason: string };
|
||||
/** #2456: the required startup files the successor started without, where a start would have failed. */
|
||||
@@ -250,7 +243,6 @@ function printHuman(status: SeatStatusResponse): void {
|
||||
console.log(`Claude ${t.kind === "api-key" ? "API key" : "subscription token"}: ${t.label}, read from ${t.token_file} at each launch (${t.file === "readable" ? "readable now" : "won't apply at the next launch"})`);
|
||||
if (t.notice) console.log(` ${t.notice}`);
|
||||
}
|
||||
if (status.guidance) console.log(`Guidance: ${status.guidance.detail}`);
|
||||
if (status.typingGuard) {
|
||||
const g = status.typingGuard;
|
||||
const mode = g.effectiveLevel ?? g.effectiveMode ?? (g.effective ? "on" : "off");
|
||||
@@ -281,7 +273,6 @@ function printHuman(status: SeatStatusResponse): void {
|
||||
if (rules?.size_cut) console.log(" Suggested-rule evidence is incomplete because of size bounds.");
|
||||
}
|
||||
if (status.compaction) console.log(`Managed compaction (${status.compaction.stage}): ${status.compaction.detail}`);
|
||||
if (status.refocus) console.log(`Refocus: ${status.refocus.detail}`);
|
||||
const reason = sessionReason(status.session_reason);
|
||||
if (reason) console.log(`Reason: ${reason}`);
|
||||
if (status.permissions?.account) {
|
||||
@@ -393,11 +384,7 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
if (result.sourceOutcome.forkId === "pending") console.log(`Fork id: PENDING. ${result.sourceOutcome.limit ?? ""}`.trim());
|
||||
} else if (result.sourceOutcome?.mode === "rebuild") {
|
||||
const outcome = result.sourceOutcome;
|
||||
console.log("Rebuild starts a new conversation; durable artifacts carry its context, not a native resume.");
|
||||
if (outcome.permissions) console.log(`Codex rebuild permissions: ${outcome.permissions.state}. ${outcome.permissions.detail}`);
|
||||
if (outcome.model) {
|
||||
console.log(`Codex rebuild model: ${outcome.model.requested ? `requested ${outcome.model.requested}` : "runtime configuration/default"}. The predecessor's in-pane model was not compared or carried.`);
|
||||
}
|
||||
if (outcome.primedArtifacts.length > 0) {
|
||||
const heading = result.restorePacket?.status === "unconfirmed" ? "Rebuild packet includes" : "Rebuild primed from";
|
||||
console.log(`${heading} ${outcome.primedArtifacts.length} durable artifact${outcome.primedArtifacts.length === 1 ? "" : "s"}:`);
|
||||
@@ -986,47 +973,6 @@ Examples:
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("decide-permission")
|
||||
.argument("<request>", "The permission request's id (qitem-runner-permission-…), as the row handed to you names it")
|
||||
.requiredOption("--option <id>", "One of the options the agent offered, by its id")
|
||||
.option("--reason <text>", "Why, recorded with the decision (say what the person meant)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Decide a runner seat's permission request that its person answered: one of the agent's offered options")
|
||||
.addHelpText("after", `
|
||||
When a person answers a runner seat's permission request, with a click or in their own
|
||||
words, the request goes to this host's receiving agent to decide while the runner waits.
|
||||
This records that agent's decision: one of the options the agent offered, which the runner
|
||||
then gives its agent. It's recorded as an agent's decision, not the person's, with the
|
||||
deciding seat and the reason. A request that's already decided, withdrawn or stale is
|
||||
reported as it is, and nothing is written. See docs/reference/harness-runner-contract.md,
|
||||
section 7.
|
||||
Example:
|
||||
rig seat decide-permission qitem-runner-permission-0123abcd --option allow --reason "they said go ahead"`)
|
||||
.action(async (request: string, opts: { option: string; reason?: string; json?: boolean }) => {
|
||||
type Decided = { ok?: boolean; state?: string; optionId?: string; code?: string; error?: string };
|
||||
let res: { status: number; data: Decided };
|
||||
try {
|
||||
res = await new DaemonClient().post<Decided>(`/api/activity/permission-requests/${encodeURIComponent(request.trim())}/decide`,
|
||||
{ optionId: opts.option.trim(), reason: opts.reason ?? "" });
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
if (res.status !== 200 || !res.data?.ok) {
|
||||
if (!opts.json) console.error(res.data?.error ?? `the decision failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) return;
|
||||
const state = res.data.state;
|
||||
console.log(state === "answered" ? `Decided: ${res.data.optionId}. The runner gives it to its agent at its next read.`
|
||||
: state === "refused" ? "Decided: refused. The runner gives its agent the refusal at its next read."
|
||||
: `Nothing was decided: the request is ${state}.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("effort")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
@@ -1089,13 +1035,13 @@ resume/successor launch reads it at call time. Examples:
|
||||
cmd
|
||||
.command("set-runtime")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--runtime <runtime>", "codex, claude-code or pi")
|
||||
.option("--runtime <runtime>", "codex or claude-code")
|
||||
.option("--reason <text>", "Why the seat moves; recorded with the switch")
|
||||
.option("--model <id>", "Target model; otherwise the seat's agent spec for the target, else the target's default")
|
||||
.option("--effort <level>", "Target effort, the target runtime's own level (not translated)")
|
||||
.option("--codex-config-profile <name>", "Codex configuration profile for a Codex successor")
|
||||
.option("--clear-account-home", "Return to member/rig/daemon/native login selection through a fresh successor")
|
||||
.option("--account-home <path>", "Target Codex/Claude home or Pi account directory containing auth.json; also permits a same-runtime successor")
|
||||
.option("--account-home <path>", "Isolated target CODEX_HOME or CLAUDE_CONFIG_DIR; also permits a fresh successor on the same runtime")
|
||||
.option("--token-file <path>", "With a Claude account home, its required subscription token reference")
|
||||
.option("--account-label <label>", "With --token-file, the selected account's label")
|
||||
.option("--permission-mode <mode>", "Target native permission choice; default: inherit the seat's member and rig policy")
|
||||
@@ -1105,7 +1051,7 @@ resume/successor launch reads it at call time. Examples:
|
||||
.option("--note <text>", "With --confirm: what you'll do first")
|
||||
.option("--recover", "Finish an interrupted switch from its record")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Move a live Codex, Claude Code or Pi seat to another runtime, successor first")
|
||||
.description("Move a live Codex or Claude Code seat to the other runtime, successor first")
|
||||
.addHelpText("after", `
|
||||
The seat keeps its address, connections, queue rows and claims. Its successor
|
||||
starts on the target runtime beside the current occupant, reads a handover, and
|
||||
@@ -1119,7 +1065,7 @@ later converge reports a deferred change_runtime); the source runtime's model,
|
||||
effort, Codex profile and native permission choice are cleared, not mapped; two
|
||||
processes run until cutover; messages sent to the seat during the switch are
|
||||
held, not typed, and need sending again afterwards.
|
||||
Codex, Claude Code and Pi, single-pane seats only. --dry-run lists the details.
|
||||
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
|
||||
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
|
||||
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
|
||||
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
|
||||
@@ -1491,17 +1437,12 @@ Examples:
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Stop exactly one LIVE managed seat (kills only that seat's tmux session; audited)")
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
check it first (rig capture <seat>) and stop only a seat you own or were asked to
|
||||
stop. rig seat launch <seat> --fresh --reason <text> starts
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean); an adopted
|
||||
session is refused (use rig unclaim). A seat handed over (fresh, fork or rebuild)
|
||||
before OpenRig 0.7.9 still reads adopted and is refused too, until its next launched
|
||||
handover or managed launch; the refusal says so. Stopping ends that agent's session and any
|
||||
work in progress: check it first (rig capture <seat>) and stop only a seat you
|
||||
own or were asked to stop. rig seat launch <seat> --fresh --reason <text> starts
|
||||
a fresh occupant there; it does not resume the stopped conversation.
|
||||
A community harness stop closes its managed pane; its native processes or compute
|
||||
may outlive it. Inspect their own lifecycle receipts.
|
||||
|
||||
@@ -61,8 +61,6 @@ export interface EnvelopeMeta {
|
||||
/** Twin of pane-envelope's field: the row a person's (@external) message delivers, named in the
|
||||
* reply hint with --reply-to so the answer joins its Slack thread. ABSENT ⇒ the plain hint. */
|
||||
replyToQitem?: string;
|
||||
/** Twin of pane-envelope's field (#2895 (a)): false ⇒ the reply line names no `rig send`. ABSENT or true ⇒ today's hint. */
|
||||
senderReachable?: boolean;
|
||||
}
|
||||
|
||||
/** The To-line projection + anti-storm scale (header-alone distinguishability). */
|
||||
@@ -110,9 +108,7 @@ export function wrapSendBody(
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
|
||||
: isHumanSeatSessionRef(personAddress)
|
||||
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
|
||||
: meta?.senderReachable === false
|
||||
? `↩ No reply by rig send: ${senderLabel} has no session.`
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
: `↩ Reply: rig send ${senderLabel} "..."`;
|
||||
return [...header, "---", body, "---", reply].join("\n");
|
||||
}
|
||||
|
||||
@@ -250,7 +246,7 @@ export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
|
||||
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending. If it's still busy, or its activity is unknown, when the wait ends, nothing is sent (exit 1) and nothing is kept to send later; put separate work in a queue row (rig queue create)")
|
||||
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
|
||||
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added for the complete visibly staged answer, or away from a recognized prompt when the input box can't be found or read.")
|
||||
.option("--dangerously-interact", "DANGEROUS: type an answer as keystrokes into an interactive prompt (implies --raw; requires --reason). Letters can select menu shortcuts; CR/ESC act as keys. Enter is added only for the complete visibly staged answer.")
|
||||
.option("--type-without-checking", "With --dangerously-interact: type without reading or clearing existing input; may join or submit that text")
|
||||
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
|
||||
.option("--urgent", "Mark this message urgent: it types at seats whose interruption level holds other messages. Anyone may mark one; the recipient sees who did")
|
||||
@@ -326,10 +322,8 @@ would run the envelope lines; the output says so. Use
|
||||
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
|
||||
option or approve a permission) — the only override of the prompt guard; it
|
||||
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
|
||||
be menu shortcuts, and CR/ESC act as keys. An Enter is added when the complete
|
||||
answer is visibly staged in a recognized text input, and, away from a recognized
|
||||
prompt or menu, when the input box can't be found or read (the result says so).
|
||||
Combining --verify with
|
||||
be menu shortcuts, and CR/ESC act as keys. An Enter is added only when the complete
|
||||
answer is visibly staged in a recognized text input. Combining --verify with
|
||||
--dangerously-interact never adds an automatic submit retry, even when the daemon
|
||||
did not detect a prompt or did not report its disposition. Unknown consumption
|
||||
is not a send failure. On a managed seat with the delivery guard attached, a
|
||||
@@ -338,10 +332,7 @@ pasted text stops the send and is shown only to you, separately from recognized
|
||||
dim suggestions. Collapsed paste contents and unrecognized inputs are not known.
|
||||
Inspect the native terminal, deliberately clear its prompt with native/tmux
|
||||
controls, then retry. Manual clearing runs outside OpenRig's lease and observation
|
||||
checks and is not recorded. An input that can't be read doesn't stop the send:
|
||||
the answer is typed, nothing is cleared, the result says the input couldn't be
|
||||
read, and the answer may join or submit what is there.
|
||||
--type-without-checking explicitly skips this read;
|
||||
checks and is not recorded. --type-without-checking explicitly skips this read;
|
||||
it does not clear anything and may join or submit existing text. Human typing
|
||||
can still race a capture and paste; the check is not atomic.
|
||||
|
||||
@@ -1143,11 +1134,9 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown; warning?: string }>) ?? [];
|
||||
// Context beside a recipient's own result: its own advisory (such as unreadable input), and whether
|
||||
// it was in a managed compaction or restore when the send ran.
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
|
||||
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
|
||||
const printNotice = (r: (typeof results)[number]) => {
|
||||
if (r.warning) console.log(`${r.sessionName}: Advisory: ${r.warning}`);
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
};
|
||||
// Round-2 F1 (desk-binding): ONE verdict line per recipient. A
|
||||
|
||||
@@ -193,7 +193,7 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: "CLAUDE.md (or CLAUDE.local.md when the rig chooses it)",
|
||||
purpose: "OpenRig managed blocks at each start: the rig's culture, the OpenRig Start identity pointer, the context-entry pointer to skills, help and declared context, the onboarding pages at a fresh start (onboarding.default_pack.enabled), and guidance files a team merges. The router line is added only on a yes. rig down removes the blocks unless a seat of another active rig, or a still-running seat of this one, uses the file.",
|
||||
purpose: "OpenRig managed blocks at each start: the rig's culture, the OpenRig Start identity pointer, the onboarding pages at a fresh start (onboarding.default_pack.enabled), and guidance files a team merges. The router line is added only on a yes. rig down removes the blocks unless a seat of another active rig, or a still-running seat of this one, uses the file.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
|
||||
@@ -56,9 +56,6 @@ interface SeatInspection {
|
||||
bootstrapLine?: { state: "unasked" | "unknown" } | { state: "yes" | "no"; by: "saved" | "environment" } | null;
|
||||
/** OPR.0.7.0.9: what the seat's launch passes of its own additions; absent from an older daemon. */
|
||||
launchAdditions?: SeatLaunchAdditionsView | null;
|
||||
/** #2895 (b): set for a seat on a runtime without seat skills; absent from an older daemon. */
|
||||
unsupported?: true;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface SeatApplyResponse {
|
||||
@@ -188,10 +185,6 @@ export function skillCommand(depsOverride?: StatusDeps): Command {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.apply && data.unsupported) {
|
||||
if (!opts.json) console.log(data.note);
|
||||
return;
|
||||
}
|
||||
const inspection = opts.apply ? data.inspection : data;
|
||||
if (!opts.json) printSeat(inspection);
|
||||
if (!opts.apply) {
|
||||
|
||||
@@ -122,7 +122,7 @@ const CHANNEL_MAP_APPLY =
|
||||
const POST_OPEN_ASKS = "`rig slack post-open-asks`";
|
||||
|
||||
type OpenAskOutcome = "posted" | "already-posted" | "queued" | "held" | "below-floor" | "no-longer-active" | "superseded"
|
||||
| "recipient-changed" | "no-connector" | "state-unavailable" | "transport-failed" | "uncertain";
|
||||
| "recipient-changed" | "state-unavailable" | "transport-failed" | "uncertain";
|
||||
interface OpenAsk { qitemId: string; recipient: string; summary: string | null; outcome: OpenAskOutcome; detail?: string }
|
||||
interface PostOpenAsksResponse { ok: boolean; asks: OpenAsk[] }
|
||||
|
||||
@@ -135,7 +135,6 @@ const OUTCOME_LABEL: Record<OpenAskOutcome, string> = {
|
||||
"no-longer-active": "no longer active",
|
||||
superseded: "superseded",
|
||||
"recipient-changed": "recipient changed",
|
||||
"no-connector": "no connector (Steering and the Feed only)",
|
||||
"state-unavailable": "state unavailable",
|
||||
"transport-failed": "transport failed",
|
||||
uncertain: "uncertain",
|
||||
@@ -181,7 +180,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
.command("setup")
|
||||
.description("Configure the connector (first-class config; secrets stay in the env file, never here)")
|
||||
.option("--channel <id>", "Slack channel id the connector app must be a member of")
|
||||
.option("--inbound-destination <session>", "where a person's unrouted message lands: a seat's session, or default-receiver (the default) for the host's default receiving agent (agents.operator_session, else the kernel operator)")
|
||||
.option("--inbound-destination <session>", "where inbound human messages land (default operator-agent@kernel)")
|
||||
.option("--minimum-level-that-posts <level>", "minimum OWNER level posted to Slack: RECORD|NOTICE|ALERT")
|
||||
.option("--minimum-level-that-interrupts <level>", "minimum OWNER level that mentions/interrupts: RECORD|NOTICE|ALERT")
|
||||
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
|
||||
@@ -251,7 +250,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
for (const r of readiness) log(` ${r.ok ? "✓" : "✗"} ${r.label}: ${r.detail}`);
|
||||
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
|
||||
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
|
||||
inboundRouting?: { fallback: string; destination: string; destinationFrom?: string; destinationNote?: string };
|
||||
inboundRouting?: { fallback: string; destination: string };
|
||||
settlement?: { pending: number; lastFailure: string | null };
|
||||
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
|
||||
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
|
||||
@@ -259,7 +258,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
coverage?: { coverageStart: string; coveredThrough: string; pending?: { upper: string; nextLatest: string }; nextRetryAt?: number } | null;
|
||||
channels?: Array<{ channel?: string | null; state?: string; reason?: string; lastScanAt?: string;
|
||||
coverage?: { coverageStart: string; coveredThrough: string; nextRetryAt?: number } | null }>;
|
||||
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; settled?: number; settledRescanHours?: number; oldestPendingSeconds?: number | null;
|
||||
threads?: { state?: string; reason?: string; tracked?: number; scanned?: number; pending?: number; oldestPendingSeconds?: number | null;
|
||||
backoff?: Record<string, number>; incomplete?: Record<string, number> };
|
||||
limits?: string[] } } | undefined;
|
||||
if (connector) {
|
||||
@@ -268,7 +267,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
|
||||
}
|
||||
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
|
||||
if (connector.inboundRouting?.destinationNote) log(` ${connector.inboundRouting.destinationFrom === "receiving-agent" ? "Default receiving agent" : "No receiving agent"}: ${connector.inboundRouting.destinationNote}`);
|
||||
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
|
||||
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
|
||||
const recovery = connector.recovery;
|
||||
@@ -290,7 +288,6 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
const behind = threads.oldestPendingSeconds != null ? ` (oldest ${Math.round(threads.oldestPendingSeconds / 60)} min behind)` : "";
|
||||
log(` Thread recovery: ${threads.state ?? "unknown"}${threads.reason ? ` (${threads.reason})` : ""}; ${threads.tracked ?? 0} tracked (open, or closed within seven days); `
|
||||
+ `${threads.scanned ?? 0} scanned, ${threads.pending ?? 0} waiting their turn${behind}`
|
||||
+ `${threads.settled ? `, ${threads.settled} settled (nobody waiting; rescanned ${!threads.settledRescanHours || threads.settledRescanHours === 24 ? "daily" : `every ${threads.settledRescanHours} hours`})` : ""}`
|
||||
+ `${reasons(threads.backoff) ? `; backing off: ${reasons(threads.backoff)}` : ""}${reasons(threads.incomplete) ? `; incomplete: ${reasons(threads.incomplete)}` : ""}`);
|
||||
}
|
||||
log(` Recovery counts since connector start: accepted ${recovery?.acceptedThisProcess ?? "unknown"}; dead-lettered ${recovery?.deadLetteredThisProcess ?? "unknown"} (custody, not delivery)`);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { readLifecycleDescription } from "../daemon-lifecycle-status.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { attentionLines, checkKernelAttention } from "../seat-attention.js";
|
||||
|
||||
export interface StatusDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
@@ -137,12 +136,8 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
} catch {
|
||||
// config resolution unavailable - null rather than a guess.
|
||||
}
|
||||
// A kernel short of ready: its seats that need attention, and any it couldn't check (never read as none).
|
||||
const attention = kernelRes?.status === 200 && kernelRes.data?.kernel_state && kernelRes.data.kernel_state !== "ready"
|
||||
? await checkKernelAttention(client) : null;
|
||||
const kernel = kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state
|
||||
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null,
|
||||
...(attention && (attention.attention.length || attention.unknown.length) ? { attention } : {}) }
|
||||
? { state: kernelRes.data.kernel_state, lastBootFailure: kernelRes.data.last_boot_failure ?? null }
|
||||
: { state: kernelRes && kernelRes.status === 503 ? "not_tracked" : "unknown", lastBootFailure: null };
|
||||
const daemon = { state: "running", reachable: true, pid: status.pid, port: status.port, healthy: true };
|
||||
if (summaryRes.status !== 200) {
|
||||
@@ -168,10 +163,6 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
if (failure) {
|
||||
console.log(` Recovered from an earlier boot failure (${failure.state}${failure.at ? ` at ${failure.at}` : ""}): ${failure.detail ?? "no detail recorded"}`);
|
||||
}
|
||||
// A kernel seat held at a startup prompt or sign-in keeps the kernel short of ready: name it and why.
|
||||
if (kernelRes.data.kernel_state !== "ready") {
|
||||
for (const line of attentionLines(await checkKernelAttention(client), "pane")) console.log(` ${line}`);
|
||||
}
|
||||
} else if (kernelRes && kernelRes.status === 503) {
|
||||
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
|
||||
} else {
|
||||
|
||||
@@ -24,40 +24,10 @@ import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
|
||||
import { assertLocalTmux, attachTmux, isLocalTmuxHost, readTmuxServerArgs, type TmuxAttachment } from "../tmux-attachment.js";
|
||||
import { attentionLines, checkSeatAttention } from "../seat-attention.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/** herdr-app's home, resolved the way herdr-app resolves it (as the TUI does). */
|
||||
function herdrAppHome(env: NodeJS.ProcessEnv): string {
|
||||
return env["HERDR_APP_HOME"] || join(env["XDG_CONFIG_HOME"] || join(env["HOME"] || homedir(), ".config"), "herdr-app");
|
||||
}
|
||||
|
||||
export interface TerminalDeps extends StatusDeps {
|
||||
windowDeps?: WindowDeps;
|
||||
attachTmux?: (target: TmuxAttachment) => Promise<number>;
|
||||
/** This process's environment (herdr-app context); injectable for tests. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
socketExists?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The herdr-app client a command addresses, or null when it doesn't use herdr-app: `--provider herdr-app` opens the
|
||||
* view in the herdr-app window you run it in. Inside a herdr-app terminal, plain `rig terminal open <view>` does the
|
||||
* same when that window is answering (its socket file is a hint; the window is asked before it is chosen). `--window`
|
||||
* always asks for a new desktop window.
|
||||
*/
|
||||
export function herdrAppClient(opts: { provider?: string; window?: boolean; appHome?: string }, deps: TerminalDeps):
|
||||
{ appHome: string; ambient: boolean } | null {
|
||||
if (opts.window) return null;
|
||||
const env = deps.env ?? process.env;
|
||||
const named = opts.provider?.trim();
|
||||
const exists = deps.socketExists ?? existsSync;
|
||||
const ambient = !named
|
||||
&& !!env["HC_TERMINAL_ID"] && !!env["HC_TERMINAL_SOCKET"] && exists(join(herdrAppHome(env), "herdr-app.sock"));
|
||||
if (named !== "herdr-app" && !ambient) return null;
|
||||
return { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env), ambient };
|
||||
}
|
||||
export interface FollowControls {
|
||||
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
|
||||
@@ -74,8 +44,6 @@ export interface OpenViewResult {
|
||||
session?: string;
|
||||
ok: boolean;
|
||||
opened: string[];
|
||||
/** Opened seats on another host, with that host: complete when sent, so its absence (an older daemon) means unknown. */
|
||||
remote?: Array<{ seat: string; host: string }>;
|
||||
absent: { seat: string; host: string | null; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
|
||||
pages: number;
|
||||
@@ -138,23 +106,6 @@ function humanOpen(r: OpenViewResult): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Names the opened seats that need attention (a startup prompt, a sign-in, a failed start), so a held seat doesn't
|
||||
* read as an open that failed for no reason. Local seats are checked on this daemon; a seat on another host, or one
|
||||
* whose host the result doesn't say, is reported unchecked, never matched to a local seat of the same name. */
|
||||
async function withHeldSeats(client: DaemonClient, r: OpenViewResult): Promise<OpenViewResult> {
|
||||
const seats = [...new Set([...r.opened, ...(r.following?.tiles.map(t => t.seat) ?? [])])];
|
||||
if (!Array.isArray(r.remote)) {
|
||||
// An older daemon names opened seats without saying which are remote: none can be checked locally by name.
|
||||
const lines = seats.length ? attentionLines({ attention: [], unknown: seats.map(seat => ({ seat, why: "the daemon didn't say which host each tile is on" })) }, "tile") : [];
|
||||
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
|
||||
}
|
||||
const remote = new Map(r.remote.map(({ seat, host }) => [seat, host]));
|
||||
const check = await checkSeatAttention(client, seats.filter(seat => !remote.has(seat)));
|
||||
for (const [seat, host] of remote) check.unknown.push({ seat, why: `it's on host ${host}, which this daemon doesn't check` });
|
||||
const lines = attentionLines(check, "tile");
|
||||
return lines.length ? { ...r, notes: [...(r.notes ?? []), ...lines] } : r;
|
||||
}
|
||||
|
||||
/** A successful existing-workspace selection also needs no new panes. */
|
||||
function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
if (json) {
|
||||
@@ -170,7 +121,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
const cmd = new Command("terminal").description(
|
||||
"Open the OpenRig view and agent conversations in terminal tiles",
|
||||
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside a herdr-app terminal, omit --window to open in that window.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
|
||||
).addHelpText("after", "\nThe OpenRig TUI and operator (after install, and whenever the person wants their agents):\n rig terminal open saved:kernel --window\n Opens the window itself on the daemon's desktop; an agent can run it from its shell.\n Inside Rex it opens a Rex session and shows it in the Rex app; otherwise it uses herdr when installed, else the same layout in plain tmux.\n Herdr needs a terminal the person can see; TUI navigation does not open one.\n Over headless SSH, give the exact connection/attach command for a new terminal/tab.\n Only if the window cannot open: rig tui --shared is the dashboard-only fallback.\n On a desktop, the agent opens the view; it does not finish by printing a command to copy.\n");
|
||||
|
||||
const getDeps = (): TerminalDeps =>
|
||||
depsOverride ?? {
|
||||
@@ -182,10 +133,9 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, pod:<rig>/<pod>, seat:<rig>/<logical-id>, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.description("Open a desktop terminal showing the view's live agents as interactive tiles")
|
||||
.option("--provider <name>", "herdr, herdr-app, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one), and herdr-app opens a Rig view in the herdr-app window this runs in, which must be answering; tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.option("--provider <name>", "herdr, cmux or rex: open in the running provider without a desktop window; each open adds a new workspace (inside Herdr, --window reuses a matching one); tmux requires --window. Inside Rex, the default opens in Rex")
|
||||
.option("--session <name>", "Herdr session on the daemon's host; --session default selects Herdr's default session")
|
||||
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted outside herdr-app); inside a herdr-app terminal --window still asks for a desktop window, and plain open reuses herdr-app")
|
||||
.option("--app-home <dir>", "herdr-app: the client to open in (default: herdr-app's own home, from HERDR_APP_HOME or ~/.config/herdr-app)")
|
||||
.option("--window", "Open a new desktop terminal tab/window (the default when --provider is omitted)")
|
||||
.option("--expected-plan <id>", "Open only if the view still matches this preview")
|
||||
.option("--follow", "Keep one qitem:<id>, slice:<id-or-name> or mission:<id> workspace following current obligation owners (Linux/macOS Herdr protocol 22)")
|
||||
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
|
||||
@@ -198,7 +148,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
.option("--grace-ms <ms>", "Departure grace, default 4000, at most 60000", Number)
|
||||
.option("--focus-retention-ms <ms>", "Extra focused-reading retention, default 12000, at most 300000", Number)
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string; appHome?: string;
|
||||
.action(async (view: string, opts: { provider?: string; session?: string; json?: boolean; window?: boolean; expectedPlan?: string;
|
||||
follow?: boolean; maxTiles?: number; pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[];
|
||||
suppressionMs?: number; graceMs?: number; focusRetentionMs?: number }) => {
|
||||
const deps = getDeps();
|
||||
@@ -207,51 +157,12 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
const hasControls = [maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs].some(v => v !== undefined);
|
||||
const following: FollowControls | undefined = opts.follow !== undefined || hasControls
|
||||
? { enabled: opts.follow !== false, maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } : undefined;
|
||||
const herdrApp = herdrAppClient(opts, deps);
|
||||
await withClient(deps, async (client) => {
|
||||
// An ambient herdr-app terminal does not claim --follow; only an explicit --provider herdr-app errors with follow_unsupported.
|
||||
let selectedHerdrApp = herdrApp?.ambient && following ? null : herdrApp;
|
||||
let ambientFallbackNote: string | undefined;
|
||||
if (selectedHerdrApp?.ambient) {
|
||||
const probe = await client.get<{
|
||||
available?: boolean;
|
||||
launch?: { socketPath?: string };
|
||||
providers?: Array<{ status?: { available?: boolean; launch?: { socketPath?: string } } }>;
|
||||
}>(
|
||||
`/api/terminal/status?provider=herdr-app&appHome=${encodeURIComponent(selectedHerdrApp.appHome)}`,
|
||||
);
|
||||
const report = probe.data?.providers?.[0]?.status ?? probe.data;
|
||||
if (probe.status >= 400 || typeof report?.available !== "boolean") {
|
||||
const socketPath = report?.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
|
||||
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but the daemon could not confirm whether it is answering (HTTP ${probe.status}); opening a desktop window instead. Use --provider herdr-app to require it.`;
|
||||
console.error(ambientFallbackNote);
|
||||
selectedHerdrApp = null;
|
||||
} else if (!report.available) {
|
||||
const socketPath = report.launch?.socketPath ?? join(selectedHerdrApp.appHome, "herdr-app.sock");
|
||||
ambientFallbackNote = `herdr-app's socket exists at ${socketPath} but it isn't answering; opening a desktop window instead. Use --provider herdr-app to require it.`;
|
||||
console.error(ambientFallbackNote);
|
||||
selectedHerdrApp = null;
|
||||
}
|
||||
}
|
||||
if (selectedHerdrApp) {
|
||||
// herdr-app: a fresh Rig view in that client's own window, through the daemon. No desktop window.
|
||||
if (following) { printResult(opts.json ?? false, { ok: false, provider: "herdr-app", code: "follow_unsupported", error: "Following isn't available in herdr-app yet; open the view without --follow." }, 400); return; }
|
||||
const { appHome } = selectedHerdrApp;
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", { view, provider: "herdr-app",
|
||||
herdrApp: { appHome },
|
||||
...(opts.expectedPlan !== undefined ? { expectedPlan: opts.expectedPlan } : {}) }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
if (!Array.isArray(res.data?.opened)) { printResult(opts.json ?? false, res.data, res.status); return; }
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
|
||||
return;
|
||||
}
|
||||
// Herdr's control socket can answer with no desktop client attached.
|
||||
// The default requests a desktop; an explicit provider reuses its workspace.
|
||||
// --window never selects herdr-app
|
||||
if (following?.enabled !== false && (opts.window || !opts.provider)) {
|
||||
const windowProvider = opts.provider?.trim() === "herdr-app" ? undefined : opts.provider;
|
||||
const result = await openTerminalWindow(client, view, windowProvider, deps.windowDeps, opts.expectedPlan, session, following);
|
||||
const out = ambientFallbackNote ? { ...result, notes: [...(result.notes ?? []), ambientFallbackNote] } : result;
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, out), 200);
|
||||
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session, following);
|
||||
printOpen(opts.json ?? false, result, 200);
|
||||
return;
|
||||
}
|
||||
let expectedPlan = opts.expectedPlan;
|
||||
@@ -276,7 +187,7 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
printOpen(opts.json ?? false, await withHeldSeats(client, res.data), res.status);
|
||||
printOpen(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,38 +203,18 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("close <view>")
|
||||
.description("Close a herdr-app Rig view (rv-…): its cells leave the layout, then only its own viewers end; seats keep running")
|
||||
.option("--app-home <dir>", "herdr-app home the view is in (default: herdr-app's own)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { appHome?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const env = deps.env ?? process.env;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/close", { view, provider: "herdr-app",
|
||||
herdrApp: { appHome: opts.appHome ? resolve(opts.appHome) : herdrAppHome(env) } }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
if (opts.json) { printResult(true, res.data, res.status); return; }
|
||||
console.log(res.data?.ok ? `Closed Rig view ${view}.` : `Not closed: ${res.data?.error ?? `HTTP ${res.status}`}`);
|
||||
for (const note of res.data?.notes ?? []) console.log(` ${note}`);
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show terminal provider availability + liveness (doctor)")
|
||||
.option("--provider <name>", "restrict to one provider (herdr / herdr-app / cmux / rex)")
|
||||
.option("--provider <name>", "restrict to one provider (herdr / cmux / rex)")
|
||||
.option("--session <name>", "Inspect a named Herdr session on the daemon's host")
|
||||
.option("--app-home <dir>", "herdr-app: the client to inspect (default: herdr-app's own home)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { provider?: string; session?: string; appHome?: string; json?: boolean }) => {
|
||||
.action(async (opts: { provider?: string; session?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
let path = opts.provider
|
||||
? `/api/terminal/status?provider=${encodeURIComponent(opts.provider)}`
|
||||
: "/api/terminal/status";
|
||||
if (opts.provider === "herdr-app") path += `&appHome=${encodeURIComponent(opts.appHome ? resolve(opts.appHome) : herdrAppHome(deps.env ?? process.env))}`;
|
||||
const session = opts.session?.trim();
|
||||
if (session !== undefined) path += `${opts.provider ? "&" : "?"}session=${encodeURIComponent(session)}`;
|
||||
const res = await client.get<{ session?: string; error?: string }>(path);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonS
|
||||
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
|
||||
import { prepareDaemonAutoStart } from "../daemon-auto-start.js";
|
||||
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
|
||||
import { launchStatusRunning } from "./launch.js";
|
||||
import type { StartOptions } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -121,17 +120,6 @@ snapshot, resuming seats where their harness allows; it refuses while any of its
|
||||
whose rig name already exists starts a new team in place of the stopped one, which
|
||||
is archived, and refuses if the old one may still be running. To bring a stopped
|
||||
team back with its conversations, run rig up <rig-name> --existing, not the spec.
|
||||
|
||||
After a restore, rig up exits 0 only when the rig came back whole (rigResult
|
||||
fully_restored). It exits 1 when it didn't (partially_restored, failed or
|
||||
not_attempted), including when every seat is running but some started fresh
|
||||
without their conversation, or came back with a guidance or restore gap. With
|
||||
--json, read rigResult and each node's status, noConversation, guidanceGaps and
|
||||
restoreGaps to tell these apart: a seat with a gap keeps its own status, such as
|
||||
resumed, and a fresh seat marked noConversation (a terminal seat) lost nothing.
|
||||
A refused request exits 1 for a conflict (HTTP 409, such as a rig still running
|
||||
or a restore not attempted) and 2 for any other refusal (400, 404 or 500, such
|
||||
as no rig by that name or a failed boot).
|
||||
`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
@@ -718,7 +706,7 @@ as no rig by that name or a failed boot).
|
||||
}
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
if (rigResult) console.log(`Result: ${rigResult}`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string; noConversation?: true; guidanceGaps?: string[]; restoreGaps?: string[] }>) ?? [];
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
|
||||
// awaiting-decision reason is part of the line (not a dead end), and so is a failed seat's (such as a seat held
|
||||
@@ -729,16 +717,6 @@ as no rig by that name or a failed boot).
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
// Every seat running still exits 1 when the restore isn't whole; say why, so nobody retries a start that worked.
|
||||
if (rigResult === "partially_restored" && nodes.length > 0 && nodes.every((n) => launchStatusRunning(n.status))) {
|
||||
const fresh = nodes.filter((n) => (n.status === "fresh" || n.status === "fresh-primed") && !n.noConversation).length;
|
||||
const gapped = nodes.filter((n) => (n.guidanceGaps?.length ?? 0) > 0 || (n.restoreGaps?.length ?? 0) > 0).map((n) => n.logicalId);
|
||||
const reasons = [
|
||||
...(fresh > 0 ? [`${fresh} started fresh without their conversation`] : []),
|
||||
...(gapped.length > 0 ? [`${gapped.join(", ")} came back with a gap named in a warning`] : []),
|
||||
];
|
||||
console.log(`${nodes.length === 1 ? "The one seat is" : `All ${nodes.length} seats are`} running${reasons.length > 0 ? `; ${reasons.join("; ")}` : ""}. The restore isn't whole, so rig up exits 1.`);
|
||||
}
|
||||
const warnings = (res.data["warnings"] as string[]) ?? [];
|
||||
for (const w of warnings) {
|
||||
console.error(` warning: ${w}`);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
"Context transcripts measured 113K–153K tokens/MB. The margin is the protection " +
|
||||
"because prompt-bound consumers act only at turn boundaries",
|
||||
)
|
||||
.option("--spec <path>", "YAML spec file (required except for context-usage-threshold)")
|
||||
.option("--spec <path>", "Path to YAML spec file (optional for context-usage-threshold)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive, idle-gate-qitem, context-usage-threshold)")
|
||||
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
|
||||
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
|
||||
@@ -100,27 +100,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
|
||||
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Recurring reminder for an agent (not a person's one-shot queue watch):
|
||||
Save periodic-reminder.yaml:
|
||||
policy: periodic-reminder
|
||||
target:
|
||||
session: worker@my-team
|
||||
message: "Finish the current action, then check the pending work."
|
||||
|
||||
rig watchdog register --spec periodic-reminder.yaml --policy periodic-reminder \\
|
||||
--target-session worker@my-team --interval-seconds 600 \\
|
||||
--registered-by coordinator@my-team
|
||||
|
||||
All five flags above are required for periodic-reminder. At registration,
|
||||
--policy selects the policy; a YAML policy field is not the selector.
|
||||
YAML target.session takes precedence over --target-session; keep them aligned.
|
||||
Put the reminder message in YAML. --message is only a context-usage-threshold shortcut.
|
||||
For a person's one-shot watch, use rig queue block --wake-after instead.
|
||||
|
||||
Transcript density varies by session shape; do not tune a context threshold near the context wall.
|
||||
|
||||
The returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>`)
|
||||
.addHelpText("after", "\nTranscript density varies by session shape; do not tune a context threshold near the context wall.\n\nThe returned job id can be attached to a deliberate park with: rig queue block <qitemId> --on <blocker> --continuation <what-resumes> --wake-watchdog <jobId>")
|
||||
.action(async (opts: {
|
||||
spec?: string;
|
||||
policy: string;
|
||||
|
||||
@@ -64,8 +64,6 @@ interface WhoamiResult {
|
||||
resolvedBy: string;
|
||||
identity: WhoamiIdentity & Record<string, unknown>;
|
||||
peers: WhoamiPeer[];
|
||||
/** #2871: present only while `rig up` is still creating the team. */
|
||||
rosterState?: "building";
|
||||
edges: { outgoing: WhoamiEdge[]; incoming: WhoamiEdge[] };
|
||||
transcript: { enabled: boolean; path: string | null; tailCommand: string | null };
|
||||
contextUsage?: {
|
||||
@@ -105,8 +103,6 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
|
||||
}),
|
||||
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
|
||||
peersNote: bounded ? bounded.note : data["peersNote"],
|
||||
// #2871: while `rig up` is still creating the team, say so, as the daemon does.
|
||||
...(data["rosterState"] !== undefined ? { rosterState: data["rosterState"] } : {}),
|
||||
...(bounded ? { peersOmitted: bounded.omitted } : {}),
|
||||
// edges already carry only kind + to/from {logicalId, sessionName}.
|
||||
edges: data["edges"],
|
||||
@@ -147,14 +143,9 @@ export function boundPeers(data: Record<string, unknown>, bound: number, allPeer
|
||||
const ordered = [0, 1, 2].flatMap((t) => peers.filter((peer) => tier(peer) === t));
|
||||
const shown = ordered.slice(0, bound);
|
||||
const omitted = peers.length - shown.length;
|
||||
// #2871: while `rig up` is still building the team, the daemon's note opens with that; keep it first, and don't call
|
||||
// the edges complete.
|
||||
const daemonNote = typeof data["peersNote"] === "string" ? data["peersNote"] : "";
|
||||
const building = data["rosterState"] === "building";
|
||||
const opening = building && daemonNote.includes("peers = ") ? daemonNote.slice(0, daemonNote.indexOf("peers = ")) : "";
|
||||
const note = `${opening}peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
const note = `peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
+ `bound output.whoami_peers=${bound}); ${omitted} not shown; all: ${allPeersCommand}; `
|
||||
+ `edges = directional relationships (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
|
||||
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
|
||||
return { peers: shown, omitted, note };
|
||||
}
|
||||
|
||||
@@ -424,10 +415,6 @@ left out. --all-peers lists every one (today's compact output).`)
|
||||
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
|
||||
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
|
||||
|
||||
if (data.rosterState === "building") {
|
||||
console.log("");
|
||||
console.log("Team: still being built by `rig up`: the peers and edges below are the ones created so far, and more may come. Run `rig whoami` again once it finishes.");
|
||||
}
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
|
||||
@@ -72,7 +72,7 @@ export function describeDaemonRejection(body: unknown, instanceHint?: string): T
|
||||
return {
|
||||
fact: "The failed step no longer exists in the cached workflow spec.",
|
||||
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
|
||||
action: "Restore the step in the spec (then re-register it through the workflows-folder scan or an instantiate by path to refresh the cache) or instantiate a fresh run.",
|
||||
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
|
||||
};
|
||||
case "instance_version_conflict":
|
||||
return {
|
||||
|
||||
@@ -172,7 +172,7 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file in memory, without registering it (returns structured ok/error report)")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
|
||||
@@ -84,7 +84,6 @@ export interface RiggedConfig {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
terminal: { maxLiveTerminals: number };
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
maxPins: number;
|
||||
@@ -219,14 +218,10 @@ export interface RiggedConfig {
|
||||
enabled: boolean;
|
||||
transitionsDays: number;
|
||||
watchdogDays: number;
|
||||
usageSamplesDays: number;
|
||||
watchdogKeepPerJob: number;
|
||||
streamDays: number;
|
||||
streamMaxItems: number;
|
||||
eventsDays: number;
|
||||
closedContentDays: number;
|
||||
closedMetadataDays: number;
|
||||
erasureLedger: string;
|
||||
batchSize: number;
|
||||
};
|
||||
// S04 (OPR.0.5.5.4) — pickup-receipt stall threshold (twin of the daemon settings-store).
|
||||
@@ -308,10 +303,6 @@ const DEFAULTS = {
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
// The daemon store leaves its own resolver value unset for this key and the web
|
||||
// client falls back to 2 (settings-browser names that fallback), so the CLI reports
|
||||
// the same effective default rather than inventing a third answer.
|
||||
terminal: { maxLiveTerminals: 2 },
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
maxPins: 4,
|
||||
@@ -422,15 +413,10 @@ const DEFAULTS = {
|
||||
enabled: true,
|
||||
transitionsDays: 30,
|
||||
watchdogDays: 14,
|
||||
// Usage samples age out after this many days; the daemon's own default.
|
||||
usageSamplesDays: 14,
|
||||
watchdogKeepPerJob: 50,
|
||||
streamDays: 30,
|
||||
streamMaxItems: 20000,
|
||||
eventsDays: 0,
|
||||
closedContentDays: 0,
|
||||
closedMetadataDays: 0,
|
||||
erasureLedger: "",
|
||||
batchSize: 500,
|
||||
},
|
||||
// S04 — pickup-receipt stall threshold default (twin of daemon getDefaultValue).
|
||||
@@ -507,9 +493,6 @@ export const VALID_KEYS = [
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals. The daemon store
|
||||
// resolves it (SETTINGS_VALID_KEYS), so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -579,20 +562,12 @@ export const VALID_KEYS = [
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
// How long the daemon keeps usage samples; lockstep with the daemon twin.
|
||||
"retention.usage_samples_days",
|
||||
"retention.watchdog_keep_per_job",
|
||||
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
|
||||
"retention.stream_days",
|
||||
"retention.stream_max_items",
|
||||
// The history-only event types of `events` older than N days, 0 (default) keeps them; lockstep with the daemon twin.
|
||||
"retention.events_days",
|
||||
// Closed queue work: content erased N days after it closed, then the row (only its id kept); 0 (default) keeps them;
|
||||
// lockstep with the daemon twin.
|
||||
"retention.closed_content_days",
|
||||
"retention.closed_metadata_days",
|
||||
// Queue erasure's ledger file (JSON lines of erased ids and actions); empty keeps none; lockstep with the daemon twin.
|
||||
"retention.erasure_ledger",
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
|
||||
@@ -656,7 +631,6 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -713,14 +687,10 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
|
||||
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
|
||||
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
|
||||
"retention.usage_samples_days": { primary: "OPENRIG_RETENTION_USAGE_SAMPLES_DAYS" },
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
|
||||
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
|
||||
"retention.events_days": { primary: "OPENRIG_RETENTION_EVENTS_DAYS" },
|
||||
"retention.closed_content_days": { primary: "OPENRIG_RETENTION_CLOSED_CONTENT_DAYS" },
|
||||
"retention.closed_metadata_days": { primary: "OPENRIG_RETENTION_CLOSED_METADATA_DAYS" },
|
||||
"retention.erasure_ledger": { primary: "OPENRIG_RETENTION_ERASURE_LEDGER" },
|
||||
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
|
||||
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
|
||||
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
|
||||
@@ -773,7 +743,6 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -824,14 +793,10 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"retention.enabled": ["retention", "enabled"],
|
||||
"retention.transitions_days": ["retention", "transitionsDays"],
|
||||
"retention.watchdog_days": ["retention", "watchdogDays"],
|
||||
"retention.usage_samples_days": ["retention", "usageSamplesDays"],
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.stream_days": ["retention", "streamDays"],
|
||||
"retention.stream_max_items": ["retention", "streamMaxItems"],
|
||||
"retention.events_days": ["retention", "eventsDays"],
|
||||
"retention.closed_content_days": ["retention", "closedContentDays"],
|
||||
"retention.closed_metadata_days": ["retention", "closedMetadataDays"],
|
||||
"retention.erasure_ledger": ["retention", "erasureLedger"],
|
||||
"stream.intake": ["stream", "intake"],
|
||||
"retention.batch_size": ["retention", "batchSize"],
|
||||
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
|
||||
@@ -1105,7 +1070,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
|
||||
},
|
||||
"ui.terminal.max_live_terminals": positiveIntegerConstraint("ui.terminal.max_live_terminals"),
|
||||
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
|
||||
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
|
||||
@@ -1192,12 +1156,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// Same bound and message as the daemon twin's KEY_CONSTRAINTS entry.
|
||||
"retention.usage_samples_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.usage_samples_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
@@ -1225,24 +1183,6 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.events_days: must be an integer >= 0 (0 keeps the events), got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// 0 (the default) keeps closed work; a positive day count turns its erasure on (queue-erasure).
|
||||
"retention.closed_content_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.closed_content_days: must be an integer >= 0 (0 keeps the content), got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.closed_metadata_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.closed_metadata_days: must be an integer >= 0 (0 keeps the rows), got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
|
||||
"retention.erasure_ledger": (raw) => {
|
||||
const value = (raw ?? "").trim();
|
||||
if (value !== "" && !value.startsWith("/")) {
|
||||
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
@@ -1421,7 +1361,6 @@ export class ConfigStore {
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
terminal: { maxLiveTerminals: v("ui.terminal.max_live_terminals") as number },
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
maxPins: v("ui.preview.max_pins") as number,
|
||||
@@ -1512,14 +1451,10 @@ export class ConfigStore {
|
||||
enabled: v("retention.enabled") as boolean,
|
||||
transitionsDays: v("retention.transitions_days") as number,
|
||||
watchdogDays: v("retention.watchdog_days") as number,
|
||||
usageSamplesDays: v("retention.usage_samples_days") as number,
|
||||
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
|
||||
streamDays: v("retention.stream_days") as number,
|
||||
streamMaxItems: v("retention.stream_max_items") as number,
|
||||
eventsDays: v("retention.events_days") as number,
|
||||
closedContentDays: v("retention.closed_content_days") as number,
|
||||
closedMetadataDays: v("retention.closed_metadata_days") as number,
|
||||
erasureLedger: v("retention.erasure_ledger") as string,
|
||||
batchSize: v("retention.batch_size") as number,
|
||||
},
|
||||
queue: {
|
||||
|
||||
@@ -101,12 +101,7 @@ export async function runCrossHostCommand(
|
||||
? `OPENRIG_SESSION_NAME=${shellQuote(opts.originTriple)} ${quotedArgv}`
|
||||
: quotedArgv;
|
||||
const remoteCommandLine = `sh -lc ${shellQuote(innerCommand)}`;
|
||||
// SEP-1: `--` ends ssh's own option parsing, so a target that begins with `-` reaches ssh as the
|
||||
// destination instead of being read as one more option. That misreading is the harm: `-V`, for
|
||||
// instance, is ssh's own version flag, parsed and exited 0 before any connection, which this
|
||||
// executor would count as a success with no remote command run. With the separator, ssh rejects
|
||||
// the name itself.
|
||||
const fullArgs = [...sshOpts, "--", sshHost.target, remoteCommandLine];
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
@@ -22,15 +22,8 @@ export interface DaemonState {
|
||||
host?: string;
|
||||
db: string;
|
||||
startedAt: string;
|
||||
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
|
||||
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
|
||||
* Absent on older records. */
|
||||
pidNamespace?: string;
|
||||
}
|
||||
|
||||
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
|
||||
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
|
||||
* body. Present only when the daemon actually answered healthz with a monitor
|
||||
@@ -71,10 +64,6 @@ export interface DaemonStatus {
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
|
||||
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
|
||||
* down (#2860). */
|
||||
pidUnseen?: true;
|
||||
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
|
||||
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
|
||||
startPending?: { pid: number; since?: string };
|
||||
@@ -163,15 +152,6 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
|
||||
if (status.pidUnseen) {
|
||||
return {
|
||||
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
|
||||
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
|
||||
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
@@ -268,9 +248,6 @@ export interface LifecycleDeps {
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
|
||||
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
|
||||
pidNamespace?: () => string | undefined;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -912,8 +889,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
}
|
||||
assertChild();
|
||||
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
|
||||
const namespace = deps.pidNamespace?.();
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
|
||||
try {
|
||||
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
assertChild();
|
||||
@@ -974,17 +950,6 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
|
||||
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
|
||||
}
|
||||
|
||||
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
|
||||
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
|
||||
* missing number says nothing. Only an older record, which names none, is read as the host's. */
|
||||
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
|
||||
if (!deps.pidNamespace) return true;
|
||||
const own = deps.pidNamespace();
|
||||
if (own === undefined) return false;
|
||||
if (state.pidNamespace) return own === state.pidNamespace;
|
||||
return own === "host" || own === INITIAL_PID_NAMESPACE;
|
||||
}
|
||||
|
||||
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
|
||||
// A concurrent start/rebind must not have its state removed by an old stop/status.
|
||||
const current = readState(deps);
|
||||
@@ -1188,23 +1153,7 @@ export async function getDaemonStatus(
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
|
||||
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
|
||||
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
|
||||
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
|
||||
const recordedHost = recorded.host ?? DEFAULT_HOST;
|
||||
const recordedLiveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(recorded.pid)
|
||||
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
|
||||
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
|
||||
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|
||||
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
|
||||
const startPending = liveStartReservation(deps);
|
||||
return {
|
||||
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
|
||||
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
|
||||
};
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1238,21 +1187,17 @@ export async function getDaemonStatus(
|
||||
}
|
||||
}
|
||||
|
||||
let liveness: ProcessLiveness = deps.processLiveness
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
|
||||
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
|
||||
if (liveness === "dead") {
|
||||
if (cleanShutdown && options.cleanupStaleState !== false) {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
|
||||
removeMatchingState(deps, stateFile, state);
|
||||
}
|
||||
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
|
||||
liveness = "unknown";
|
||||
return { state: "stale" };
|
||||
}
|
||||
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
@@ -1268,9 +1213,7 @@ export async function getDaemonStatus(
|
||||
// T2: unless a live start reservation says a launch is in progress.
|
||||
if (isRefusedError(err)) {
|
||||
const startPending = liveStartReservation(deps);
|
||||
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
|
||||
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
|
||||
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
|
||||
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
|
||||
}
|
||||
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
|
||||
* reporting a starved loop) gets the request, under its normal timeout. */
|
||||
export function daemonMayAnswer(status: DaemonStatus): boolean {
|
||||
if (status.state === "stopped" || status.state === "stale") return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
|
||||
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,53 +1,23 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface DaemonStartLock {
|
||||
recordChild(pid: number): void;
|
||||
release(preserve?: boolean): void;
|
||||
/** Where a reservation left from before this boot was archived, when this launch did so. */
|
||||
archived?: string;
|
||||
}
|
||||
|
||||
export interface StaleLockCheck {
|
||||
/** This boot's identity (`currentBootId`), recorded in the reservation; undefined where the OS has none. */
|
||||
bootId?: string;
|
||||
/** When this machine booted, in ms since the epoch. */
|
||||
bootedAtMs: number;
|
||||
/** Three-state liveness (`createProcessLiveness`): only "dead" counts as gone. */
|
||||
processLiveness: (pid: number) => "alive" | "dead" | "unknown";
|
||||
/** Test seam: runs after the reservation was inspected and before it is claimed. */
|
||||
afterInspect?: () => void;
|
||||
}
|
||||
|
||||
/** One supported local launch owns pre-bind initialization through publication.
|
||||
* ponytail: no age-based takeover — a dead launcher can leave a live unbound child.
|
||||
* The one exception is a reservation from an earlier boot: a reboot ends every process it could name.
|
||||
* A reservation that records its boot ID is from an earlier boot when that ID differs from this one's;
|
||||
* PIDs don't matter then, since after a reboot they may belong to unrelated processes.
|
||||
* One without a boot ID (written by an older CLI) must have been written and last changed before
|
||||
* this boot, with every recorded PID positively dead; the PID check covers a clock that jumped after
|
||||
* boot, and a PID that can't be inspected keeps the refusal.
|
||||
* Otherwise hard-crash recovery inspects the recorded PIDs before archiving this file. */
|
||||
export function acquireDaemonStartLock(home: string, stale?: StaleLockCheck): DaemonStartLock {
|
||||
* Hard-crash recovery inspects the recorded PIDs before archiving this file. */
|
||||
export function acquireDaemonStartLock(home: string): DaemonStartLock {
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
const file = path.join(home, "daemon-start.lock");
|
||||
let fd: number;
|
||||
let archived: string | undefined;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx", 0o600);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
archived = stale ? archiveIfBeforeBoot(file, stale) : undefined;
|
||||
if (!archived) throw reserved(file);
|
||||
try {
|
||||
fd = fs.openSync(file, "wx", 0o600);
|
||||
} catch (retry) {
|
||||
// Another launch took the path after this one archived the old reservation: it owns startup.
|
||||
if ((retry as NodeJS.ErrnoException).code === "EEXIST") throw reserved(file);
|
||||
throw retry;
|
||||
}
|
||||
throw new Error(`Daemon startup is already reserved: ${file}. Inspect its launcher/child PIDs, daemon.json and daemon.log. If the launch was abandoned, prove both processes are absent before archiving the reservation and retrying; no child spawned.`);
|
||||
}
|
||||
const identity = fs.fstatSync(fd);
|
||||
const release = (preserve = false): void => {
|
||||
@@ -63,8 +33,7 @@ export function acquireDaemonStartLock(home: string, stale?: StaleLockCheck): Da
|
||||
}
|
||||
};
|
||||
try {
|
||||
const bootId = stale?.bootId ? { bootId: stale.bootId } : {};
|
||||
fs.writeSync(fd, JSON.stringify({ launcherPid: process.pid, startedAt: new Date().toISOString(), ...bootId }) + "\n");
|
||||
fs.writeSync(fd, JSON.stringify({ launcherPid: process.pid, startedAt: new Date().toISOString() }) + "\n");
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
@@ -72,94 +41,5 @@ export function acquireDaemonStartLock(home: string, stale?: StaleLockCheck): Da
|
||||
return {
|
||||
recordChild: (pid) => { fs.writeSync(fd, JSON.stringify({ childPid: pid }) + "\n"); },
|
||||
release,
|
||||
...(archived ? { archived } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reserved(file: string): Error {
|
||||
return new Error(`Daemon startup is already reserved: ${file}. Inspect its launcher/child PIDs, daemon.json and daemon.log. If the launch was abandoned, prove both processes are absent before archiving the reservation and retrying; no child spawned.`);
|
||||
}
|
||||
|
||||
/** This machine's boot time, in ms since the epoch. */
|
||||
export function bootedAtMs(): number {
|
||||
return Date.now() - os.uptime() * 1000;
|
||||
}
|
||||
|
||||
/** An ID that changes on every boot: Linux's boot_id, macOS's kern.bootsessionuuid; else undefined. */
|
||||
export function currentBootId(): string | undefined {
|
||||
try {
|
||||
const id = process.platform === "linux"
|
||||
? fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8")
|
||||
: process.platform === "darwin"
|
||||
? execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 2_000 })
|
||||
: "";
|
||||
return id.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Archives a reservation from an earlier boot (see `acquireDaemonStartLock`) and returns the archive's path.
|
||||
* Anything unreadable or from this boot stays where it is, and so does a legacy one naming a PID that isn't
|
||||
* positively dead. One launch wins: the archive is a hard link named after the inspected file's inode, made
|
||||
* with link(2), which never replaces an existing name. A launch that lost the race, or whose path now holds a
|
||||
* different file, gets EEXIST or a mismatched inode and keeps the refusal. Only the winner removes the old
|
||||
* name, and nothing else can replace it while it exists, so the winner never removes another launch's lock.
|
||||
* A winner that dies between link and unlink leaves both names; later starts refuse, as before this change. */
|
||||
function archiveIfBeforeBoot(file: string, stale: StaleLockCheck): string | undefined {
|
||||
let text: string;
|
||||
let inspected: fs.Stats;
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(file, "r");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
inspected = fs.fstatSync(fd);
|
||||
text = fs.readFileSync(fd, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
const pids: number[] = [];
|
||||
let startedAtMs = Number.NaN;
|
||||
let lockBootId: string | undefined;
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let record: { launcherPid?: unknown; childPid?: unknown; startedAt?: unknown; bootId?: unknown };
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const pid of [record.launcherPid, record.childPid]) {
|
||||
if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) pids.push(pid);
|
||||
}
|
||||
if (typeof record.startedAt === "string") startedAtMs = Date.parse(record.startedAt);
|
||||
if (typeof record.bootId === "string" && record.bootId) lockBootId = record.bootId;
|
||||
}
|
||||
if (!Number.isFinite(startedAtMs) || pids.length === 0) return undefined;
|
||||
if (lockBootId && stale.bootId) {
|
||||
if (lockBootId === stale.bootId) return undefined;
|
||||
} else {
|
||||
if (Math.max(startedAtMs, inspected.mtimeMs) >= stale.bootedAtMs) return undefined;
|
||||
if (!pids.every((pid) => stale.processLiveness(pid) === "dead")) return undefined;
|
||||
}
|
||||
stale.afterInspect?.();
|
||||
const stamp = new Date(startedAtMs).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
||||
const target = `${file}.abandoned-${stamp}-${inspected.ino}`;
|
||||
try {
|
||||
fs.linkSync(file, target);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const linked = fs.statSync(target);
|
||||
if (linked.dev !== inspected.dev || linked.ino !== inspected.ino) {
|
||||
fs.unlinkSync(target);
|
||||
return undefined;
|
||||
}
|
||||
fs.unlinkSync(file);
|
||||
return target;
|
||||
}
|
||||
@@ -91,7 +91,6 @@ import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-pla
|
||||
import { compactCommand, type CompactDeps } from "./commands/compact.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { networkCommand, type NetworkDeps } from "./commands/network.js";
|
||||
import { rigModeCommand, type RigModeDeps } from "./commands/rig-mode.js";
|
||||
import { policyCommand } from "./commands/policy.js";
|
||||
import { startupProofCommand, type StartupProofDeps } from "./commands/startup-proof.js";
|
||||
@@ -169,7 +168,6 @@ export interface ProgramDeps {
|
||||
compactDeps?: CompactDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
networkDeps?: NetworkDeps;
|
||||
rigModeDeps?: RigModeDeps;
|
||||
startupProofDeps?: StartupProofDeps;
|
||||
healthDeps?: HealthDeps;
|
||||
@@ -271,7 +269,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(handoverCommand(depsOverride?.seatDeps));
|
||||
program.addCommand(networkCommand(depsOverride?.networkDeps));
|
||||
program.addCommand(startupProofCommand(depsOverride?.startupProofDeps));
|
||||
// release-0.3.2 slice 12 — rig scope CLI primitive.
|
||||
program.addCommand(scopeCommand());
|
||||
|
||||
@@ -102,7 +102,6 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
|
||||
["heartbeat", async () => (await import("./commands/heartbeat.js")).heartbeatCommand()],
|
||||
["seat", async () => (await import("./commands/seat.js")).seatCommand()],
|
||||
["handover", async () => (await import("./commands/seat.js")).handoverCommand()],
|
||||
["network", async () => (await import("./commands/network.js")).networkCommand()],
|
||||
["startup-proof", async () => (await import("./commands/startup-proof.js")).startupProofCommand()],
|
||||
["scope", async () => (await import("./commands/scope.js")).scopeCommand()],
|
||||
["proof", async () => (await import("./commands/proof.js")).proofCommand()],
|
||||
|
||||
@@ -46,7 +46,6 @@ export function parseLogicalCheckboxes(block: string | null): LogicalCheckboxIte
|
||||
if (!block) return [];
|
||||
const lines = block.split(/\r?\n/);
|
||||
const out: LogicalCheckboxItem[] = [];
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
let current: { checked: boolean; parts: string[]; indent: number; sourceLine: number } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
@@ -57,18 +56,6 @@ export function parseLogicalCheckboxes(block: string | null): LogicalCheckboxIte
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
// Fenced examples do not declare proof items or shift evidence indexes.
|
||||
const marker = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (fence) {
|
||||
if (marker && marker[1]![0] === fence.marker && marker[1]!.length >= fence.length
|
||||
&& /^[ \t]*$/.test(marker[2]!)) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (marker && (marker[1]![0] !== "`" || !marker[2]!.includes("`"))) {
|
||||
flush();
|
||||
fence = { marker: marker[1]![0]!, length: marker[1]!.length };
|
||||
continue;
|
||||
}
|
||||
const m = line.match(CHECKBOX_LINE);
|
||||
if (m) {
|
||||
flush();
|
||||
|
||||
@@ -410,8 +410,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
|
||||
consequence: "Command did not run.",
|
||||
action: "List available missions with: rig scope mission ls. "
|
||||
+ "For a mission in another workspace, such as a catalog project, select its tree with the parent option: rig scope --workspace <project root> mission <verb> <mission>",
|
||||
action: "List available missions with: rig scope mission ls",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -529,15 +528,10 @@ export function findSlice(
|
||||
// Walk up to find the owning mission.
|
||||
const owningMissionPath = findOwningMission(missionsRoot, candidate);
|
||||
if (!owningMissionPath) {
|
||||
const workspace = workspaceHoldingSlice(missionsRoot, candidate);
|
||||
const error = new ScopeCliError({
|
||||
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
|
||||
consequence: "Cannot determine mission context for this slice.",
|
||||
action: workspace
|
||||
? `The slice is in another workspace's missions tree (${workspace.missionsRoot}), not under ${missionsRoot}. `
|
||||
+ `Select that tree with the parent --workspace option, for example: rig scope --workspace ${quoteArg(workspace.option)} slice <verb> ${quoteArg(slicePath)}`
|
||||
: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/. "
|
||||
+ "For a slice in another workspace, such as a catalog project, select its tree with the parent option: rig scope --workspace <project root> slice <verb> <slice-path>",
|
||||
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
|
||||
});
|
||||
if (!hintedName) throw error;
|
||||
firstContextError ??= error;
|
||||
@@ -557,33 +551,6 @@ export function findSlice(
|
||||
});
|
||||
}
|
||||
|
||||
/** POSIX single-quoting for a path shown in a command. */
|
||||
function quoteArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a slice directory the selected missions root does not hold, the --workspace value that
|
||||
* selects the nearest missions tree that does: the project root when that resolves to the tree,
|
||||
* else the missions folder itself. Each value is checked with resolveMissionsRoot, so the named
|
||||
* option is one that works. Null when no ancestor is a readable missions tree.
|
||||
*/
|
||||
function workspaceHoldingSlice(selectedRoot: string, sliceDir: string): { option: string; missionsRoot: string } | null {
|
||||
const selected = path.resolve(selectedRoot);
|
||||
const target = path.resolve(sliceDir);
|
||||
for (let root = path.dirname(target); root !== path.dirname(root); root = path.dirname(root)) {
|
||||
if (root === selected || !walkToMission(root, target)) continue;
|
||||
for (const option of [path.dirname(root), root]) {
|
||||
try {
|
||||
if (path.resolve(resolveMissionsRoot({ override: option, strictOverride: true })) === root) return { option, missionsRoot: root };
|
||||
} catch {
|
||||
// Not a workspace for this tree; try the next form.
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
|
||||
// path.dirname never ends in a separator, so compare against the normalized root
|
||||
// (a configured or --workspace root may be written as `/w/missions/`); resolve the
|
||||
|
||||
@@ -305,18 +305,6 @@ function projectForExternal<T>(row: T, view: ExternalView): T {
|
||||
return fields as T;
|
||||
}
|
||||
|
||||
/** A handed-on item as a scoped caller receives it: without the daemon's own successor fields (successors,
|
||||
* successorsTruncated, successorNote), which name rows and remote ids regardless of this caller's scope. Its answer is
|
||||
* show's scope-checked successor (successorOf). */
|
||||
function withoutDaemonSuccessors<T>(row: T): T {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
|
||||
const fields = { ...(row as Record<string, unknown>) };
|
||||
delete fields.successors;
|
||||
delete fields.successorsTruncated;
|
||||
delete fields.successorNote;
|
||||
return fields as T;
|
||||
}
|
||||
|
||||
/** What an authenticated non-owner caller may reach: its own seat, its rigs' seats, the items they send or own (to read),
|
||||
* the items they own (to change), and its shared roots. A person (<entityId>@external) is never in a non-owner's scope. */
|
||||
function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
|
||||
@@ -403,18 +391,6 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
|
||||
};
|
||||
}
|
||||
|
||||
/** The daemon's root_unknown names every shared root it has. A scoped caller learns only the ones it may read: the
|
||||
* daemon's roots its grant names, or none when that list can't be read. Every other answer passes as it is. */
|
||||
function scopedRootUnknown(res: DaemonResponse<unknown>, scope: Scope): DaemonResponse<unknown> {
|
||||
const data = (res.data ?? {}) as Record<string, unknown>;
|
||||
if (data.error !== "root_unknown") return res;
|
||||
const roots = Array.isArray(data.configuredRoots)
|
||||
? data.configuredRoots.filter((name): name is string => typeof name === "string" && scope.root(name)) : [];
|
||||
const named = typeof data.rootName === "string" ? ` '${data.rootName}'` : "";
|
||||
return { status: res.status, data: { error: "root_unknown", ...(named ? { rootName: data.rootName } : {}), configuredRoots: roots,
|
||||
message: `Shared root${named} is not configured on this daemon. Shared roots this connection may read: ${roots.join(", ") || "(none)"}.` } };
|
||||
}
|
||||
|
||||
/** A target outside an authenticated caller's scope. It names the kind of target only, never the record. */
|
||||
function outOfScope(what: string, hint?: string): TextResult {
|
||||
const message = `${what} is outside what this connection may reach.${hint ? ` ${hint}` : ""}`;
|
||||
@@ -633,9 +609,8 @@ const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
if (!access?.external) return frontDoor ? FRONT_DOOR_INSTRUCTIONS : INSTRUCTIONS;
|
||||
const tools = externalTools(access.external);
|
||||
const instructions = tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS
|
||||
: tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
if (tools.has("rig_queue_claim") && tools.has("rig_queue_update")) return EXTERNAL_INBOX_INSTRUCTIONS;
|
||||
return tools.has("rig_question_list") || tools.has("rig_question_answer") ? EXTERNAL_PERSON_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
|
||||
@@ -1445,10 +1420,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
const view = externalView(scope);
|
||||
let successor = handedOn ? await successorOf(client, qitemId, row.closureTarget, view ? scope!.item : undefined) : undefined;
|
||||
if (scope && !view && successor && "destinationSession" in successor && !scope.successor(successor.destinationSession)) successor = { unknown: true, code: "out_of_scope" };
|
||||
const shownItem = scope ? withoutDaemonSuccessors(item.data) : item.data;
|
||||
const data = view
|
||||
? { item: projectForExternal(shownItem, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
|
||||
: { item: shownItem, transitions: transitions.data };
|
||||
? { item: projectForExternal(item.data, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
|
||||
: { item: item.data, transitions: transitions.data };
|
||||
return mapResult({ status: 200, data: { ...data, ...(handedOn ? { successor } : {}) } }, undefined, { structured: true });
|
||||
} catch (err) {
|
||||
return caught(err);
|
||||
@@ -1705,33 +1679,18 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_team")) server.registerTool(
|
||||
"rig_team",
|
||||
{
|
||||
description: "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
|
||||
description: "Show a rig's team: its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
|
||||
inputSchema: z.object({
|
||||
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
|
||||
rigId: z.string().describe("Rig name or identifier"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
const inferred = rigId === undefined && !!access?.external;
|
||||
if (rigId === undefined) {
|
||||
if (!access?.external) {
|
||||
const message = "Pass a rig name or identifier as rigId.";
|
||||
return errorResult(message, "rig_required", message);
|
||||
}
|
||||
const rigs = [...new Set(access.rigs)];
|
||||
if (rigs.length !== 1) return mapResult({ status: 200, data: { rigs,
|
||||
message: rigs.length ? "Choose one of this grant's rigs and pass its name as rigId." : "This grant names no rigs; ask the connection operator to check its scope." } }, undefined, { structured: true });
|
||||
rigId = rigs[0]!;
|
||||
}
|
||||
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
|
||||
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
|
||||
const rig = await rigIdFor(client, rigId);
|
||||
if ("content" in rig) return rig;
|
||||
if (inferred && !rig.resolved) {
|
||||
const message = `No rig could be resolved from this grant's scope: ${rigId}. Check the connection's scope with its operator.`;
|
||||
return errorResult(message, "scoped_rig_unresolved", message, { rigs: [rigId] });
|
||||
}
|
||||
const id = encodeURIComponent(rig.id);
|
||||
const detail = await client.get(`/api/rigs/${id}`);
|
||||
if (detail.status >= 400) return mapResult(detail);
|
||||
@@ -1880,7 +1839,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
try {
|
||||
const read = async (source: string, rootName: string, inside: string): Promise<TextResult> => {
|
||||
const res = await client.get(`/api/files/read?${new URLSearchParams({ root: rootName, path: inside }).toString()}`);
|
||||
if (res.status >= 400) return mapResult(scope ? scopedRootUnknown(res, scope) : res);
|
||||
if (res.status >= 400) return mapResult(res);
|
||||
return mapResult({ status: 200, data: artifactView(source, (res.data ?? {}) as FileRead, offset ?? 0) }, undefined, { structured: true });
|
||||
};
|
||||
if (scope && root && !scope.root(root)) return outOfScope(`Root ${root}`);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parseSessionName } from "./session-name.js";
|
||||
|
||||
/** One of the rig's seats as the daemon records it: its logical id, its member id within its pod, and its folder, the
|
||||
* part of its session name before `@`, which every recap reader uses. */
|
||||
export interface RigSeat { logicalId: string; member: string; folder: string }
|
||||
|
||||
/** Where `rig context recap-write` writes a recap: `rigs/<rig>/seats/<seat>`. Without --rig or --seat it's the calling
|
||||
* seat's folder (OPENRIG_SESSION_NAME). A name that is a seat's folder, or the caller's own, is used as given. Another
|
||||
* name maps to a seat's folder only when it is exactly one seat's recorded logical id or member id in the rig's seats,
|
||||
* so neither a suffix nor a dot is taken as identity. When the seats can't be read, or nothing matches, the name is
|
||||
* written as given and a note says what was and wasn't checked. Nothing is refused. */
|
||||
export function resolveRecapSeat(opts: { rig?: string; seat?: string; sessionName?: string; topologyRoot: string;
|
||||
seats?: RigSeat[] | { unavailable: string } }): { rig: string; seat: string; notes: string[] } | { error: string } {
|
||||
const own = opts.sessionName ? parseSessionName(opts.sessionName) : undefined;
|
||||
const ownRig = own?.kind === "canonical" ? own.rig : undefined;
|
||||
const rig = opts.rig ?? ownRig;
|
||||
if (!rig) return { error: "Name the rig with --rig: OPENRIG_SESSION_NAME isn't set, so there's no seat to default to." };
|
||||
const self = own?.kind === "canonical" && rig === own.rig ? own.member : undefined;
|
||||
if (opts.seat === undefined) {
|
||||
if (!self) return { error: `Name the seat with --seat: the part of its session name before @ (dev-qa for dev-qa@${rig}).` };
|
||||
return { rig, seat: self, notes: [] };
|
||||
}
|
||||
const given = opts.seat;
|
||||
if (given === self) return { rig, seat: given, notes: [] };
|
||||
const fresh = !existsSync(join(opts.topologyRoot, "rigs", rig, "seats", given));
|
||||
const where = "OpenRig reads a seat's recap from the folder named by the part of its session name before @.";
|
||||
if (!opts.seats || "unavailable" in opts.seats) {
|
||||
const why = opts.seats && "unavailable" in opts.seats ? ` (${opts.seats.unavailable})` : "";
|
||||
return { rig, seat: given, notes: [`${fresh ? `seats/${given} didn't exist in rig ${rig}, so this write created it, and the` : `The recap went to the existing seats/${given} as given, but the`} name couldn't be checked against the rig's seats${why}. ${where}`] };
|
||||
}
|
||||
if (opts.seats.some((seat) => seat.folder === given)) return { rig, seat: given, notes: [] };
|
||||
const matches = opts.seats.filter((seat) => seat.logicalId === given || seat.member === given);
|
||||
if (matches.length === 1) {
|
||||
const [seat] = matches;
|
||||
const kind = seat!.logicalId === given ? "logical id" : "member id";
|
||||
return { rig, seat: seat!.folder, notes: [`--seat ${given} is the ${kind} of ${seat!.folder}@${rig}, so the recap goes to seats/${seat!.folder}, the folder OpenRig reads its recap from.`] };
|
||||
}
|
||||
const named = matches.length ? ` It's the logical or member id of ${matches.map((seat) => `seats/${seat.folder}`).join(" and ")}, so it wasn't mapped; name one of them.` : "";
|
||||
return { rig, seat: given, notes: [`seats/${given} ${fresh ? `didn't exist in rig ${rig}, so this write created it` : `isn't one of rig ${rig}'s seat folders`}. ${where}${named}`] };
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// Seats that need attention, named where a command shows or reports them, so a held seat doesn't read as a view or
|
||||
// kernel that failed for no reason (D#52). This reads the daemon's own attention flag and recorded reason from the
|
||||
// node route `rig ps --nodes` uses; it detects nothing itself. A check that couldn't be made is reported as unknown,
|
||||
// never as "nothing needs attention".
|
||||
|
||||
import type { DaemonClient } from "./client.js";
|
||||
import { attentionReason, questionReason, servesQuestion, type NodeEntry } from "./commands/ps.js";
|
||||
|
||||
export interface SeatAttention {
|
||||
seat: string;
|
||||
reason: string;
|
||||
/** The daemon serves a live question for this seat (needs-input), so a person can answer it there. Otherwise the
|
||||
* flag is broader: a failed or gated startup, a recorded error, a held message or a missing required file. */
|
||||
waitingOnPerson: boolean;
|
||||
}
|
||||
|
||||
export interface AttentionCheck {
|
||||
attention: SeatAttention[];
|
||||
/** Seats whose attention couldn't be read, and why. */
|
||||
unknown: Array<{ seat: string; why: string }>;
|
||||
}
|
||||
|
||||
function failure(res: { status: number; data?: unknown }): string {
|
||||
const error = (res.data as { error?: unknown } | null | undefined)?.error;
|
||||
return `HTTP ${res.status}${typeof error === "string" && error ? `: ${error}` : ""}`;
|
||||
}
|
||||
|
||||
/** The given local seats (session names, `member@rig`) checked against the daemon's node inventory. A rig whose read
|
||||
* fails leaves its seats unknown and keeps the other rigs' answers. */
|
||||
export async function checkSeatAttention(client: DaemonClient, seats: readonly string[]): Promise<AttentionCheck> {
|
||||
const check: AttentionCheck = { attention: [], unknown: [] };
|
||||
const byRig = new Map<string, Set<string>>();
|
||||
for (const seat of seats) {
|
||||
const at = seat.indexOf("@");
|
||||
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
|
||||
const rig = seat.slice(at + 1);
|
||||
if (!byRig.has(rig)) byRig.set(rig, new Set());
|
||||
byRig.get(rig)!.add(seat);
|
||||
}
|
||||
if (byRig.size === 0) return check;
|
||||
const unknownAll = (why: string) => { for (const wanted of byRig.values()) for (const seat of wanted) check.unknown.push({ seat, why }); };
|
||||
let rigs: Array<{ rigId: string; rigName?: string; name?: string }>;
|
||||
try {
|
||||
const res = await client.get<Array<{ rigId: string; rigName?: string; name?: string }>>("/api/ps");
|
||||
if (res.status >= 400 || !Array.isArray(res.data)) { unknownAll(`the rig list couldn't be read (${failure(res)})`); return check; }
|
||||
rigs = res.data;
|
||||
} catch (err) {
|
||||
unknownAll(`the rig list couldn't be read (${err instanceof Error ? err.message : String(err)})`);
|
||||
return check;
|
||||
}
|
||||
for (const [rigName, wanted] of byRig) {
|
||||
const matches = rigs.filter(rig => (rig.rigName ?? rig.name) === rigName);
|
||||
if (matches.length === 0) { for (const seat of wanted) check.unknown.push({ seat, why: `no rig named ${rigName} on this daemon` }); continue; }
|
||||
const found = new Set<string>();
|
||||
const failures: string[] = [];
|
||||
for (const rig of matches) {
|
||||
let nodes: NodeEntry[];
|
||||
try {
|
||||
const res = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
|
||||
if (res.status >= 400 || !Array.isArray(res.data)) { failures.push(`${rigName}'s seats couldn't be read (${failure(res)})`); continue; }
|
||||
nodes = res.data;
|
||||
} catch (err) {
|
||||
failures.push(`${rigName}'s seats couldn't be read (${err instanceof Error ? err.message : String(err)})`);
|
||||
continue;
|
||||
}
|
||||
for (const node of nodes) {
|
||||
const seat = node.canonicalSessionName;
|
||||
if (!seat || !wanted.has(seat) || found.has(seat)) continue;
|
||||
found.add(seat);
|
||||
if (node.needsAttention === undefined) { check.unknown.push({ seat, why: "the daemon served no attention evidence for it" }); continue; }
|
||||
if (!node.needsAttention) continue;
|
||||
const question = servesQuestion(node);
|
||||
// A live question names its own reason. An older recorded error would describe something else.
|
||||
check.attention.push({ seat, reason: question ? questionReason(node) ?? "reason unavailable" : attentionReason(node), waitingOnPerson: question });
|
||||
}
|
||||
}
|
||||
for (const seat of wanted) {
|
||||
if (!found.has(seat)) check.unknown.push({ seat, why: failures[0] ?? `not in ${rigName}'s seats on this daemon` });
|
||||
}
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
/** The kernel's seats that aren't ready, checked the same way. */
|
||||
export async function checkKernelAttention(client: DaemonClient): Promise<AttentionCheck> {
|
||||
try {
|
||||
const res = await client.get<{ agents?: Array<{ session_name?: string; startup_status?: string; down?: boolean }> }>("/api/kernel/status");
|
||||
if (res.status >= 400) return { attention: [], unknown: [{ seat: "kernel", why: `the kernel status couldn't be read (${failure(res)})` }] };
|
||||
const unready = (res.data?.agents ?? [])
|
||||
.filter(agent => agent.startup_status !== "ready" || agent.down)
|
||||
.flatMap(agent => agent.session_name ? [agent.session_name] : []);
|
||||
return unready.length ? checkSeatAttention(client, unready) : { attention: [], unknown: [] };
|
||||
} catch (err) {
|
||||
return { attention: [], unknown: [{ seat: "kernel", why: `the kernel status couldn't be read (${err instanceof Error ? err.message : String(err)})` }] };
|
||||
}
|
||||
}
|
||||
|
||||
/** One line per seat that needs attention, then one line per reason a check couldn't be made. */
|
||||
export function attentionLines(check: AttentionCheck, where: "tile" | "pane"): string[] {
|
||||
const lines = check.attention.map(({ seat, reason, waitingOnPerson }) => waitingOnPerson
|
||||
? `${seat} is waiting on a person: ${reason}. Answer it in its ${where === "tile" ? "tile" : "terminal"}, or read it with: rig capture ${seat}`
|
||||
: `${seat} needs attention: ${reason}. Look at it ${where === "tile" ? "in its tile " : ""}with: rig capture ${seat}`);
|
||||
const byWhy = new Map<string, string[]>();
|
||||
for (const { seat, why } of check.unknown) byWhy.set(why, [...(byWhy.get(why) ?? []), seat]);
|
||||
for (const [why, seats] of byWhy) {
|
||||
const named = seats.length > 5 ? `${seats.slice(0, 5).join(", ")} and ${seats.length - 5} more` : seats.join(", ");
|
||||
lines.push(`Couldn't check whether ${named} ${seats.length === 1 ? "needs" : "need"} attention: ${why}.`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -19,8 +19,6 @@ interface Preview {
|
||||
tmux?: { mode: "inherit" | "isolated" };
|
||||
status: { launch?: { socketPath: string; session?: string } };
|
||||
composed: { id: string; localTmuxServer?: readonly string[]; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
|
||||
/** The composed view's remote tiles, complete; an older daemon's preview omits it, so their hosts are unknown. */
|
||||
remote?: Array<{ seat: string; host: string }>;
|
||||
}
|
||||
|
||||
export interface WindowDeps {
|
||||
@@ -302,7 +300,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
return failed("The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.");
|
||||
}
|
||||
const launchTarget = preview.data.status.launch;
|
||||
let { composed, planId, remote } = preview.data;
|
||||
let { composed, planId } = preview.data;
|
||||
await assertLocalTmux(client, preview.data.tmux?.mode, "The window launcher must run on the daemon's own desktop; its instance identity could not be confirmed locally.");
|
||||
const tmuxServer = readTmuxServerArgs(composed.localTmuxServer);
|
||||
if (expectedPlan !== undefined && expectedPlan !== planId) {
|
||||
@@ -322,7 +320,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
if (JSON.stringify(readTmuxServerArgs(next.data.composed.localTmuxServer)) !== JSON.stringify(tmuxServer)) {
|
||||
throw new Error("The daemon's tmux server changed while opening the window; refresh the preview before retrying.");
|
||||
}
|
||||
({ composed, planId, remote } = next.data);
|
||||
({ composed, planId } = next.data);
|
||||
if (!composed.opened.length) throw new Error("No conversations are attachable after opening the terminal.");
|
||||
}
|
||||
if (composed.kernelLayout) windowNotes.push(viewportColumns === undefined
|
||||
@@ -542,7 +540,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
windowAttempted = true;
|
||||
window = await launchWindow(`env -u TMUX ${tmuxCommand} attach-session -t ${shellQuote(`=${viewer}`)}`);
|
||||
}
|
||||
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), ...(Array.isArray(remote) ? { remote } : {}), absent: composed.absent, degraded: composed.degraded, pages: composed.pages.length, window, notes: [`Viewing session: ${viewer}. Existing conversations were preserved.`, ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
|
||||
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), absent: composed.absent, degraded: composed.degraded, pages: composed.pages.length, window, notes: [`Viewing session: ${viewer}. Existing conversations were preserved.`, ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
|
||||
} catch (err) {
|
||||
if (windowAttempted && /-1743\b/.test(`${(err as Error).message} ${(err as { stderr?: string }).stderr ?? ""}`)) {
|
||||
windowNotes.push("macOS denied Automation access. In System Settings > Privacy & Security > Automation, allow the calling app to control the terminal app before trying again.");
|
||||
|
||||
@@ -116,9 +116,7 @@ describe("DaemonClient response body deadline", () => {
|
||||
it("uses the per-request deadline through the complete body", async () => {
|
||||
const endpoint = await slowBodyServer(100);
|
||||
const client = new DaemonClient(endpoint.url, { timeoutMs: 20 });
|
||||
const response = await client.get("/read", { timeoutMs: 1_000 });
|
||||
expect(response).toEqual({ status: 200, data: { ok: true }, headers: expect.any(Headers) });
|
||||
expect(response.headers?.get("content-type")).toBe("application/json");
|
||||
await expect(client.get("/read", { timeoutMs: 1_000 })).resolves.toEqual({ status: 200, data: { ok: true } });
|
||||
expect(endpoint.requests()).toBe(1);
|
||||
expect(endpoint.closedBeforeEnd()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -149,9 +149,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals; the daemon store
|
||||
// resolves it, so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
@@ -216,14 +213,10 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
"retention.usage_samples_days", // how long usage samples are kept
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.stream_days", // slice 22 outcome 9: stream retention
|
||||
"retention.stream_max_items",
|
||||
"retention.events_days", // the history-only event types of events
|
||||
"retention.closed_content_days", // closed-work erasure: content
|
||||
"retention.closed_metadata_days", // closed-work erasure: the row, keeping its id
|
||||
"retention.erasure_ledger", // queue erasure's replay ledger file
|
||||
"stream.intake",
|
||||
"retention.batch_size",
|
||||
"queue.pickup_stall_threshold_minutes",
|
||||
@@ -891,44 +884,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
// Both keys below were resolved by the daemon store and served by `GET /api/config`,
|
||||
// while `rig config get|set` rejected them as "Unknown config key". Their siblings in
|
||||
// the same families — every other ui.* and retention.* key — were already settable, so
|
||||
// this was drift between the two lockstep lists, not a deliberate CLI-only subset.
|
||||
it("ui.terminal.max_live_terminals is settable, numeric and a positive integer", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("ui.terminal.max_live_terminals", "3");
|
||||
expect(store.get("ui.terminal.max_live_terminals")).toBe(3);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf-8")).ui.terminal.maxLiveTerminals).toBe(3);
|
||||
for (const raw of ["0", "-1", "2.5", "2abc"]) {
|
||||
expect(() => store.set("ui.terminal.max_live_terminals", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("retention.usage_samples_days defaults to 14 and keeps an integer >= 1", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("retention.usage_samples_days"))
|
||||
.toMatchObject({ value: 14, source: "default", defaultValue: 14 });
|
||||
store.set("retention.usage_samples_days", "30");
|
||||
expect(store.get("retention.usage_samples_days")).toBe(30);
|
||||
for (const raw of ["0", "-1", "1.5"]) {
|
||||
expect(() => store.set("retention.usage_samples_days", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("both recovered keys honour their OPENRIG_* environment override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS = "7";
|
||||
process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS = "9";
|
||||
try {
|
||||
expect(store.resolveWithSource("ui.terminal.max_live_terminals")).toMatchObject({ value: 7, source: "env" });
|
||||
expect(store.resolveWithSource("retention.usage_samples_days")).toMatchObject({ value: 9, source: "env" });
|
||||
} finally {
|
||||
delete process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS;
|
||||
delete process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS;
|
||||
}
|
||||
});
|
||||
|
||||
// Constraint gap (Slice 27 residue): daemon.port had no KEY_CONSTRAINTS
|
||||
// entry, so `rig config set daemon.port 99999` stored 99999 and the next
|
||||
// daemon start died with a raw ERR_SOCKET_BAD_PORT from serve(). The
|
||||
@@ -1021,24 +976,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(cfg.ui.preview.defaultLines).toBe(50);
|
||||
});
|
||||
|
||||
it("RiggedConfig carries both recovered settings, saved values included", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const defaults = store.resolve();
|
||||
expect(defaults.ui.terminal.maxLiveTerminals).toBe(2);
|
||||
expect(defaults.retention.usageSamplesDays).toBe(14);
|
||||
store.set("ui.terminal.max_live_terminals", "5");
|
||||
store.set("retention.usage_samples_days", "21");
|
||||
const saved = store.resolve();
|
||||
expect(saved.ui.terminal.maxLiveTerminals).toBe(5);
|
||||
expect(saved.retention.usageSamplesDays).toBe(21);
|
||||
// `rig config --json` prints exactly this object, so a saved value reaches the
|
||||
// structured output and not only `rig config get`.
|
||||
expect(JSON.parse(JSON.stringify(saved))).toMatchObject({
|
||||
ui: { terminal: { maxLiveTerminals: 5 } },
|
||||
retention: { usageSamplesDays: 21 },
|
||||
});
|
||||
});
|
||||
|
||||
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { contextCommand } from "../src/commands/context.js";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
import { resolveAuthoredRecapPointer } from "../../daemon/src/domain/context-packs/seat-recap-store.js";
|
||||
|
||||
/** A running daemon on `port`, as the CLI's status check sees it. */
|
||||
function lifecycleDeps(port: number): LifecycleDeps {
|
||||
@@ -35,13 +34,6 @@ function lifecycleDeps(port: number): LifecycleDeps {
|
||||
|
||||
const daemonDeps = (port: number): StatusDeps => ({ lifecycleDeps: lifecycleDeps(port), clientFactory: (url) => new DaemonClient(url) });
|
||||
|
||||
/** No daemon state file and nothing answering: the CLI reads the daemon as stopped. */
|
||||
const noDaemon: StatusDeps = {
|
||||
lifecycleDeps: { ...lifecycleDeps(0), readFile: vi.fn(() => null), exists: vi.fn(() => false), isProcessAlive: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("connect ECONNREFUSED"); }) },
|
||||
clientFactory: () => { throw new Error("no daemon in this test"); },
|
||||
};
|
||||
|
||||
async function runRecapWrite(argv: string[], deps?: StatusDeps): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
@@ -55,8 +47,7 @@ async function runRecapWrite(argv: string[], deps?: StatusDeps): Promise<{ logs:
|
||||
try {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
// No test reaches a real daemon: without deps, the daemon reads as stopped.
|
||||
program.addCommand(contextCommand(deps ?? noDaemon));
|
||||
program.addCommand(contextCommand(deps));
|
||||
await program.parseAsync(["node", "rig", "context", ...argv]);
|
||||
} catch { /* commander exitOverride */ } finally {
|
||||
exitCode = process.exitCode;
|
||||
@@ -240,9 +231,7 @@ describe.skipIf(process.getuid?.() === 0)("rig context recap-write when this pro
|
||||
const res = await runRecapWrite(["recap-write", "--rig", "r1", "--seat", "s1", "--file", recap], daemonDeps(port));
|
||||
|
||||
expect(res.exitCode ?? 0).toBe(0);
|
||||
// The named seat is looked up first (reads only); the one write is the daemon's recap write.
|
||||
expect(received.filter((request) => request.method !== "POST").every((request) => request.method === "GET")).toBe(true);
|
||||
expect(received.filter((request) => request.method === "POST")).toEqual([{ method: "POST", url: "/api/context-packs/seat-recap",
|
||||
expect(received).toEqual([{ method: "POST", url: "/api/context-packs/seat-recap",
|
||||
body: { rig: "r1", seat: "s1", content: "## Recent Decisions\nchose the daemon because the seat can't write here" } }]);
|
||||
expect(res.logs.join("\n")).toBe(`Recap written: /daemon/topology/rigs/r1/seats/s1/RECAP.md (1 superseded predecessor retained), by the daemon at http://127.0.0.1:${port}`);
|
||||
expect(res.errLogs.join("\n")).toMatch(/can't write the topology tree \(EACCES.*\), so the daemon writes the recap instead/);
|
||||
@@ -309,121 +298,3 @@ describe.skipIf(process.getuid?.() === 0)("rig context recap-write when this pro
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Every recap reader uses seats/<the part of the session name before @>, so a recap written under a member id or a
|
||||
// logical id was left where nothing reads it (one seat had lead, editorial.lead and editorial-lead). A name maps only by
|
||||
// the rig's recorded seats, never by its spelling: a suffix or a dot isn't identity.
|
||||
describe("rig context recap-write — the seat folder every reader uses", () => {
|
||||
/** The rig's seats as the daemon records them (pod.member logical ids; custom.name is a self-attached session). */
|
||||
const recorded = [
|
||||
{ logicalId: "editorial.lead", podNamespace: "editorial", canonicalSessionName: "editorial-lead@web-studio" },
|
||||
{ logicalId: "ux.designer", podNamespace: "ux", canonicalSessionName: "ux-designer@web-studio" },
|
||||
{ logicalId: "research.dev-qa", podNamespace: "research", canonicalSessionName: "research-dev-qa@web-studio" },
|
||||
{ logicalId: "dev.qa", podNamespace: "dev", canonicalSessionName: "dev-qa@web-studio" },
|
||||
{ logicalId: "custom", podNamespace: null, canonicalSessionName: "custom.name@web-studio" },
|
||||
];
|
||||
const withSeats: StatusDeps = { lifecycleDeps: lifecycleDeps(4242), clientFactory: (() => ({
|
||||
get: async (path: string) => path === "/api/ps" ? { status: 200, data: [{ rigId: "rig-1", name: "web-studio" }] }
|
||||
: path === "/api/rigs/rig-1/nodes" ? { status: 200, data: recorded } : { status: 404, data: {} },
|
||||
})) as never };
|
||||
|
||||
async function inTopology(session: string | undefined, run: (tmp: string, seats: string, recap: string) => Promise<void>) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "recap-seat-"));
|
||||
const saved = { root: process.env["OPENRIG_TOPOLOGY_ROOT"], session: process.env["OPENRIG_SESSION_NAME"], legacy: process.env["RIGGED_SESSION_NAME"] };
|
||||
try {
|
||||
process.env["OPENRIG_TOPOLOGY_ROOT"] = join(tmp, "topology");
|
||||
if (session === undefined) delete process.env["OPENRIG_SESSION_NAME"]; else process.env["OPENRIG_SESSION_NAME"] = session;
|
||||
delete process.env["RIGGED_SESSION_NAME"];
|
||||
const seats = join(tmp, "topology", "rigs", "web-studio", "seats");
|
||||
for (const folder of ["editorial-lead", "ux-designer", "research-dev-qa", "dev-qa", "custom.name"]) mkdirSync(join(seats, folder), { recursive: true });
|
||||
const recap = join(tmp, "recap.md");
|
||||
writeFileSync(recap, "## Recent Decisions\nchose X because Y");
|
||||
await run(tmp, seats, recap);
|
||||
} finally {
|
||||
for (const [key, value] of [["OPENRIG_TOPOLOGY_ROOT", saved.root], ["OPENRIG_SESSION_NAME", saved.session], ["RIGGED_SESSION_NAME", saved.legacy]] as const) {
|
||||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||
}
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const folders = (seats: string) => readdirSync(seats).sort();
|
||||
const standard = ["custom.name", "dev-qa", "editorial-lead", "research-dev-qa", "ux-designer"];
|
||||
|
||||
it.each([
|
||||
{ name: "no --rig or --seat: this seat", args: [] as string[], note: undefined },
|
||||
{ name: "this seat's member id", args: ["--seat", "lead"], note: "--seat lead is the member id of editorial-lead@web-studio, so the recap goes to seats/editorial-lead" },
|
||||
{ name: "this seat's logical id", args: ["--seat", "editorial.lead"], note: "--seat editorial.lead is the logical id of editorial-lead@web-studio" },
|
||||
{ name: "the folder itself", args: ["--rig", "web-studio", "--seat", "editorial-lead"], note: undefined },
|
||||
])("a seat writing its own recap lands in seats/editorial-lead ($name)", async ({ args, note }) => {
|
||||
await inTopology("editorial-lead@web-studio", async (tmp, seats, recap) => {
|
||||
const res = await runRecapWrite(["recap-write", ...args, "--file", recap], withSeats);
|
||||
expect(res.exitCode ?? 0).toBe(0);
|
||||
expect(readFileSync(join(seats, "editorial-lead", "RECAP.md"), "utf-8")).toContain("chose X because Y");
|
||||
// The handover's own reader finds it, from the session name alone.
|
||||
expect(resolveAuthoredRecapPointer("editorial-lead@web-studio", join(tmp, "topology"))).toMatchObject({ address: "seat:RECAP.md" });
|
||||
expect(folders(seats)).toEqual(standard);
|
||||
if (note) expect(res.errLogs.join("\n")).toContain(note);
|
||||
else expect(res.errLogs.join("\n")).not.toContain("Note:");
|
||||
});
|
||||
});
|
||||
|
||||
it("another seat's exact folder is never mistaken for the caller, even when the caller's folder ends with it", async () => {
|
||||
await inTopology("research-dev-qa@web-studio", async (_tmp, seats, recap) => {
|
||||
const res = await runRecapWrite(["recap-write", "--seat", "dev-qa", "--file", recap], withSeats);
|
||||
expect(res.exitCode ?? 0).toBe(0);
|
||||
expect(existsSync(join(seats, "dev-qa", "RECAP.md"))).toBe(true);
|
||||
expect(existsSync(join(seats, "research-dev-qa", "RECAP.md"))).toBe(false);
|
||||
expect(res.errLogs.join("\n")).not.toContain("Note:");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["custom.name@web-studio", "editorial-lead@web-studio"])("a dotted seat folder is used as given, not rewritten to a hyphen (caller %s)", async (session) => {
|
||||
await inTopology(session, async (_tmp, seats, recap) => {
|
||||
const res = await runRecapWrite(["recap-write", "--seat", "custom.name", "--file", recap], withSeats);
|
||||
expect(res.exitCode ?? 0).toBe(0);
|
||||
expect(existsSync(join(seats, "custom.name", "RECAP.md"))).toBe(true);
|
||||
expect(folders(seats)).toEqual(standard);
|
||||
});
|
||||
});
|
||||
|
||||
it("another seat's recorded logical or member id maps to its folder; a name no seat has still writes, and says so", async () => {
|
||||
await inTopology("editorial-lead@web-studio", async (_tmp, seats, recap) => {
|
||||
const logical = await runRecapWrite(["recap-write", "--seat", "ux.designer", "--file", recap], withSeats);
|
||||
expect(existsSync(join(seats, "ux-designer", "RECAP.md"))).toBe(true);
|
||||
expect(logical.errLogs.join("\n")).toContain("--seat ux.designer is the logical id of ux-designer@web-studio");
|
||||
const member = await runRecapWrite(["recap-write", "--seat", "designer", "--file", recap], withSeats);
|
||||
expect(member.errLogs.join("\n")).toContain("--seat designer is the member id of ux-designer@web-studio");
|
||||
expect(folders(seats)).toEqual(standard);
|
||||
|
||||
const unknown = await runRecapWrite(["recap-write", "--seat", "nobody", "--file", recap], withSeats);
|
||||
expect(unknown.exitCode ?? 0).toBe(0);
|
||||
expect(existsSync(join(seats, "nobody", "RECAP.md"))).toBe(true);
|
||||
expect(unknown.errLogs.join("\n")).toContain("seats/nobody didn't exist in rig web-studio, so this write created it.");
|
||||
});
|
||||
});
|
||||
|
||||
it("when the daemon can't be asked, a name is written as given and the note says it wasn't checked", async () => {
|
||||
await inTopology("editorial-lead@web-studio", async (_tmp, seats, recap) => {
|
||||
const res = await runRecapWrite(["recap-write", "--seat", "lead", "--file", recap]);
|
||||
expect(res.exitCode ?? 0).toBe(0);
|
||||
expect(existsSync(join(seats, "lead", "RECAP.md"))).toBe(true);
|
||||
expect(res.errLogs.join("\n")).toContain("the name couldn't be checked against the rig's seats");
|
||||
// An existing folder, such as an old alias, is no proof of identity either, so the note is there too.
|
||||
const existing = await runRecapWrite(["recap-write", "--seat", "lead", "--file", recap]);
|
||||
expect(existing.exitCode ?? 0).toBe(0);
|
||||
expect(existing.errLogs.join("\n")).toContain("The recap went to the existing seats/lead as given, but the name couldn't be checked against the rig's seats");
|
||||
});
|
||||
});
|
||||
|
||||
it("without OPENRIG_SESSION_NAME, --rig and --seat are named, not guessed", async () => {
|
||||
await inTopology(undefined, async (_tmp, seats, recap) => {
|
||||
const noRig = await runRecapWrite(["recap-write", "--file", recap]);
|
||||
expect(noRig.exitCode).toBe(1);
|
||||
expect(noRig.errLogs.join("\n")).toContain("Name the rig with --rig");
|
||||
const noSeat = await runRecapWrite(["recap-write", "--rig", "web-studio", "--file", recap]);
|
||||
expect(noSeat.exitCode).toBe(1);
|
||||
expect(noSeat.errLogs.join("\n")).toContain("Name the seat with --seat");
|
||||
expect(folders(seats)).toEqual(standard);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,10 +156,8 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.command).toBe("ssh");
|
||||
// D13 supersession: the remote command now runs under `sh -lc` (login-shell PATH
|
||||
// resolution) — the quoted argv rides inside one further quoting layer.
|
||||
// SEP-1: `--` ends ssh's own option parsing before the target.
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"--",
|
||||
"vm-test.local",
|
||||
"sh -lc ''\\''rig'\\'' '\\''send'\\'' '\\''dev-impl@rig'\\'' '\\''hello world'\\'' '\\''--verify'\\'''",
|
||||
]);
|
||||
@@ -204,29 +202,6 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("SEP-1 — `--` separates the options from the target, whatever shape the target has", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand({ id: "vm-test", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
const separator = args.indexOf("--");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
// The whole target reaches ssh as one argument after the separator, so an option-shaped
|
||||
// destination is a host ssh rejects rather than an option it obeys.
|
||||
expect(args[separator + 1]).toBe("-oProxyCommand=touch /tmp/nothing");
|
||||
// Everything we pass as an option stays before it.
|
||||
expect(separator).toBeGreaterThan(args.indexOf("-o"));
|
||||
expect(separator).toBeLessThan(args.length - 1);
|
||||
});
|
||||
|
||||
it("SEP-1 — the separator sits after -l <user> too", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
expect(args.slice(0, args.indexOf("--"))).toEqual(["-l", "ops", "-o", "ConnectTimeout=10"]);
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
|
||||
@@ -289,12 +289,6 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
|
||||
});
|
||||
|
||||
it("an error other than ESRCH is never evidence that the process is gone", () => {
|
||||
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
|
||||
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
|
||||
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
|
||||
});
|
||||
|
||||
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
@@ -310,16 +304,14 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(guard.fact).not.toMatch(/not running/i);
|
||||
});
|
||||
|
||||
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
|
||||
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
|
||||
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
processLiveness: sandboxLiveness,
|
||||
fetch: probeFailing("ECONNREFUSED"),
|
||||
}));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
|
||||
expect(status.state).toBe("stopped");
|
||||
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a healthy probe is running", async () => {
|
||||
|
||||
@@ -5,8 +5,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { startDaemon, getDaemonStatus, STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { acquireDaemonStartLock, currentBootId } from "../src/daemon-start-lock.js";
|
||||
import { createProcessLiveness, readProcessState } from "../src/commands/daemon.js";
|
||||
import { acquireDaemonStartLock } from "../src/daemon-start-lock.js";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
function fixture() {
|
||||
@@ -288,116 +287,6 @@ describe("local startup reservation", () => {
|
||||
expect(fs.existsSync(file)).toBe(true); third.release();
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
describe("a reservation from before this boot", () => {
|
||||
const startedAt = "2026-11-10T20:05:59.662Z", bootedAtMs = Date.parse("2026-11-10T20:46:15Z");
|
||||
const dead = () => "dead" as const, alive = () => "alive" as const;
|
||||
const leftover = (home: string, at = startedAt, bootId?: string) => {
|
||||
const file = path.join(home, "daemon-start.lock");
|
||||
fs.writeFileSync(file, JSON.stringify({ launcherPid: 35905, startedAt: at, ...(bootId ? { bootId } : {}) }) + "\n" + JSON.stringify({ childPid: 36449 }) + "\n");
|
||||
const written = new Date(Date.parse(at) + 1000);
|
||||
fs.utimesSync(file, written, written);
|
||||
return file;
|
||||
};
|
||||
const archiveOf = (file: string, stamp: string) => `${file}.abandoned-${stamp}-${fs.statSync(file).ino}`;
|
||||
const withHome = (body: (home: string) => void) => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-start-lock-"));
|
||||
try { body(home); } finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
};
|
||||
it("is archived and the launch goes on when every recorded PID is dead", () => withHome((home) => {
|
||||
const file = leftover(home), archive = archiveOf(file, "20261110T200559Z"), liveness = vi.fn(dead);
|
||||
const lock = acquireDaemonStartLock(home, { bootedAtMs, processLiveness: liveness });
|
||||
expect(lock.archived).toBe(archive);
|
||||
expect(fs.readFileSync(archive, "utf8")).toContain('"childPid":36449');
|
||||
expect(liveness).toHaveBeenCalledWith(35905); expect(liveness).toHaveBeenCalledWith(36449);
|
||||
expect(fs.readFileSync(file, "utf8")).toContain(`"launcherPid":${process.pid}`);
|
||||
lock.release();
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
}));
|
||||
it("stays when a recorded PID is alive, in case the clock moved after boot", () => withHome((home) => {
|
||||
const file = leftover(home);
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: (pid) => pid === 36449 ? "alive" : "dead" })).toThrow(/already reserved/);
|
||||
expect(fs.readFileSync(file, "utf8")).toContain('"childPid":36449');
|
||||
}));
|
||||
it("stays when a recorded PID can't be inspected (EPERM)", () => withHome((home) => {
|
||||
const file = leftover(home);
|
||||
const processLiveness = createProcessLiveness({ signal: () => "not-permitted", readProcessState: () => "S" });
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness })).toThrow(/already reserved/);
|
||||
expect(fs.readdirSync(home)).toEqual(["daemon-start.lock"]);
|
||||
expect(fs.readFileSync(file, "utf8")).toContain('"launcherPid":35905');
|
||||
}));
|
||||
it("stays when ps can't run for a recorded PID", () => withHome((home) => {
|
||||
leftover(home);
|
||||
const processLiveness = createProcessLiveness({
|
||||
signal: () => "sent",
|
||||
readProcessState: (pid) => readProcessState(pid, "darwin", () => { throw new Error("ps: not permitted"); }),
|
||||
});
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness })).toThrow(/already reserved/);
|
||||
expect(fs.readdirSync(home)).toEqual(["daemon-start.lock"]);
|
||||
}));
|
||||
it("stays when it was written during this boot", () => withHome((home) => {
|
||||
leftover(home, "2026-11-10T20:50:00.000Z");
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
|
||||
}));
|
||||
it("stays when it was modified during this boot", () => withHome((home) => {
|
||||
const file = leftover(home), later = new Date(bootedAtMs + 60_000);
|
||||
fs.utimesSync(file, later, later);
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
|
||||
}));
|
||||
it("stays when it can't be read as a reservation", () => withHome((home) => {
|
||||
const file = path.join(home, "daemon-start.lock");
|
||||
fs.writeFileSync(file, "not json\n");
|
||||
fs.utimesSync(file, new Date(bootedAtMs - 60_000), new Date(bootedAtMs - 60_000));
|
||||
expect(() => acquireDaemonStartLock(home, { bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("not json\n");
|
||||
}));
|
||||
it("is archived when it records an earlier boot's ID, even if a recorded PID is now someone else's", () => withHome((home) => {
|
||||
const file = leftover(home, "2026-11-10T20:50:00.000Z", "boot-a"), archive = archiveOf(file, "20261110T205000Z");
|
||||
const lock = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: alive });
|
||||
expect(lock.archived).toBe(archive);
|
||||
expect(fs.readFileSync(file, "utf8")).toContain('"bootId":"boot-b"');
|
||||
lock.release();
|
||||
}));
|
||||
it("stays when it records this boot's ID, whatever its times and PIDs", () => withHome((home) => {
|
||||
leftover(home, startedAt, "boot-b");
|
||||
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead })).toThrow(/already reserved/);
|
||||
}));
|
||||
it("without a boot ID of its own, falls back to the boot time and PID checks", () => withHome((home) => {
|
||||
const file = leftover(home), archive = archiveOf(file, "20261110T200559Z");
|
||||
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: () => "unknown" })).toThrow(/already reserved/);
|
||||
const lock = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead });
|
||||
expect(lock.archived).toBe(archive);
|
||||
lock.release();
|
||||
}));
|
||||
it("has one winner when another launch archives it and takes the lock between this one's inspection and its claim", () => withHome((home) => {
|
||||
const file = leftover(home, startedAt, "boot-a"), archive = archiveOf(file, "20261110T200559Z");
|
||||
let b: ReturnType<typeof acquireDaemonStartLock> | undefined;
|
||||
const afterInspect = () => { b = acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead }); };
|
||||
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead, afterInspect })).toThrow(/already reserved/);
|
||||
expect(b?.archived).toBe(archive);
|
||||
expect(fs.readFileSync(archive, "utf8")).toContain('"launcherPid":35905'); // the archive still holds the abandoned reservation
|
||||
const current = fs.readFileSync(file, "utf8");
|
||||
expect(current).toContain(`"launcherPid":${process.pid}`); // B's fresh lock is still in place, not moved by A
|
||||
expect(fs.readdirSync(home).sort()).toEqual([path.basename(archive), "daemon-start.lock"].sort());
|
||||
b!.release();
|
||||
expect(fs.existsSync(file)).toBe(false); // B still owns its file
|
||||
}));
|
||||
it("is not moved when the path holds a different file than the one inspected", () => withHome((home) => {
|
||||
const file = leftover(home, startedAt, "boot-a");
|
||||
const afterInspect = () => { fs.renameSync(file, file + ".moved"); fs.writeFileSync(file, '{"launcherPid":1,"startedAt":"2026-11-10T21:00:00.000Z","bootId":"boot-b"}\n'); };
|
||||
expect(() => acquireDaemonStartLock(home, { bootId: "boot-b", bootedAtMs, processLiveness: dead, afterInspect })).toThrow(/already reserved/);
|
||||
expect(fs.readFileSync(file, "utf8")).toContain('"launcherPid":1');
|
||||
expect(fs.readdirSync(home).sort()).toEqual(["daemon-start.lock", "daemon-start.lock.moved"]);
|
||||
}));
|
||||
it.runIf(process.platform === "darwin" || process.platform === "linux")("reads this boot's ID from the OS", () => {
|
||||
const id = currentBootId();
|
||||
expect(id).toMatch(/^[0-9a-f-]{32,36}$/i);
|
||||
expect(currentBootId()).toBe(id);
|
||||
});
|
||||
it("stays without the boot check, as before", () => withHome((home) => {
|
||||
leftover(home);
|
||||
expect(() => acquireDaemonStartLock(home)).toThrow(/already reserved/);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { waitForKernelReady, type KernelReadyResult } from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { checkKernelAttention } from "../src/seat-attention.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async (original) => {
|
||||
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
|
||||
@@ -15,22 +14,14 @@ vi.mock("../src/daemon-lifecycle.js", async (original) => {
|
||||
waitForKernelReady: vi.fn(),
|
||||
};
|
||||
});
|
||||
// The held-seat read goes to the daemon this start reports; never a real one from a test.
|
||||
vi.mock("../src/seat-attention.js", async (original) => ({
|
||||
...await original<typeof import("../src/seat-attention.js")>(),
|
||||
checkKernelAttention: vi.fn(async () => ({ attention: [], unknown: [] })),
|
||||
}));
|
||||
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
|
||||
let home: string;
|
||||
let errors: string[];
|
||||
let logs: string[];
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "daemon-start-wait-for-kernel-"));
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
errors = [];
|
||||
logs = [];
|
||||
vi.mocked(checkKernelAttention).mockResolvedValue({ attention: [], unknown: [] });
|
||||
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { logs.push(args.join(" ")); });
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.join(" ")); });
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
|
||||
@@ -66,19 +57,3 @@ it("a kernel still booting at the deadline keeps the timeout and its fix", async
|
||||
expect(out).toContain("Fix: inspect `rig ps --rig kernel` for stalled agents");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a partial_ready kernel names the seat that needs attention and why, and any it couldn't check (D#52)", async () => {
|
||||
vi.mocked(checkKernelAttention).mockResolvedValue({
|
||||
attention: [{ seat: "advisor-lead@kernel", reason: "Startup requires attention: Login expired · Please run /login", waitingOnPerson: false }],
|
||||
unknown: [{ seat: "queue-worker@kernel", why: "not in kernel's seats on this daemon" }] });
|
||||
await startWaiting({ ok: true, kernelState: "partial_ready", variant: "claude-only" });
|
||||
expect(logs).toContain("Kernel partial_ready; variant=claude-only");
|
||||
expect(logs).toContain(" advisor-lead@kernel needs attention: Startup requires attention: Login expired · Please run /login. Look at it with: rig capture advisor-lead@kernel");
|
||||
expect(logs).toContain(" Couldn't check whether queue-worker@kernel needs attention: not in kernel's seats on this daemon.");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a ready kernel asks nothing more of the daemon", async () => {
|
||||
await startWaiting({ ok: true, kernelState: "ready", variant: "claude-only" });
|
||||
expect(checkKernelAttention).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
|
||||
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
|
||||
//
|
||||
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
|
||||
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
|
||||
// couldn't connect from this shell and the daemon may still be running, and never advises starting
|
||||
// another daemon.
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
daemonStatusGuard,
|
||||
getDaemonStatus,
|
||||
statusGuardMessage,
|
||||
STATE_FILE,
|
||||
type DaemonState,
|
||||
type LifecycleDeps,
|
||||
} from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
|
||||
|
||||
const INITIAL_NAMESPACE = "pid:[4026531836]";
|
||||
const DAEMON_NAMESPACE = "pid:[4026532001]";
|
||||
const SANDBOX_NAMESPACE = "pid:[4026532777]";
|
||||
const START = "2026-01-01T00:00:00Z";
|
||||
|
||||
const REFUSED: LifecycleDeps["fetch"] = async () => {
|
||||
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
|
||||
err.cause = { code: "ECONNREFUSED" };
|
||||
throw err;
|
||||
};
|
||||
|
||||
function cleanReceipt(pid: number): string {
|
||||
const now = new Date().toISOString();
|
||||
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
|
||||
}
|
||||
|
||||
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
|
||||
fetch: vi.fn(REFUSED),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 0),
|
||||
isProcessAlive: vi.fn(() => false),
|
||||
processLiveness: vi.fn(() => "dead" as const),
|
||||
pidNamespace: () => SANDBOX_NAMESPACE,
|
||||
sleep: async () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
|
||||
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
|
||||
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
const message = statusGuardMessage(status);
|
||||
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
|
||||
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
|
||||
expect(message.action).toMatch(/don't start another daemon/i);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks commands through the guard with the same message", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
|
||||
const savedExit = process.exitCode;
|
||||
try {
|
||||
expect(daemonStatusGuard(status)).toBe(false);
|
||||
} finally { process.exitCode = savedExit; }
|
||||
const message = statusGuardMessage(status);
|
||||
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
|
||||
it("is the same on an older record that names no namespace", async () => {
|
||||
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when this shell cannot read its own namespace", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not remove state it cannot prove stale", async () => {
|
||||
const d = deps(recorded());
|
||||
await getDaemonStatus(d);
|
||||
expect(d.removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: the real stopped cases keep reading as down", () => {
|
||||
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
|
||||
const d = deps(recorded(), {}, cleanReceipt(4242));
|
||||
const status = await getDaemonStatus(d);
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
|
||||
});
|
||||
|
||||
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a receipt for another pid is no evidence", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
|
||||
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
|
||||
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
|
||||
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
|
||||
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
|
||||
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
|
||||
const fetch = vi.fn(REFUSED);
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("...and a healthy endpoint reads running, not stale", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("deps without namespace evidence keep the old rule", async () => {
|
||||
const d = deps(recorded());
|
||||
delete d.pidNamespace;
|
||||
expect((await getDaemonStatus(d)).state).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
|
||||
const withUrl = async (url: string, d: LifecycleDeps) => {
|
||||
process.env.OPENRIG_URL = url;
|
||||
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
|
||||
};
|
||||
const URL_OF_DAEMON = "http://127.0.0.1:7433";
|
||||
|
||||
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
|
||||
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
expect(daemonMayAnswer(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("a missing pid in the daemon's own namespace reads stopped", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a matching clean shutdown reads stopped from any shell", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
|
||||
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a remote URL keeps reading stopped on a refusal", async () => {
|
||||
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: `rig daemon status` says what the guard says", () => {
|
||||
it("prints the guard's three lines", async () => {
|
||||
const d = deps(recorded());
|
||||
const status = await getDaemonStatus(d);
|
||||
const message = statusGuardMessage(status);
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
|
||||
await daemonCommand(d).parseAsync(["status"], { from: "user" });
|
||||
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// The opt-in wake loop in the openrig-user skill, run exactly as shipped: extracted from the skill, executed by sh,
|
||||
// calling the built `rig` CLI against the daemon's real queue routes over HTTP. It must wake on new owned work,
|
||||
// survive failed polls, and end at its deadline.
|
||||
import { describe, it, type TestContext } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -30,48 +30,62 @@ function shippedLoop(): string {
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
async function fixture(onTestFinished: TestContext["onTestFinished"]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
let db: Database.Database | undefined;
|
||||
let server: ServerType | undefined;
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
} finally {
|
||||
try { db?.close(); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", () => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
});
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
});
|
||||
const state = { failLists: 0, failListsAfter: Infinity, listCalls: 0 };
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
state.listCalls++;
|
||||
if (state.failLists > 0 || state.listCalls > state.failListsAfter) {
|
||||
if (state.failLists > 0) state.failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
}
|
||||
return app.fetch(request);
|
||||
|
||||
let db: Database.Database;
|
||||
let server: ServerType;
|
||||
let url: string;
|
||||
let root: string;
|
||||
let failLists = 0;
|
||||
let failListsAfter = Infinity; // every list request after this many fails, as if the daemon stopped
|
||||
let listCalls = 0;
|
||||
let queue: QueueRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
failLists = 0;
|
||||
failListsAfter = Infinity;
|
||||
listCalls = 0;
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
listCalls++;
|
||||
if (failLists > 0 || listCalls > failListsAfter) {
|
||||
if (failLists > 0) failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
/** Run the shipped loop as a background command would: its own process, this seat's identity, short timings. */
|
||||
function runLoop(max: number, seat: string | null = DESK) {
|
||||
@@ -92,38 +106,26 @@ async function fixture(onTestFinished: TestContext["onTestFinished"]) {
|
||||
const assign = (destination: string) => queue.create({
|
||||
sourceSession: "lead@team", destinationSession: destination, body: "Summarise the failing test.", nudge: false,
|
||||
} as never);
|
||||
return { runLoop, assign, queue, state };
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", ({ expect }) => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
});
|
||||
|
||||
it("wakes with the new owned item once it is assigned", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
it("wakes with the new owned item once it is assigned", async () => {
|
||||
const waiting = runLoop(30);
|
||||
const timer = setTimeout(() => { assign(DESK); }, 2_000);
|
||||
onTestFinished(() => clearTimeout(timer));
|
||||
setTimeout(() => { assign(DESK); }, 2_000);
|
||||
const result = await waiting;
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: .*"qitemId"/m);
|
||||
expect(result.seconds).toBeLessThan(15);
|
||||
}, 40_000);
|
||||
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, state } = await fixture(onTestFinished);
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async () => {
|
||||
assign(DESK);
|
||||
state.failLists = 3;
|
||||
failLists = 3;
|
||||
const result = await runLoop(30);
|
||||
expect(result.code).toBe(0);
|
||||
expect(state.listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: /m);
|
||||
}, 40_000);
|
||||
|
||||
it("doesn't wake for owned work that is already claimed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, queue } = await fixture(onTestFinished);
|
||||
it("doesn't wake for owned work that is already claimed", async () => {
|
||||
const item = await assign(DESK);
|
||||
queue.claim({ qitemId: item.qitemId, destinationSession: DESK } as never);
|
||||
const result = await runLoop(4);
|
||||
@@ -132,8 +134,7 @@ describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wak
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
}, 40_000);
|
||||
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async () => {
|
||||
assign(DESK);
|
||||
const result = await runLoop(3, null); // no seat identity: every `rig queue list --owned` fails
|
||||
expect(result.code).not.toBe(0);
|
||||
@@ -142,26 +143,23 @@ describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wak
|
||||
expect(result.stdout).toContain("Couldn't check for work: every poll failed.");
|
||||
}, 40_000);
|
||||
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async () => {
|
||||
failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stdout).not.toContain("No new work");
|
||||
expect(result.stdout).toMatch(/Checking stopped working at \d{2}:\d{2}:\d{2}Z: every poll since then failed\./);
|
||||
}, 40_000);
|
||||
|
||||
it("stays quiet when a failed poll is followed by a successful one", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
it("stays quiet when a failed poll is followed by a successful one", async () => {
|
||||
failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
expect(result.stdout).not.toContain("Checking stopped working");
|
||||
}, 40_000);
|
||||
|
||||
it("ignores work assigned to another seat and ends at its deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
it("ignores work assigned to another seat and ends at its deadline", async () => {
|
||||
assign("impl@team");
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -78,8 +78,7 @@ describe("rig edge", () => {
|
||||
expect(result.out).toHaveLength(1);
|
||||
const error = JSON.parse(result.out[0]!).error;
|
||||
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
|
||||
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
|
||||
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -221,89 +221,6 @@ describe("rig gateway human lifecycle verbs (S12)", () => {
|
||||
if (loaded.ok) expect(loaded.entities[0]!.prefs.deliveryClass).toBe("D");
|
||||
});
|
||||
|
||||
it("set binding.<n> unset refuses primary removal when secondary bindings exist, then unsets secondary and sole primary with remainingBindings + note", async () => {
|
||||
const addRes = addHumanFragment(
|
||||
{
|
||||
entityId: "ana",
|
||||
class: "human",
|
||||
displayName: "Ana",
|
||||
address: "ana@external",
|
||||
connectorBindings: [
|
||||
{ kind: "slack", connectorRef: "main", secretsRef: "vault://slack/ana-secret", role: "primary", handle: "U0ANA" },
|
||||
{ kind: "slack", connectorRef: "backup", secretsRef: "vault://slack/ana-backup-secret", role: "secondary" },
|
||||
],
|
||||
prefs: { deliveryClass: "A" },
|
||||
},
|
||||
home,
|
||||
);
|
||||
expect(addRes.ok).toBe(true);
|
||||
|
||||
// 1. Refuse unsetting primary binding.0 while binding.1 remains
|
||||
errSpy.mockClear();
|
||||
const p1 = program();
|
||||
p1.exitOverride();
|
||||
await p1.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.0", "unset", "--reason", "try remove primary"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
const refusal = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(refusal).toContain("no replacement is chosen silently");
|
||||
expect(refusal).toContain('To replace the primary, set binding.0 to the new primary spec: rig gateway human set ana binding.0 "<kind:connectorRef:secretsRef:primary[:handle=<id>]>"');
|
||||
expect(refusal).toContain("To promote one of the other bindings (binding.1), first save that binding's full spec (kind:connectorRef:secretsRef:role[:handle=<id>]) from rig gateway human show ana --json, then unset that secondary slot, then find the primary's current slot in remainingBindings (or rig gateway human show ana --json, since unsetting a lower slot shifts the primary down) and set it to the saved spec with role primary (promotion replaces the former primary binding; its connector reference and inbound handle are not retained as a secondary).");
|
||||
expect(refusal).toContain("To remove every binding, unset the secondary bindings first using their current slot indexes from remainingBindings or rig gateway human show ana, then finish at binding.0 (once all secondaries are gone, the primary is at binding.0).");
|
||||
expect(refusal).not.toContain("--replace");
|
||||
|
||||
// 2. Unset secondary binding.1 -> succeeds, prints remainingBindings[0] = binding.0 and note without secrets
|
||||
process.exitCode = undefined;
|
||||
logSpy.mockClear();
|
||||
const p2 = program();
|
||||
p2.exitOverride();
|
||||
await p2.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.1", "unset", "--reason", "drop backup binding"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const out1Raw = logSpy.mock.calls.at(-1)![0] as string;
|
||||
expect(out1Raw).not.toContain("ana-secret");
|
||||
const out1 = JSON.parse(out1Raw) as {
|
||||
ok: boolean;
|
||||
entityId: string;
|
||||
field: string;
|
||||
receipt: { effect: string };
|
||||
remainingBindings: Array<Record<string, unknown>>;
|
||||
note: string;
|
||||
};
|
||||
expect(out1.ok).toBe(true);
|
||||
expect(out1.entityId).toBe("ana");
|
||||
expect(out1.field).toBe("binding.1");
|
||||
expect(out1.receipt.effect).toBe("applied");
|
||||
expect(out1.remainingBindings).toEqual([
|
||||
{ slot: "binding.0", kind: "slack", connectorRef: "main", role: "primary", handle: "U0ANA", inboundResolvable: true },
|
||||
]);
|
||||
expect(out1.note).toContain("Remaining binding(s): binding.0: slack:main role=primary handle=U0ANA");
|
||||
expect(out1.note).toContain("Binding slot indexes shift after removal");
|
||||
|
||||
// 3. Unset sole remaining primary binding.0 -> succeeds, leaves connectorBindings: []
|
||||
logSpy.mockClear();
|
||||
const p3 = program();
|
||||
p3.exitOverride();
|
||||
await p3.parseAsync(["node", "rig", "gateway", "human", "set", "ana", "binding.0", "unset", "--reason", "remove last binding"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const out2 = JSON.parse(logSpy.mock.calls.at(-1)![0] as string) as {
|
||||
ok: boolean;
|
||||
remainingBindings: unknown[];
|
||||
note: string;
|
||||
};
|
||||
expect(out2.ok).toBe(true);
|
||||
expect(out2.remainingBindings).toEqual([]);
|
||||
expect(out2.note).toContain("Remaining bindings: none");
|
||||
expect(out2.note).toContain("Removing the last binding makes the current delivery episode inactive, so new missing-post findings stop (an already-open recovery item for an undelivered notice may remain)");
|
||||
expect(out2.note).toContain("It does not answer, close or cancel open questions, cancel or reroute already-prepared deliveries, or retract anything a bridge has already accepted");
|
||||
|
||||
const loaded = loadHumanRegistry(home);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (loaded.ok) {
|
||||
const ana = loaded.entities.find((e) => e.entityId === "ana")!;
|
||||
expect(ana.connectorBindings).toEqual([]);
|
||||
expect(ana.prefs.deliveryClass).toBe("A");
|
||||
}
|
||||
});
|
||||
|
||||
it("set with a bad enum exits 1 and names the allowed set", async () => {
|
||||
const p = program();
|
||||
p.exitOverride();
|
||||
|
||||
@@ -100,26 +100,6 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
|
||||
expect(yaml).not.toContain("issued-bearer");
|
||||
}, 15_000);
|
||||
|
||||
it("a target that says who was asked and who reads the answer is named; one where no agent reads says why", async () => {
|
||||
const approved = [{ status: 200, body: { status: "approved", token: "issued-bearer" } }];
|
||||
const owned = fakeDeps({ post: { status: 200, body: { pairId: "p-1", code: "424242", approvalQitemId: "qitem-1", person: "alex@external", owner: "lead@rig" } }, poll: approved });
|
||||
const a = await capture(() => run(owned, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
|
||||
expect(a.out.join("\n")).toContain("The request went to alex@external on the target.\nlead@rig reads their answer and decides.");
|
||||
expect(a.out.join("\n")).not.toContain("On the target, its person answers the pairing request");
|
||||
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
|
||||
const free = fakeDeps({ post: { status: 200, body: { pairId: "p-2", code: "515151", approvalQitemId: "qitem-2", person: "alex@external", owner: null,
|
||||
noReader: "no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel)" } }, poll: approved });
|
||||
const b = await capture(() => run(free, ["pair", "vps-b:7433", "--id", "vps-b", "--timeout", "10"]));
|
||||
expect(b.out.join("\n")).toContain("On the target: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel). The request names how to decide directly on the target.");
|
||||
// A target that couldn't tell is printed as unknown, never as "nobody reads".
|
||||
fs.rmSync(path.join(dir, "hosts.yaml"), { force: true }); fs.rmSync(path.join(dir, "secrets"), { recursive: true, force: true });
|
||||
const unknown = fakeDeps({ post: { status: 200, body: { pairId: "p-3", code: "616161", approvalQitemId: "qitem-3", person: "alex@external", owner: null,
|
||||
noReader: "OpenRig couldn't tell whether an agent reads answers on this host" } }, poll: approved });
|
||||
const c = await capture(() => run(unknown, ["pair", "vps-c:7433", "--id", "vps-c", "--timeout", "10"]));
|
||||
expect(c.out.join("\n")).toContain("On the target: OpenRig couldn't tell whether an agent reads answers on this host. The request names how to decide directly on the target.");
|
||||
expect(c.out.join("\n")).not.toContain("No agent reads answers there");
|
||||
}, 15_000);
|
||||
|
||||
it("--timeout reaches the target, which keeps the request open that long", async () => {
|
||||
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "denied" } }] });
|
||||
await capture(() => run(deps, ["pair", "http://vps-a:7433", "--timeout", "3600"]));
|
||||
|
||||
@@ -1,144 +1,51 @@
|
||||
// `rig host list` / `rig host ls` status is tested at process and probe boundaries:
|
||||
// passive SSH rows must not invoke ssh -G or trigger a TCP probe.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
|
||||
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
|
||||
// `ssh -G`; no real ssh runs.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { hostCommand, type DoctorDeps } from "../src/commands/host.js";
|
||||
|
||||
const { execFileMock, connectMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
connectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...actual, execFile: execFileMock };
|
||||
});
|
||||
vi.mock("node:net", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:net")>();
|
||||
return { ...actual, connect: connectMock };
|
||||
});
|
||||
|
||||
interface ProbeCall {
|
||||
target: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
interface ExecFileCall {
|
||||
command: string;
|
||||
args: string[];
|
||||
timeout: number | undefined;
|
||||
}
|
||||
|
||||
let home: string;
|
||||
let savedHome: string | undefined;
|
||||
let execFileCalls: ExecFileCall[];
|
||||
let log: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function writeRegistry(hosts: unknown[]): void {
|
||||
writeFileSync(join(home, "hosts.yaml"), JSON.stringify({ hosts }));
|
||||
}
|
||||
|
||||
function deps(tcpProbe: DoctorDeps["tcpProbe"]): DoctorDeps {
|
||||
return {
|
||||
run: async () => { throw new Error("unexpected remote SSH command"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP request"); },
|
||||
tcpProbe,
|
||||
};
|
||||
}
|
||||
|
||||
async function listHosts(hosts: unknown[], tcpProbe: DoctorDeps["tcpProbe"], verb: "list" | "ls" = "ls") {
|
||||
writeRegistry(hosts);
|
||||
const program = new Command().exitOverride();
|
||||
program.addCommand(hostCommand(deps(tcpProbe)));
|
||||
await program.parseAsync(["node", "rig", "host", verb, "--json"]);
|
||||
const output = log.mock.calls.map((call) => String(call[0])).find((line) => line.trim().startsWith("["));
|
||||
if (output === undefined) throw new Error("host list did not print its JSON array");
|
||||
return JSON.parse(output) as Array<Record<string, unknown>>;
|
||||
}
|
||||
import { probeHostStatus } from "../src/commands/host.js";
|
||||
|
||||
let dir: string;
|
||||
let savedPath: string | undefined;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "host-list-ssh-inert-"));
|
||||
savedHome = process.env["OPENRIG_HOME"];
|
||||
process.env["OPENRIG_HOME"] = home;
|
||||
execFileCalls = [];
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeout?: number },
|
||||
callback: (error: Error | null, stdout: string) => void,
|
||||
) => {
|
||||
execFileCalls.push({ command, args: [...args], timeout: options.timeout });
|
||||
queueMicrotask(() => callback(null, ""));
|
||||
});
|
||||
connectMock.mockReset();
|
||||
connectMock.mockImplementation(() => { throw new Error("unexpected direct TCP socket"); });
|
||||
log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
|
||||
writeFileSync(join(dir, "ssh"), `#!/bin/sh
|
||||
case "$*" in
|
||||
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
|
||||
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
|
||||
*) exit 255 ;;
|
||||
esac
|
||||
`);
|
||||
chmodSync(join(dir, "ssh"), 0o755);
|
||||
savedPath = process.env["PATH"];
|
||||
process.env["PATH"] = `${dir}:${savedPath}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
if (savedHome === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = savedHome;
|
||||
process.env["PATH"] = savedPath;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("rig host list/ls passive status", () => {
|
||||
it.each(["list", "ls"] as const)("reports an SSH host unknown without launching a process or probing TCP through host %s", async (verb) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
describe("rig host ls status for an ssh host", () => {
|
||||
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
|
||||
dials.push(`${target}:${port}`);
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "devbox", transport: "ssh", target: "devbox", user: "ops" }], tcpProbe, verb);
|
||||
|
||||
expect({ status: rows[0]?.status, execFileCalls, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unknown",
|
||||
execFileCalls: [],
|
||||
probes: [],
|
||||
directSockets: [],
|
||||
});
|
||||
expect(status).toBe("reachable");
|
||||
expect(dials).toEqual(["10.1.2.3:2222"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "HTTP default port", url: "http://example.test", target: "example.test", port: 80 },
|
||||
{ label: "HTTP explicit port", url: "http://example.test:7433", target: "example.test", port: 7433 },
|
||||
{ label: "HTTPS default port", url: "https://example.test", target: "example.test", port: 443 },
|
||||
{ label: "bracketed IPv6 HTTPS", url: "https://[2001:db8::7]:9443", target: "2001:db8::7", port: 9443 },
|
||||
])("preserves CLI $label status probing through its injected seam", async ({ url, target, port }) => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (host, servicePort, timeoutMs) => {
|
||||
probes.push({ target: host, port: servicePort, timeoutMs });
|
||||
return "open";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, execFileCalls, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "reachable",
|
||||
probes: [{ target, port, timeoutMs: 1500 }],
|
||||
execFileCalls: [],
|
||||
directSockets: [],
|
||||
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
|
||||
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
|
||||
const dials: string[] = [];
|
||||
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
|
||||
dials.push(`${t}:${port}`);
|
||||
return "closed";
|
||||
});
|
||||
expect(status).toBe("unknown");
|
||||
expect(dials).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an HTTP closed-probe control to unreachable", async () => {
|
||||
const probes: ProbeCall[] = [];
|
||||
const tcpProbe: DoctorDeps["tcpProbe"] = async (target, port, timeoutMs) => {
|
||||
probes.push({ target, port, timeoutMs });
|
||||
return "closed";
|
||||
};
|
||||
|
||||
const rows = await listHosts([{ id: "web", transport: "http", url: "http://example.test:7433" }], tcpProbe);
|
||||
|
||||
expect({ status: rows[0]?.status, probes, directSockets: connectMock.mock.calls }).toEqual({
|
||||
status: "unreachable",
|
||||
probes: [{ target: "example.test", port: 7433, timeoutMs: 1500 }],
|
||||
directSockets: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,11 @@ import { configCommand } from "../src/commands/config.js";
|
||||
describe("rig host add/list", () => {
|
||||
let dir: string;
|
||||
let savedHome: string | undefined;
|
||||
let tcpProbeCalls: Array<{ target: string; port: number; timeoutMs: number }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
|
||||
savedHome = process.env.OPENRIG_HOME;
|
||||
process.env.OPENRIG_HOME = dir;
|
||||
tcpProbeCalls = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
|
||||
@@ -45,15 +43,7 @@ describe("rig host add/list", () => {
|
||||
function run(argv: string[]) {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(hostCommand({
|
||||
run: async () => { throw new Error("unexpected cross-host command in add/list test"); },
|
||||
httpGet: async () => { throw new Error("unexpected HTTP GET in add/list test"); },
|
||||
httpPost: async () => { throw new Error("unexpected HTTP POST in add/list test"); },
|
||||
tcpProbe: async (target, port, timeoutMs) => {
|
||||
tcpProbeCalls.push({ target, port, timeoutMs });
|
||||
return "unknown";
|
||||
},
|
||||
}));
|
||||
prog.addCommand(hostCommand());
|
||||
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
}
|
||||
|
||||
@@ -107,17 +97,11 @@ describe("rig host add/list", () => {
|
||||
const added = await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN", "--json"]));
|
||||
expect(JSON.parse(added.out.join("\n"))).toMatchObject({ ok: true, entry: { id: "f1" } });
|
||||
expect(added.err).toEqual([]);
|
||||
const beforeTextList = tcpProbeCalls.length;
|
||||
const { out } = await capture(() => run(["list"]));
|
||||
expect(tcpProbeCalls.length - beforeTextList).toBe(1);
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("env:FACTORY_TOKEN");
|
||||
expect(text).toContain("unknown");
|
||||
expect(text).not.toContain("super-secret-bearer-value");
|
||||
const beforeJsonList = tcpProbeCalls.length;
|
||||
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
|
||||
expect(tcpProbeCalls.length - beforeJsonList).toBe(1);
|
||||
expect(JSON.parse(jsonOut.join(""))).toMatchObject([{ id: "f1", status: "unknown" }]);
|
||||
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
|
||||
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
|
||||
});
|
||||
|
||||
Loaded 100 of 671 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user