mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71389bef06 | ||
|
|
ebf20ae616 | ||
|
|
4c14ca76c4 | ||
|
|
682bede406 | ||
|
|
db45c6bcb8 | ||
|
|
03a2828ce7 | ||
|
|
c57f0173ab | ||
|
|
72086329ae | ||
|
|
8684c981a6 | ||
|
|
e5c4cba385 | ||
|
|
10b5f155b3 | ||
|
|
5700c6c2ff | ||
|
|
d44cf3199a | ||
|
|
c2378fa315 | ||
|
|
884a5bfb6b | ||
|
|
9ba9abe858 | ||
|
|
2648083e96 | ||
|
|
76c0170ec8 | ||
|
|
0224e7710d | ||
|
|
8efb5a59c1 | ||
|
|
36f971fa2c | ||
|
|
b51500f024 | ||
|
|
4926be0c0c | ||
|
|
413504a029 | ||
|
|
a70bf71d5f | ||
|
|
3f17e625af | ||
|
|
470f55241e | ||
|
|
3874c743df | ||
|
|
b0bc678833 | ||
|
|
26a490938c | ||
|
|
afee489108 | ||
|
|
898b39ea5c | ||
|
|
b71a9288c0 | ||
|
|
6957751f5e | ||
|
|
ae59c4287a | ||
|
|
0c44b23ae9 | ||
|
|
e5f8a8eee7 | ||
|
|
84d6bbdbd9 | ||
|
|
4f1d031bab | ||
|
|
81a48ce6c6 | ||
|
|
c823dfedb4 | ||
|
|
30a3628b7d | ||
|
|
4ee8bdca0c | ||
|
|
a50e56e868 | ||
|
|
ef5117441f | ||
|
|
a17fdc5faa | ||
|
|
9110ab9dc9 | ||
|
|
a6f61d9a0a | ||
|
|
bd1158660a | ||
|
|
b9dd23db87 | ||
|
|
b8fe8f1e11 | ||
|
|
588ba54d9d | ||
|
|
d87e843fd2 | ||
|
|
a04bc1cf2f | ||
|
|
be52fa0da2 | ||
|
|
bf893d3f4d | ||
|
|
a3a48e171b | ||
|
|
de6b635c52 | ||
|
|
0e8bdd4488 | ||
|
|
db26369483 | ||
|
|
80616b7926 | ||
|
|
2fbe0ddb49 | ||
|
|
aaa681c28a | ||
|
|
9ca37f1ede | ||
|
|
a5e42d3d6b | ||
|
|
7fa0f386b1 | ||
|
|
e9e18e4e68 |
No files matched your search
+337
@@ -8,6 +8,343 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.4]
|
||||
|
||||
OpenRig 0.7.4 lets an assistant you already use work as a member of your team.
|
||||
An assistant connected over MCP can be listed on its rig's team as a hosted
|
||||
member, learns from the connection how to take, finish and hand on work, can
|
||||
ask a person while keeping the item, and can be rung by a webhook when work
|
||||
lands for it; when nothing can wake it, its person is told instead.
|
||||
`rig mcp serve --http` can check each remote caller's key with an external
|
||||
grant service, offer OAuth discovery, record whose grant acted, and serve MCP
|
||||
clients on protocol revision 2026-07-28 beside 2025's. You can list the
|
||||
conversations a seat could resume, and a client can resume the one a person
|
||||
reviewed. Daemon start now brings back only a kernel that this start found
|
||||
down. It is built from the commit tagged `v0.7.4`. The changes since 0.7.3:
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Restart the daemon after installing.** One database update applies on its
|
||||
first start (migration 140). It adds a column, empty on earlier rows, that
|
||||
records which grant's caller acted, to queue transitions and their archive,
|
||||
inbox and outbox entries, and stream items.
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.4
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **The bundled openrig-core plugin goes from 0.1.20 to 0.1.21.** The
|
||||
`queue-handoff` skill gains writing guidance, and the Claude hooks add
|
||||
`StopFailure`, which reports a stop caused by a rate limit. As before,
|
||||
startup first copies each file it replaces under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; if you edited one, reapply
|
||||
your change from the copy. A Claude seat gets the new hook at its next
|
||||
launch, resume, restore or handover. Codex sees its openrig-core plugin as
|
||||
changed: if a Codex seat stops at "Hooks need review", review the hooks in
|
||||
Codex.
|
||||
- **Going back to 0.7.3** leaves migration 140's column in place, unread.
|
||||
|
||||
### What's new and fixed
|
||||
|
||||
**Assistants as team members**
|
||||
|
||||
- **A rig's team shows the members that live outside a terminal.** A roster
|
||||
entry can mark a member as hosted (`"hosted": true`), such as an assistant
|
||||
connected over MCP. `rig_team` lists a rig's hosted members with their
|
||||
declared capabilities and caveat, no pane, and activity `unknown`: OpenRig
|
||||
doesn't observe a hosted member between its calls, so it never reads idle.
|
||||
A roster that can't be read gives a note, never "no members". `rig roster
|
||||
show` and `find` say the member is hosted.
|
||||
([#2095](https://github.com/mvschwarz/openrig/pull/2095))
|
||||
- **The connection teaches the assistant how to work.** The MCP server's
|
||||
instructions say that its inbox is the items addressed to its seat, that a
|
||||
wake only points at the inbox, and to claim an item, do it, then close it
|
||||
with the answer or hand it on. When an item needs a person's approval first,
|
||||
it keeps the item and asks: it blocks the item on that person with the
|
||||
question as the summary, and the answer returns the item in progress. A
|
||||
hosted member can call `rig_whoami` to learn the seat it acts as.
|
||||
([#2095](https://github.com/mvschwarz/openrig/pull/2095),
|
||||
[#2127](https://github.com/mvschwarz/openrig/pull/2127))
|
||||
- **When work waits for a member nothing can wake, the person is told.** The
|
||||
stuck sweep used to address its finding to that same member. Now it goes to
|
||||
the person, on Steering and the TUI's Feed, and to Slack under the default
|
||||
subscription, saying to ask the member to check or to fix the address.
|
||||
([#2110](https://github.com/mvschwarz/openrig/pull/2110))
|
||||
- **A hosted member can be rung when work lands for it.** Its roster entry
|
||||
declares a webhook trigger: `wake.webhook` with `url_ref`,
|
||||
`authorization_ref` and optionally `secrets_env_file`. These are secret
|
||||
names, read from the daemon's environment or a 0600 env file; values never
|
||||
go in rosters or rows. OpenRig POSTs a pointer to the row (the event
|
||||
`work-waiting`, the row id, destination, summary and sender, never the row's
|
||||
body), waits up to 10 seconds and doesn't follow redirects. A 2xx is
|
||||
recorded as the ring being accepted, not as the member waking. If the member
|
||||
doesn't claim the work by the stuck sweep's unclaimed age, its person is
|
||||
told. ([#2115](https://github.com/mvschwarz/openrig/pull/2115))
|
||||
- **The team sees when a hosted member last showed up.** `rig_team` shows when
|
||||
each hosted member last called the team's MCP server, and its latest ring
|
||||
and how that went; `rig roster show` and `find` show the last call. With no
|
||||
evidence it says unknown, never idle, and a claim on a row is still the only
|
||||
pickup. ([#2121](https://github.com/mvschwarz/openrig/pull/2121))
|
||||
|
||||
**Remote MCP callers and grants**
|
||||
|
||||
- **`rig mcp serve --http --grant-service <url>`** checks each request's key
|
||||
with an external grant service instead of the local token file, on every
|
||||
request, with nothing cached. `--grant-service-header-file <path>` sends one
|
||||
`Name: value` header with each lookup.
|
||||
([#2096](https://github.com/mvschwarz/openrig/pull/2096),
|
||||
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
|
||||
- **OAuth discovery:** `--oauth-resource <url>` with `--oauth-issuer <url>`
|
||||
publishes OAuth protected-resource metadata (RFC 9728), so an MCP client
|
||||
that signs in with OAuth can find out how to authenticate, and a request
|
||||
with no credential or a bad one gets a standard challenge. The key and its
|
||||
grant still decide what the caller may do.
|
||||
([#2135](https://github.com/mvschwarz/openrig/pull/2135))
|
||||
- **Whose grant acted is recorded.** The rows a granted caller's actions write
|
||||
record the caller's principal (`principal:v1`): its source, its grant name
|
||||
or id and, for a grant service, the grant's version. It's attribution, not
|
||||
authentication, and never a credential.
|
||||
([#2098](https://github.com/mvschwarz/openrig/pull/2098),
|
||||
[#2141](https://github.com/mvschwarz/openrig/pull/2141))
|
||||
- **A caller's seat that was created but never launched** (for example with
|
||||
`rig import --materialize-only`) is served, instead of every request being
|
||||
refused with `seat_unavailable`.
|
||||
([#2125](https://github.com/mvschwarz/openrig/pull/2125))
|
||||
- **A caller granted through a grant service** hands its tasks to the team it
|
||||
was granted, never to its own caller seat or another caller's
|
||||
([#2132](https://github.com/mvschwarz/openrig/pull/2132)), and can't attach
|
||||
tags that claim OpenRig's own Slack provenance or a workflow's internal
|
||||
sign-off, so a person asked about its task sees its whole text
|
||||
([#2126](https://github.com/mvschwarz/openrig/pull/2126)).
|
||||
|
||||
**MCP protocol 2026-07-28**
|
||||
|
||||
- `rig mcp serve`, over stdio or `--http`, serves clients on MCP protocol
|
||||
revision 2026-07-28, which has no `initialize` handshake, sends its version
|
||||
on every request and may open with `server/discover`. They reach the same
|
||||
tools and the same work loop. Clients that open with 2025's `initialize`
|
||||
keep working unchanged, on the same endpoint and process.
|
||||
([#2112](https://github.com/mvschwarz/openrig/pull/2112))
|
||||
|
||||
**Resuming the conversation you chose**
|
||||
|
||||
- **`rig seat history --rig <id> --node-id <id>`** lists the recorded Claude and Codex
|
||||
conversations this exact node could resume, read-only and without native
|
||||
tokens (`--json`; `--snapshot-id` with `--history-session-id` checks one
|
||||
reference it returned). The rig ID is `rigId` in `rig ps --nodes --json`, and the
|
||||
node ID is `identity.nodeId` in `rig whoami --session <seat> --json`. ([#2100](https://github.com/mvschwarz/openrig/pull/2100))
|
||||
- **A client can resume the history a person reviewed** for an exact node,
|
||||
through the daemon's startup review. Applying it rechecks the choice and the
|
||||
settings through the existing restore path, and refuses, rather than
|
||||
substituting another conversation or starting fresh, if the history changed
|
||||
or became unavailable. A running or unverified terminal is left alone. With
|
||||
0.7.3's saved seat settings by node id
|
||||
([#2051](https://github.com/mvschwarz/openrig/pull/2051)), this completes
|
||||
the three stages agreed in
|
||||
[#1837](https://github.com/mvschwarz/openrig/issues/1837).
|
||||
([#2109](https://github.com/mvschwarz/openrig/pull/2109))
|
||||
|
||||
**Task views**
|
||||
|
||||
- `rig terminal open` and the daemon's terminal preview accept `qitem:<id>`: the current local
|
||||
owners of that task, meaning its pending, in-progress and blocked rows
|
||||
through explicitly linked successors, without closed predecessors or past
|
||||
senders. Stopped, missing, external and unresolved owners are named, and
|
||||
incomplete lineage is reported rather than cut short. It's a read-only
|
||||
membership view: it doesn't follow the task as it moves, and an explicit
|
||||
open still has the provider's usual workspace effects. Preview and open
|
||||
responses now carry `viewDescriptor` (`kind`, `name`).
|
||||
([#2131](https://github.com/mvschwarz/openrig/pull/2131))
|
||||
|
||||
**The kernel at daemon start**
|
||||
|
||||
- **Startup brings back a kernel only when this start found it down,** which
|
||||
is what a reboot leaves. A kernel that was already down before this start
|
||||
stays down, with its old conversations. `rig start` and `rig daemon start
|
||||
--wait-for-kernel` say why and how to bring it back (`rig up kernel
|
||||
--existing`); `--wait-for-kernel` no longer prints a timeout and an
|
||||
authentication hint for a kernel skipped on purpose, and still exits 1.
|
||||
([#2146](https://github.com/mvschwarz/openrig/pull/2146),
|
||||
[#2152](https://github.com/mvschwarz/openrig/pull/2152),
|
||||
[#2156](https://github.com/mvschwarz/openrig/pull/2156))
|
||||
|
||||
**Seats and runtimes**
|
||||
|
||||
- **A Claude seat stopped by a rate limit is recognised** from Claude's
|
||||
`StopFailure` hook, even when its status line shows no usage window, and
|
||||
pending handoffs use the existing wake ladder and retry timer. A delayed
|
||||
older Claude hook no longer replaces the current state in send readiness,
|
||||
attention, review telemetry or the wake scheduler.
|
||||
([#1908](https://github.com/mvschwarz/openrig/pull/1908),
|
||||
[#2113](https://github.com/mvschwarz/openrig/pull/2113))
|
||||
- **`rig handover --source fresh` on Claude** reports the restore packet
|
||||
delivered only when Claude's composer shows it submitted. If it's still
|
||||
staged after one retried Enter, the handover fails with
|
||||
`context_delivery_failed`.
|
||||
([#1863](https://github.com/mvschwarz/openrig/pull/1863))
|
||||
- **A periodic snapshot between separate named-agent launches** keeps a down
|
||||
agent's resume source, including its restore policy and community harness
|
||||
selection, without claiming it was running.
|
||||
([#2123](https://github.com/mvschwarz/openrig/pull/2123), fixes
|
||||
[#1822](https://github.com/mvschwarz/openrig/issues/1822))
|
||||
- **A resumed Pi seat** no longer prints `Entry not found` after `READY` on
|
||||
every restart: a saved history cursor Pi doesn't hold is recovered.
|
||||
([#2122](https://github.com/mvschwarz/openrig/pull/2122))
|
||||
|
||||
**People, parks and reviews**
|
||||
|
||||
- **A park on a registered person's own address** (`<id>@external`) resolves
|
||||
only from a Slack reply written after its question was posted
|
||||
([#2106](https://github.com/mvschwarz/openrig/pull/2106)), keeps its
|
||||
`--summary` and `--evidence-ref`
|
||||
([#2107](https://github.com/mvschwarz/openrig/pull/2107)), and shows in
|
||||
NEEDS YOU in the slice, mission and rig reviews, with asks to that address,
|
||||
instead of the person being listed as an agent holding work
|
||||
([#2108](https://github.com/mvschwarz/openrig/pull/2108)).
|
||||
- **The reviews' agents band** counts only open rows as held, and a seat with
|
||||
work in progress reads as working even when it also has an older blocked
|
||||
row. ([#2136](https://github.com/mvschwarz/openrig/pull/2136), fixes
|
||||
[#2129](https://github.com/mvschwarz/openrig/issues/2129))
|
||||
|
||||
**TUI and CLI**
|
||||
|
||||
- `rig` or `rig tui` started in a terminal that reports 0 rows or columns
|
||||
waits and draws on the next resize, instead of crashing on its first frame.
|
||||
([#2099](https://github.com/mvschwarz/openrig/pull/2099))
|
||||
- The TUI lists a host's rigs by name, in `rig ps`'s order.
|
||||
([#2134](https://github.com/mvschwarz/openrig/pull/2134), fixes
|
||||
[#2130](https://github.com/mvschwarz/openrig/issues/2130))
|
||||
- Opening a seat whose runtime doesn't report effort no longer shows a failed
|
||||
read in the TUI header. ([#2102](https://github.com/mvschwarz/openrig/pull/2102))
|
||||
- `rig whoami`, `rig down`, `rig ask` and `rig health` in a tmux pane without
|
||||
`OPENRIG_SESSION_NAME` no longer print tmux's `invalid option` errors.
|
||||
([#2103](https://github.com/mvschwarz/openrig/pull/2103))
|
||||
|
||||
**Also:** an artifact whose frontmatter header isn't closed no longer wakes an
|
||||
artifact-pool worker as ready
|
||||
([#2016](https://github.com/mvschwarz/openrig/pull/2016)); with Pi cost
|
||||
reporting on, a seat that now runs Claude or Codex shows its current cost, and
|
||||
a charge lookup retried during a re-post finishes
|
||||
([#2120](https://github.com/mvschwarz/openrig/pull/2120)); the
|
||||
`queue-handoff` skill says to write a handoff that can be acted on after one
|
||||
read, as guidance and not a filter
|
||||
([#2154](https://github.com/mvschwarz/openrig/pull/2154),
|
||||
[#2155](https://github.com/mvschwarz/openrig/pull/2155)); the harness preset
|
||||
guide says to check that a harness answers on its own first
|
||||
([#2116](https://github.com/mvschwarz/openrig/pull/2116)); seat environments
|
||||
are exercised on Linux in CI
|
||||
([#2101](https://github.com/mvschwarz/openrig/pull/2101)); and portability and
|
||||
test fixes ([#2147](https://github.com/mvschwarz/openrig/pull/2147),
|
||||
[#2148](https://github.com/mvschwarz/openrig/pull/2148),
|
||||
[#2149](https://github.com/mvschwarz/openrig/pull/2149)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- A kernel that was already down before daemon start is no longer restored on
|
||||
that start; see "The kernel at daemon start".
|
||||
- `rig daemon start --wait-for-kernel` for a kernel skipped on purpose prints
|
||||
the reason and the way back instead of a timeout header and an
|
||||
authentication fix. It still exits 1.
|
||||
- Work waiting for a member nothing can wake is reported to its person, not
|
||||
to that member.
|
||||
- A caller granted through a grant service can't address its own or another
|
||||
caller seat, or set OpenRig's Slack provenance or a workflow's internal
|
||||
sign-off tags.
|
||||
- A park on a registered person's address resolves only from a reply written
|
||||
after its question.
|
||||
|
||||
### Known issues
|
||||
|
||||
- **A webhook ring proves only that the endpoint accepted it.** Whether the
|
||||
member woke and took the work is its claim on the row. An ordinary chat app
|
||||
can't receive unsolicited events, so a hosted assistant without a
|
||||
webhook-capable runner sees work only when it checks.
|
||||
([#2115](https://github.com/mvschwarz/openrig/pull/2115))
|
||||
- **A recorded principal is attribution, not authentication.** The daemon
|
||||
records what the MCP adapter tells it about the caller it checked.
|
||||
([#2098](https://github.com/mvschwarz/openrig/pull/2098))
|
||||
- **The task view (`qitem:<id>`) is membership only.** It doesn't follow a
|
||||
task as it moves, keep a reusable viewer, or change who may type into a
|
||||
seat. ([#2131](https://github.com/mvschwarz/openrig/pull/2131))
|
||||
- **Resuming a reviewed history has no CLI yet.** `rig seat history` lists
|
||||
and checks; applying a choice is through the daemon's startup review, for
|
||||
clients. Choices are limited to this node's eligible Claude and Codex
|
||||
histories in the current compatible snapshot.
|
||||
([#2100](https://github.com/mvschwarz/openrig/pull/2100),
|
||||
[#2109](https://github.com/mvschwarz/openrig/pull/2109))
|
||||
- **The kernel's restore evidence lasts one daemon process.** If the first
|
||||
daemon after a reboot exits before the kernel's restore starts a seat, the
|
||||
next start finds it already down and leaves it down, saying so. Bring it
|
||||
back with `rig up kernel --existing`. (0.7.3's issue of a long-gone kernel
|
||||
returning on daemon start is fixed by
|
||||
[#2146](https://github.com/mvschwarz/openrig/pull/2146).)
|
||||
- **OpenClaw's interrupted local turns are still not recovered.** The seat
|
||||
shows as needing attention, and a send submits a new turn: inspect the pane
|
||||
with `rig capture` first. For an unattended seat, use the Gateway preset.
|
||||
([#2092](https://github.com/mvschwarz/openrig/pull/2092))
|
||||
- **Whether processes a community harness starts outside its pane survive is
|
||||
still not exercised**, and `rig down` stops only the pane.
|
||||
([#2088](https://github.com/mvschwarz/openrig/pull/2088),
|
||||
[#2077](https://github.com/mvschwarz/openrig/pull/2077))
|
||||
- **Hermes:** as in 0.7.3, the example preset doesn't declare `resume`, and
|
||||
whether a seat's Hermes gateway exits once idle is unknown, although
|
||||
`docs/reference/community-harness-presets.md` says it exits. Check with `ps`
|
||||
before relying on it.
|
||||
- **A Codex seat's own permission settings** were enforced natively in 0.7.3's
|
||||
test with Codex 0.164.0; other Codex versions weren't tested.
|
||||
([#2048](https://github.com/mvschwarz/openrig/pull/2048))
|
||||
- **Upgrading doesn't restore Codex hook approvals that an earlier OpenRig
|
||||
removed.** If Codex asks for hook review, approve the hooks you mean to
|
||||
enable. ([#2035](https://github.com/mvschwarz/openrig/pull/2035))
|
||||
- **After the last managed session ends, an empty tmux server may stay
|
||||
running.** That alone doesn't mean an agent is still running.
|
||||
- **Slack threads from before 0.7.3** keep 0.7.3's limits: a reply on an ask
|
||||
parked before the fix can still reach the person asked, and a message
|
||||
received before 0.7.3 can still show OpenRig's routing footer, policy text
|
||||
and attachment paths when an agent asks a person about it.
|
||||
([#2119](https://github.com/mvschwarz/openrig/pull/2119),
|
||||
[#2124](https://github.com/mvschwarz/openrig/pull/2124))
|
||||
- **Shared channels are Slack only**, and the primary user's authority in the
|
||||
TUI is attribution, not authentication.
|
||||
([#1992](https://github.com/mvschwarz/openrig/pull/1992),
|
||||
[#2006](https://github.com/mvschwarz/openrig/pull/2006))
|
||||
- **The cmux path wasn't tried in a real cmux**, and a remote host's rigs are
|
||||
read over HTTP only. ([#2080](https://github.com/mvschwarz/openrig/pull/2080),
|
||||
[#2052](https://github.com/mvschwarz/openrig/pull/2052))
|
||||
- **Window sizing:** a browser that closes while the daemon is down leaves the
|
||||
window at the browser's size until the next web-terminal visit, and a
|
||||
`--readable` capture isn't atomic with a person's own edits.
|
||||
([#2065](https://github.com/mvschwarz/openrig/pull/2065),
|
||||
[#2075](https://github.com/mvschwarz/openrig/pull/2075))
|
||||
- **The runtime-switch handover change** hasn't been shown to cure the
|
||||
unsent-handover case that prompted it.
|
||||
([#2054](https://github.com/mvschwarz/openrig/pull/2054))
|
||||
- The community harness field keeps its name (`communityHarness`) and its
|
||||
original database update; renaming them would break a return to 0.7.2.
|
||||
- Not in this release: verified task viewers
|
||||
([#2133](https://github.com/mvschwarz/openrig/pull/2133)), switching a seat
|
||||
to an isolated account home
|
||||
([#2138](https://github.com/mvschwarz/openrig/issues/2138),
|
||||
[#2143](https://github.com/mvschwarz/openrig/pull/2143)), source evidence for
|
||||
resolving stuck findings
|
||||
([#2153](https://github.com/mvschwarz/openrig/pull/2153)), the stale-pane
|
||||
repair message ([#2157](https://github.com/mvschwarz/openrig/pull/2157)),
|
||||
current launch evidence for fresh handovers
|
||||
([#2159](https://github.com/mvschwarz/openrig/pull/2159)), and a warning for
|
||||
out-of-range MCP daemon ports
|
||||
([#2160](https://github.com/mvschwarz/openrig/pull/2160)). Hermes resume and
|
||||
measured idle exit, and OpenClaw interrupted-turn recovery, aren't in this
|
||||
release either. The limits listed for 0.7.3 still apply unless a change
|
||||
above names them fixed.
|
||||
|
||||
### Credits
|
||||
|
||||
Thank you to everyone whose work is in this release: @Aschenbath (#1908,
|
||||
#2101, #2113), @Nikhi00718 (#1863, #2016, #2106, #2107, #2108), @bradvin
|
||||
(#2100, #2109), @dajiaohuang (#2123) and @seanmcn (#2147, #2148, #2149). And
|
||||
thank you to @pr0xc3nt4ur1 (#1822), @duncanleung (#2130), @ziadeh (#2129),
|
||||
@alperta (#99), @smota (discussion #2117) and @oSquashBlossomo, whose reports
|
||||
and reviews led to fixes here.
|
||||
|
||||
## [0.7.3]
|
||||
|
||||
OpenRig 0.7.3 lets a team work with several people. You can register more than
|
||||
|
||||
@@ -26,9 +26,9 @@ use:
|
||||
|
||||
```sh
|
||||
# Claude Code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent claude-code
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent claude-code
|
||||
# Codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.0/skills/rigs --skill rigs --agent codex
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.4/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
|
||||
@@ -68,11 +68,11 @@ Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Co
|
||||
|
||||
### Install it yourself
|
||||
|
||||
This runs OpenRig's install script from the `v0.7.0` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
This runs OpenRig's install script from the `v0.7.4` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
If a provider isn't signed in yet, the last step reports `FAILED [4/4]`, and `rig setup` lists the sign-in under "Some steps need attention". If the only remaining failures are provider sign-ins, the install steps finished; sign in to each selected provider as below and continue.
|
||||
|
||||
+4
-16
@@ -6,23 +6,11 @@ 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.2
|
||||
## Released in 0.7.4
|
||||
|
||||
[0.7.2 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.2).
|
||||
See its release notes for what shipped, upgrade instructions, contributor credits and the remaining testing gaps.
|
||||
|
||||
Hermes and Cursor through the shared plug-in point were not included. The Codex counterpart to per-member Claude
|
||||
permission rules was not included either; that work has since merged on main in
|
||||
[#2048](https://github.com/mvschwarz/openrig/pull/2048). These follow-ups have no release promise here.
|
||||
|
||||
## Active: 0.7.3 — people and agent teams through one gateway
|
||||
|
||||
- **Participant foundation:** people and vetted shared channels, an explicit default, organization and groups, and readable policy.
|
||||
- **Attributed shared-channel conversations:** reach a support seat with who spoke, the configured role and policy, and the thread;
|
||||
send answers back to that thread.
|
||||
- **Actions and perspective for the primary user:** see whose work needs attention and preserve who actually spoke or acted.
|
||||
|
||||
These are the active themes, not claims that the changes have shipped or a release-date promise.
|
||||
[0.7.4 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.4): a first, experimental step toward every
|
||||
assistant you use working on one team. See its release notes for what shipped, the experimental and security notes for
|
||||
hosted members, upgrade instructions, contributor credits and the remaining testing gaps.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -281,6 +281,34 @@ resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
|
||||
`no_conversation_found` (`:429–434`) and with `recovery:
|
||||
"attention_required"` plus pane evidence on an `attention_required` probe
|
||||
(`:444–450`) — the adapter never relaunches on its own.
|
||||
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
|
||||
otherwise ready history probe only for the matching fresh launch, binding and
|
||||
launch generation. The current pane must show a prompt, and `assessManagedProbe`
|
||||
must confirm the new launch's exact session token in the same foreground process
|
||||
across two observations, with the pane and launch still current. It reuses the
|
||||
existing managed identity proof, including its process fingerprint. A process
|
||||
label alone, old scrollback, an unavailable screen, an unreadable process list
|
||||
or an unidentified Claude process cannot admit the handover. Those cases stay
|
||||
`awaiting_runtime`; a positive identity conflict and visible approval or login
|
||||
gates keep their specific results. The handover waits within its existing
|
||||
readiness budget before sending the context packet.
|
||||
|
||||
The fresh path requires the last `❯` prompt to sit inside Claude's complete
|
||||
input frame: its top rule directly above the prompt and a bottom rule below.
|
||||
A shell's echoed launch command cannot borrow an earlier header or input
|
||||
frame, even when the matching Claude process is already running. A header
|
||||
and prompt alone, or a partially drawn frame, remain pending. This may delay
|
||||
readiness until the full frame is visible; other launch paths keep their
|
||||
existing screen predicates.
|
||||
|
||||
Previously, unavailable observations and ambiguous headerless prompts could
|
||||
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
|
||||
missing observations, unknown or changing process identity, retained UI above
|
||||
direct and script launch commands, partial frames, recovery and normal prompt
|
||||
controls. `claude-handover-readiness.test.ts` exercises the real handover
|
||||
service with migrated SQLite, modeled pane/process observations and one synthetic
|
||||
clock. Readiness and the later packet-submission receipt remain separate checks;
|
||||
these tests do not run a live Claude account.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
|
||||
|
||||
|
||||
@@ -178,15 +178,58 @@ the destination's orchestrator when one is derivable, otherwise to the
|
||||
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,
|
||||
an unreadable human registry or a larger reporting-age setting could therefore
|
||||
look like successful recovery. These findings now require source evidence:
|
||||
closure, pickup or parking of the work, a verified/accepted wake, or a posted
|
||||
notification for the current human recipient and episode, as appropriate to the
|
||||
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
|
||||
legacy human receipts do not prove resolution. Uncertainty keeps an existing
|
||||
finding open without itself sending another wake or creating another finding.
|
||||
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
|
||||
does not prove pickup.
|
||||
|
||||
A legacy human notice with no owner-notification history can still resolve from
|
||||
the existing item-scoped posted ledger when its recipient is confirmed human.
|
||||
This covers older pending asks and parks without inventing an episode that was
|
||||
never recorded. A registered alias needs a readable registry to establish its
|
||||
human recipient; reserved human references remain known during an outage.
|
||||
Legacy receipts carry no recipient key, so a destination reroute requires a new
|
||||
matching notification episode. A legacy park is accepted only when no earlier
|
||||
notice or reroute predates it, and later history contains only receipts or
|
||||
asking-agent reroutes within that park. A re-park, an exit from the park or an
|
||||
ambiguous append cannot establish the same recipient without owner history;
|
||||
the finding waits for a new matching episode or other source resolution.
|
||||
Rerouting only the asking agent within an otherwise unchanged park preserves
|
||||
its human recipient (`blockedOn`).
|
||||
Modern notifications still require their current episode, and an unrelated
|
||||
legacy human receipt cannot resolve an agent wake.
|
||||
|
||||
A known hold still leaves recovery with the existing mechanism: an ask on
|
||||
Steering without an alert, a wake held by the seat typing guard, or a live
|
||||
quiet-release job. Its closure note names that disposition rather than claiming
|
||||
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
|
||||
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
|
||||
returning a row to `pending` while retaining its claim no longer hides a later
|
||||
stall or closes its existing stalled-claim finding. A retained failed wake cannot
|
||||
override that pickup evidence. The agent's claim still does not establish delivery
|
||||
of a separate notification to a person.
|
||||
|
||||
That automatic closure records `stuck-sweep@system` as the actor in both the
|
||||
transition and the `queue.updated` audit event. Previously it credited the
|
||||
finding's source agent with the detector's action. The finding's source,
|
||||
transition and the `queue.updated` audit event, with the source evidence in the
|
||||
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
|
||||
with the detector's action. The finding's source,
|
||||
destination and claim remain its existing ownership records; the underlying work
|
||||
is not reassigned, and existing transition history is not rewritten.
|
||||
|
||||
`queue-stuck-sweep.test.ts` checks this with migrated SQLite: pending and claimed
|
||||
findings close once, their transition and event name the sweep, and ownership
|
||||
stays intact. The posted-human-notice case also checks the actor while leaving
|
||||
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
|
||||
claims, current human-notification episodes and registry outages with migrated
|
||||
SQLite, including positive recovery controls and no repeated send on an
|
||||
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
|
||||
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
|
||||
pending and claimed findings close once, name the sweep in their transition and
|
||||
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
|
||||
the person's original ask open. These checks exercise the detector and queue
|
||||
repository, not a live agent or connector.
|
||||
|
||||
|
||||
@@ -354,6 +354,23 @@ the sections that were not read are `null`.
|
||||
| `rig host list` | `ls` | `--json` |
|
||||
| `rig host doctor <id>` | — | `--posture <profile>`<br>`--public-addr <ip>`<br>`--json` |
|
||||
|
||||
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
|
||||
`laptop`, run this on the local instance to include its attention items:
|
||||
|
||||
```sh
|
||||
rig config set feed.subscriptions.laptop.enabled true
|
||||
```
|
||||
|
||||
The key uses the registry id, including any dots, and defaults to `false`. Registration
|
||||
does not change that setting. SSH entries instead explain that FEED needs an HTTP entry;
|
||||
ids reserved by feed settings (such as `progress`) get a different-id suggestion rather
|
||||
than an invalid command. `--json` keeps its single JSON result without these hints.
|
||||
The host CLI tests execute the advertised command against an isolated config and cover
|
||||
registration, reserved names and JSON output; pairing responses are simulated, so they
|
||||
do not establish connectivity to a live peer.
|
||||
|
||||
### ps
|
||||
|
||||
| Invocation | Aliases | Declared options |
|
||||
|
||||
@@ -88,6 +88,11 @@ are not yet runnable checks.
|
||||
credentials, and outbound network blocked except to localhost (see below). A test that reads
|
||||
your real home directory, a running daemon, the network or a credential can pass on your
|
||||
machine and fail in CI.
|
||||
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
|
||||
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
|
||||
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
|
||||
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
|
||||
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
|
||||
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
|
||||
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
|
||||
exception.
|
||||
@@ -105,7 +110,7 @@ credentials are supplied.
|
||||
| `repo-checks` | ubuntu | `npm run test:repo` |
|
||||
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
|
||||
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
|
||||
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
|
||||
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
|
||||
|
||||
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
|
||||
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
|
||||
@@ -153,7 +158,7 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
|
||||
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
|
||||
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
|
||||
@@ -185,10 +190,12 @@ node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test
|
||||
|
||||
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
|
||||
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
|
||||
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
|
||||
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
|
||||
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
|
||||
containers.
|
||||
each run three times as healthy, then lost-baton, then healthy (six containers); the
|
||||
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
|
||||
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
|
||||
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
|
||||
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
|
||||
fifteen fresh containers.
|
||||
|
||||
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
|
||||
|
||||
@@ -198,10 +205,13 @@ DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
|
||||
--remote --case library --mode healthy --out dist/scenario-check
|
||||
```
|
||||
|
||||
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
|
||||
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
|
||||
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
|
||||
always run once, healthy. `--mode lost-baton` leaves them out of a full run, and
|
||||
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
|
||||
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
|
||||
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
|
||||
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
|
||||
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
|
||||
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
|
||||
each, healthy. `--mode lost-baton` leaves them out of a full run, and
|
||||
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
|
||||
directory for each run you want to keep.
|
||||
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
|
||||
@@ -268,7 +278,7 @@ means:
|
||||
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
|
||||
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
|
||||
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
|
||||
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
|
||||
@@ -309,9 +319,9 @@ of them. The other nine scenarios are authored but not yet runnable as meaningfu
|
||||
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
|
||||
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
|
||||
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
|
||||
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
|
||||
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
|
||||
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
|
||||
|
||||
In most of these files, `seed_regression` steps that come *after* the assertions are
|
||||
|
||||
@@ -244,6 +244,15 @@ interactive"), and the post-launch files aren't sent. A Codex fork that the rig
|
||||
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
|
||||
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
|
||||
|
||||
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
|
||||
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
|
||||
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
|
||||
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
|
||||
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.
|
||||
|
||||
**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
|
||||
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
|
||||
|
||||
@@ -102,7 +102,7 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
|
||||
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
|
||||
means fall down the ladder, never error.
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/Stop/Notification;
|
||||
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
|
||||
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
|
||||
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
|
||||
hour. Exactly-once turn boundaries; SubagentStop filtered (it
|
||||
@@ -139,6 +139,26 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
|
||||
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
|
||||
runner) keeps the hold-until-the-next-boundary behaviour.
|
||||
|
||||
Claude permission notifications raise needs-input immediately. A visible permission can clear
|
||||
after it has been observed and then disappears. A notification whose dialog was never observed
|
||||
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
|
||||
so its earlier permission requests are no longer pending. The relay carries the native actor
|
||||
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
|
||||
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
|
||||
because another tool in the batch may still be waiting for permission.
|
||||
|
||||
Batch completion must come from the current occupant and have a later source timestamp than
|
||||
the matching notification. Missing actor attribution from an older relay, equal or older
|
||||
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
|
||||
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
|
||||
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
|
||||
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
|
||||
A notification with an equal or later timestamp remains eligible; swapping or retiring the
|
||||
occupant discards its remembered completions.
|
||||
Completing a batch changes the needs-input signal without inferring that the seat is idle or
|
||||
overwriting a later elicitation. The hook contract is documented in
|
||||
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
|
||||
|
||||
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
|
||||
trust, unless its inventory declares it positive-only. A positive-only source can report
|
||||
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/sh
|
||||
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
|
||||
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
|
||||
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
|
||||
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
|
||||
# folder, and takes the first input as its first prompt;
|
||||
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
|
||||
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
|
||||
# an id can open another session whose id starts the same way, so neither is used to resume.
|
||||
set -eu
|
||||
|
||||
pi="${OPENRIG_PI:-pi}"
|
||||
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
|
||||
sessions="$state/sessions"
|
||||
|
||||
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
|
||||
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
|
||||
# login and settings in this folder, before its first start.
|
||||
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
|
||||
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
|
||||
case "$PI_CODING_AGENT_DIR" in
|
||||
"~") PI_CODING_AGENT_DIR="$HOME" ;;
|
||||
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
|
||||
esac
|
||||
export PI_CODING_AGENT_DIR
|
||||
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
|
||||
|
||||
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
|
||||
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
|
||||
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
|
||||
set -f
|
||||
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
|
||||
case "$skill" in
|
||||
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
|
||||
esac
|
||||
if [ -d "$skills_from/$skill" ]; then
|
||||
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
|
||||
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
|
||||
else
|
||||
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
|
||||
fi
|
||||
done
|
||||
set +f
|
||||
|
||||
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
|
||||
valid_id() {
|
||||
case "$1" in
|
||||
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
|
||||
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
|
||||
set --
|
||||
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
|
||||
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
|
||||
first_input="${first_input%.}"
|
||||
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
|
||||
fi
|
||||
|
||||
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
|
||||
id="$OPENRIG_RESUME_REF"
|
||||
if ! valid_id "$id"; then
|
||||
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
|
||||
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
|
||||
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
|
||||
node="${OPENRIG_PI_NODE:-node}"
|
||||
if ! command -v "$node" >/dev/null 2>&1; then
|
||||
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
found="" count=0
|
||||
for file in "$sessions"/*_"$id".jsonl; do
|
||||
[ -s "$file" ] || continue
|
||||
if head -n 1 "$file" | "$node" -e '
|
||||
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
|
||||
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
|
||||
catch { process.exit(1); }
|
||||
});' "$id"; then
|
||||
found="$file" count=$((count + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$count" -ne 1 ]; then
|
||||
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
|
||||
fi
|
||||
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
|
||||
if ! valid_id "$OPENRIG_CONVERSATION"; then
|
||||
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
|
||||
exit 2
|
||||
fi
|
||||
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
|
||||
fi
|
||||
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
|
||||
exit 2
|
||||
@@ -0,0 +1,15 @@
|
||||
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
|
||||
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
|
||||
format: 1
|
||||
name: pi-tui
|
||||
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
|
||||
maintainer: Your name or team
|
||||
limits:
|
||||
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
|
||||
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
|
||||
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
|
||||
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
|
||||
- how Pi takes a wake that arrives while it's busy isn't shown
|
||||
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
|
||||
names_conversation: true
|
||||
first_input: true
|
||||
@@ -214,6 +214,33 @@ CLI.
|
||||
|
||||
Copy both and maintain them as your own.
|
||||
|
||||
## An example: Pi's terminal UI
|
||||
|
||||
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
|
||||
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
|
||||
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
|
||||
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
|
||||
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
|
||||
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
|
||||
on it, not Pi choosing by itself to park or hand work on.
|
||||
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
|
||||
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
|
||||
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
|
||||
gave>` and passes the first input as Pi's first prompt, byte for byte.
|
||||
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
|
||||
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
|
||||
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
|
||||
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
|
||||
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
|
||||
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
|
||||
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
|
||||
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
|
||||
it), and other Pi versions.
|
||||
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
|
||||
agent folder.
|
||||
|
||||
Copy both 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` or `first_input` is refused by an older
|
||||
OpenRig that doesn't know the field.
|
||||
@@ -228,6 +255,56 @@ OpenRig records `node.startup_first_input_passed` with the path and `submission:
|
||||
|
||||
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
|
||||
|
||||
## Interactive Pi in a terminal seat: the coordination skills
|
||||
|
||||
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
|
||||
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
|
||||
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
|
||||
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
|
||||
seats in its launcher.
|
||||
|
||||
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
|
||||
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
|
||||
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
|
||||
source. The seat's setup does two things.
|
||||
|
||||
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
|
||||
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
|
||||
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
|
||||
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
|
||||
|
||||
```bash
|
||||
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
|
||||
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||
mkdir -p "$PI_AGENT_DIR/skills"
|
||||
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
|
||||
```
|
||||
|
||||
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
|
||||
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
|
||||
For example:
|
||||
|
||||
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
|
||||
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
|
||||
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
|
||||
|
||||
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
|
||||
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
|
||||
counts as guidance, which a community seat doesn't take.
|
||||
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
|
||||
argument in your Pi version.
|
||||
- `first_input` carries text, not files: it doesn't install the skills.
|
||||
|
||||
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
|
||||
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
|
||||
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
|
||||
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
|
||||
|
||||
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
|
||||
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
|
||||
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
|
||||
made with `handoff-and-complete`, whose next row names it as its source.
|
||||
|
||||
## Write your own
|
||||
|
||||
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Edge Types Reference
|
||||
|
||||
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; its sources unchanged in edge behaviour since then)
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`
|
||||
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
|
||||
The fallback escalation section and deferred-target exceptions below describe #605.
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -16,8 +17,7 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
|
||||
|
||||
## Edge Kinds
|
||||
|
||||
Five edge kinds are accepted by the pod-aware validator (the legacy flat-node format accepts only `delegates_to`,
|
||||
`spawned_by` and `can_observe`):
|
||||
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
|
||||
|
||||
| Kind | Accepted | Has Runtime Behavior | Description |
|
||||
|------|----------|---------------------|-------------|
|
||||
@@ -25,13 +25,13 @@ Five edge kinds are accepted by the pod-aware validator (the legacy flat-node fo
|
||||
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
|
||||
| `can_observe` | yes | no | Source can observe target's output |
|
||||
| `collaborates_with` | yes | no | Peer collaboration |
|
||||
| `escalates_to` | yes | no | Source escalates to target |
|
||||
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
|
||||
|
||||
## What Edges Actually Do Today
|
||||
|
||||
### Launch ordering
|
||||
|
||||
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
|
||||
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
|
||||
|
||||
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
|
||||
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
|
||||
@@ -46,13 +46,42 @@ All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT
|
||||
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. `spawned_by` plays no part here.
|
||||
|
||||
### Fallback escalation across rigs
|
||||
|
||||
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:
|
||||
- kind: escalates_to
|
||||
from: work.lead
|
||||
to: review-advisor@coord
|
||||
```
|
||||
|
||||
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
|
||||
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
|
||||
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
|
||||
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
|
||||
|
||||
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
|
||||
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
|
||||
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
|
||||
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
|
||||
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
|
||||
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
|
||||
|
||||
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
|
||||
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
|
||||
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
|
||||
|
||||
### Graph visualization
|
||||
|
||||
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
|
||||
### Identity projection
|
||||
|
||||
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
|
||||
### Attach hint heuristic
|
||||
|
||||
@@ -64,7 +93,7 @@ node, in creation order, whose session is running, or else the first node with a
|
||||
Edges do NOT currently:
|
||||
|
||||
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
|
||||
daemon path that follows `delegates_to`)
|
||||
daemon path that follows `delegates_to` or fallback `escalates_to`)
|
||||
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
|
||||
- **Control permissions** — there's no edge-based access control
|
||||
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
|
||||
@@ -128,7 +157,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
|
||||
## Validation Rules
|
||||
|
||||
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
|
||||
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
|
||||
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
|
||||
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
|
||||
5. Self-edges are not validated at the schema level but are not meaningful
|
||||
@@ -191,11 +191,11 @@ You can also read [`skills/rigs/SKILL.md`](../../skills/rigs/SKILL.md) directly.
|
||||
script will do and changes nothing:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh -s -- --dry-run
|
||||
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
|
||||
The same command without `--dry-run`,
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.0/scripts/install.sh | sh`, installs the
|
||||
`curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.4/scripts/install.sh | sh`, installs the
|
||||
latest published `@openrig/cli` with `npm install -g`,
|
||||
runs the Node.js and SQLite check, then `rig setup --dry-run` and `rig setup`. `rig setup` checks both Claude Code and
|
||||
Codex and may install a missing one, as described below. A failed step prints
|
||||
@@ -331,6 +331,16 @@ every seat while those seats still run the sessions it started, reports that
|
||||
restore's outcome instead of starting another. Explicitly repeating the saved
|
||||
`--non-interruptive` or `--no-non-interruptive` choice also joins that restore.
|
||||
|
||||
`rig start --last` restores a rig unless every seat it launched was stopped on
|
||||
purpose with `rig down` or `rig seat stop`. A rig whose stop was never recorded
|
||||
counts as last running: one taken down with `rig down` before OpenRig 0.6.12, or
|
||||
one whose seats exited or were lost without `rig down`. So does a rig with no snapshot
|
||||
whose saved state can be restored. To keep such a rig down, run `rig down <rig>`
|
||||
once, by rig id when names are shared; it records the stop, and `--last` then
|
||||
leaves the rig down. `--last` never selects the kernel. Under `--no-kernel`
|
||||
(`OPENRIG_NO_KERNEL=1`) the daemon skips the kernel's automatic restore too;
|
||||
bring it back with `rig up kernel --existing`.
|
||||
|
||||
An explicit `rig up kernel --existing --fresh <seat...>` waits for startup's
|
||||
restore to finish, then replaces only the named seats that still hold the exact
|
||||
sessions startup launched. Other seats keep running. An operator's conversation
|
||||
@@ -841,7 +851,7 @@ the lifecycle API without booting a new kernel, then stop the remaining rigs.
|
||||
| Shared terminal is absent | Inspect the existing kernel binding and recovery state; use standalone `rig tui` 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. |
|
||||
| Daemon restarted but tmux survived | Re-read `rig status` and the existing queue; a daemon restart is not a fresh project. |
|
||||
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when all of its seats were lost; check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
|
||||
| Host reboot lost tmux sessions | Starting the daemon restores the kernel by itself when that start finds all of its seats lost; a kernel that was already down before the reboot stays down, and `rig start` names `rig up kernel --existing`. Check `rig status`, and if it reports the restore failed, run `rig up kernel --existing`. For other rigs, open `rig`, start the daemon if needed (press **S** if it opens on the work views), and select the existing rig and seats. Resume is the default; a fresh conversation needs a separate decision. |
|
||||
| Launch reports no usable snapshot | Inspect the existing rig and retained project files, then follow the same-seat recovery below. |
|
||||
| Work is waiting on a prompt or decision | Read the row, transition and named prompt; preserve the obligation until the missing decision arrives. |
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
A hosted member is a tool that lives outside a terminal, such as an assistant, a chat bot or a cloud routine, and works as a member of a rig. You
|
||||
address work to it like any seat. It reaches the team through OpenRig's MCP server under a grant you issue and can revoke. It never gets a pane.
|
||||
|
||||
Assistants that run on your machine can reach OpenRig's MCP server locally. Many assistants live in a vendor's cloud, and those can only reach a server on the internet.
|
||||
|
||||
> **Experimental and unsupported. Off by default.** Nothing in OpenRig listens on the internet unless you set it up yourself. You serve `rig mcp serve --http`, put it behind your own TLS tunnel or proxy, and issue a token. Doing that is like opening a port in your firewall. Anyone with the URL and a valid token can act as that member within its grant. Treat the token like a password, give the member the narrowest grant, and revoke it when you're done (`rig mcp token revoke <name>`). We don't support, and take no responsibility for, an OpenRig reachable from the internet, just as with running OpenRig itself on the open internet. Use at your own risk.
|
||||
|
||||
## Set one up
|
||||
|
||||
1. **Pick its address** on an existing rig: `<member>@<rig>`, for example `grok@research`. No node is added; the queue takes rows for it.
|
||||
@@ -52,7 +56,7 @@ member, so it checks its rows again.
|
||||
|
||||
The server tells the member this in its MCP instructions. The text is versioned, so you can check which version a tool received:
|
||||
|
||||
> Member workflow (member-workflow/v1). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
|
||||
> Member workflow (member-workflow/v2). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act. For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it. Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files. If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may. When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask. If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress. Otherwise ask as a new item, to the seat it came from or, for a shared channel's item, to your rig's lead (rig_team shows it), with your question and draft. Then set yours blocked with blockedOn that new qitemId: when that item closes, yours returns to pending, to claim again. Nothing promises to wake you, so check your inbox again. Then finish the same item; don't open a new one.
|
||||
|
||||
## Ring it when work lands
|
||||
|
||||
|
||||
@@ -120,6 +120,11 @@ or **"Not tested by OpenRig"**, which means exactly that. You're welcome to run
|
||||
|
||||
## 6. Submit it to openrig.dev/rigs
|
||||
|
||||
Before you submit, run `rig bundle check` on the folder at the commit you'll submit. openrig-registry's registry check
|
||||
runs the same check, with the latest published OpenRig, on the folder at your `ref`, and any finding fails it. A team
|
||||
made with `rig export` has no README of its own: add one and declare it in `rig.yaml` as `docs: [{ path: README.md }]`
|
||||
(see [section 1](#1-lay-out-the-folder)).
|
||||
|
||||
Open a pull request to [mvschwarz/openrig-registry](https://github.com/mvschwarz/openrig-registry) that adds one file,
|
||||
`registry/submissions/<your-team>.yaml`, with three fields: `repository` (your GitHub repository URL), `folder` (the
|
||||
folder holding `rig.yaml`, or `.` for the repository root) and `ref` (a branch, tag or commit). No pull requests? Open
|
||||
|
||||
@@ -17,7 +17,7 @@ npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/ri
|
||||
npx skills add https://github.com/mvschwarz/openrig/tree/<release-tag>/skills/rigs --skill rigs --agent codex
|
||||
```
|
||||
|
||||
For a new install, use the latest release's tag, for example `v0.7.0`. If OpenRig is already installed, use the tag
|
||||
For a new install, use the latest release's tag, for example `v0.7.4`. If OpenRig is already installed, use the tag
|
||||
that matches `rig --version`, or upgrade first. If the command fails with `npx: command not found`, Node.js isn't
|
||||
installed or isn't on your PATH yet (see [What you need](#what-you-need)). Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what
|
||||
you want built.
|
||||
|
||||
@@ -511,9 +511,9 @@ pods:
|
||||
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
|
||||
|
||||
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
|
||||
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
|
||||
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
|
||||
@@ -653,7 +653,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
@@ -718,7 +718,11 @@ 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` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
|
||||
| `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,
|
||||
cycle detection and visible fallback outcomes.
|
||||
|
||||
### Pod-Local Edges
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@ commit. Evidence capture (`proof add`), policy acceptance, higher outcome
|
||||
judgment and publication are distinct. This optional component does not select
|
||||
Part B or add a mandatory review gate.
|
||||
|
||||
A judgment binds the slice's path, the item's text and the policy. Moving or
|
||||
renaming a slice (`rig scope slice move`, or by hand) reopens its judged items:
|
||||
`rig proof show` reports each as `unknown`, and each needs a new judgment on its
|
||||
current revision. `rig scope audit` checks structure only and never reads proof
|
||||
readiness, so a passing audit doesn't mean the proof still stands.
|
||||
|
||||
The project readiness that `rig proof show --project` reports counts only
|
||||
missions whose `mission.yaml` carries `metadata.status: active` or
|
||||
`metadata.status: release-candidate`. `rig scope mission create` writes
|
||||
|
||||
@@ -16,6 +16,12 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
|
||||
|
||||
Lifecycle work is refused at `openrig-urgent` and `urgent-only`, as at `hold`.
|
||||
|
||||
A terminal member (`runtime: terminal`) defaults to `sends-only`, which holds OpenRig's own notices. Set one
|
||||
to `off` and those notices, such as park reminders and other watchdog messages, type into whatever the pane
|
||||
is running, and a shell prompt runs each line as a command, From and To lines included: OpenRig doesn't check
|
||||
a terminal pane's foreground process. `openrig-urgent` and `draft-aware` type only into a recognized empty agent composer, so
|
||||
a shell prompt holds them. Keep the default for a seat that may fall back to a shell.
|
||||
|
||||
Runtime permissions are separate. Recipient and interactive-prompt checks still
|
||||
apply. `--raw` and `--force` do not bypass the typing guard. A deliberate
|
||||
`--dangerously-interact --reason` remains available at every interruption level
|
||||
|
||||
@@ -30,16 +30,44 @@ With neither a provider nor a session, the daemon's open and preview APIs try
|
||||
a live Herdr first, then a live cmux. An explicit session always selects Herdr
|
||||
and skips that fallback, including when the daemon's default Herdr endpoint is down.
|
||||
|
||||
An explicit session resolves under the daemon user's Herdr session directory;
|
||||
it takes precedence over that daemon's default socket override. The selection
|
||||
belongs to the request. Saved views still describe membership and layout, so
|
||||
pass `--session` again when reopening the view in that session.
|
||||
Session paths use the daemon's `XDG_CONFIG_HOME` when set, or `HOME/.config`
|
||||
when unset. Relative paths (including an empty value) resolve from the daemon's
|
||||
startup directory, matching Herdr's configuration selection. Named sessions live under
|
||||
`herdr/sessions/<name>/herdr.sock`; `default` uses `herdr/herdr.sock` directly.
|
||||
These settings are captured when the daemon starts.
|
||||
|
||||
If the requested name equals the daemon's `HERDR_SESSION`, it keeps that
|
||||
session's explicit `HERDR_SOCKET_PATH`. A different requested name uses the
|
||||
configuration-tree path, without reusing the override or inferring a session
|
||||
from an arbitrary socket path. With an explicit socket but no configured
|
||||
session name, omit `--session` to keep that exact endpoint. A missing selected
|
||||
socket never falls back to another session. Viewer and follow operations carry
|
||||
that exact socket to the CLI through `HERDR_SOCKET_PATH`, without an explicit
|
||||
CLI `--session` that would override it. They check that the CLI reports the
|
||||
selected socket before changing its panes. Daemon-side CLI calls retain the
|
||||
startup environment; desktop follow uses the socket confirmed by the daemon.
|
||||
|
||||
The selection belongs to the request. Saved views still describe membership
|
||||
and layout, so pass `--session` again when reopening the view in that session.
|
||||
|
||||
The existing view rules apply: saved views, rig and pod views, read-only mission
|
||||
and slice views, pagination, absent seats and partial results all use the same
|
||||
composer. Selecting a session changes where those tiles are placed. It does
|
||||
not relaunch the seats' tmux sessions or move queue ownership.
|
||||
|
||||
## Open results
|
||||
|
||||
For Herdr, a successful layout request alone does not count its seats as opened.
|
||||
OpenRig checks the returned tab's pane list. A missing tab identity or an
|
||||
unreadable, empty or unattributable listing puts the affected seats in
|
||||
`degraded`, with `code: "pane_unconfirmed"`, instead of `opened`. The CLI and
|
||||
TUI name those seats as unconfirmed. Inspect the terminal before retrying:
|
||||
the layout may have taken effect even though its panes could not be confirmed.
|
||||
Confirmed seats on other pages still count; with none confirmed, the command
|
||||
returns a nonzero exit status. Reusing an existing workspace keeps its separate
|
||||
reuse result. This pane check does not prove desktop visibility or create a
|
||||
verified viewer mapping.
|
||||
|
||||
## Replacing a seat's session
|
||||
|
||||
New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
@@ -180,7 +208,101 @@ its name, falling back to the qitem ID. The existing preview `view` string is
|
||||
unchanged. A following view derives membership when requested; it does not
|
||||
mean an open workspace updates itself.
|
||||
|
||||
Preview is read-only. In this membership increment, `viewerStatus` is
|
||||
`not-provisioned`: the response does not verify a reusable provider terminal
|
||||
Preview is read-only. A member without owned viewer provenance is
|
||||
`not-provisioned`; the ensure operation below can establish a reusable provider
|
||||
mapping. An explicit open retains the provider's existing workspace behavior;
|
||||
it does not continuously reconcile the view or change an agent's lifecycle.
|
||||
|
||||
## Following one task
|
||||
|
||||
`rig terminal open qitem:<id> --follow` keeps one workspace following the
|
||||
task's current obligation owners. A joining member gets a verified local
|
||||
viewer tile without taking focus. A departing member loses only its viewer,
|
||||
after a grace period; the agent and its conversation keep running. The
|
||||
explanation strip names joins, departures, unavailable terminals and limits.
|
||||
The first workspace on an empty provider may become focused.
|
||||
|
||||
This increment supports the qualified **Linux Herdr 0.9.5/protocol 22**
|
||||
endpoint. Other platforms/providers and other selector kinds retain ordinary
|
||||
one-shot open. Use `--provider herdr --json` to operate without opening a
|
||||
desktop window, and `--session <name>` to select that endpoint. Opening a tile
|
||||
can change the agent's terminal size; `ignore-size` does not guarantee isolation.
|
||||
|
||||
```bash
|
||||
rig terminal open qitem:example --follow --provider herdr --json
|
||||
rig terminal open qitem:example --follow --provider herdr --pin reviewer@team
|
||||
rig terminal open qitem:example --follow --provider herdr --max-tiles 12
|
||||
rig terminal open qitem:example --follow --provider herdr --suppress reviewer@team --suppression-ms 60000
|
||||
rig terminal open qitem:example --no-follow
|
||||
```
|
||||
|
||||
`--unpin` and `--unsuppress` reverse those presentation choices. The default
|
||||
cap is eight tiles; explicitly raising `--max-tiles` overrides it. Pins can
|
||||
exceed the cap rather than being evicted. A departed tile retained for typed or
|
||||
unconfirmed input does not consume a slot for joining members. Disabling removes
|
||||
the saved following selector and keeps the existing layout intact.
|
||||
|
||||
The default departure grace is four seconds (`--grace-ms`). A focused leaver
|
||||
gets another twelve seconds for reading (`--focus-retention-ms`); before its
|
||||
tile closes, focus moves to a verified surviving member, or to the explanation
|
||||
strip when none remains. Typed or unconfirmed input keeps the tile, labeled
|
||||
as no longer participating. Explicit suppression also retains a typed draft.
|
||||
Pi, terminal and community harness input may be unreadable to the composer
|
||||
inspector: those tiles remain while input is unconfirmed, even after unpinning.
|
||||
Each input-retained departure gets one delayed recheck after thirty seconds.
|
||||
After that, queue or node/session lifecycle events, or an explicit repeat of
|
||||
the follow command, can reconsider it. Activity hooks do not wake following,
|
||||
and input is not repeatedly polled. Draft text is never saved.
|
||||
|
||||
Following consumes Core events and one-shot retention/suppression deadlines,
|
||||
not repeated CLI membership queries. Complete empty membership drains the
|
||||
tiles and preserves the workspace. Once no retained tiles remain, following
|
||||
stops and the selector releases its active slot; new work does not automatically
|
||||
restart it. Run `--follow` again to resume the same workspace. An incomplete source, failed provider read
|
||||
or changed pane/terminal generation retains the previous view and reports
|
||||
the uncertainty. It never authorizes a blind workspace rebuild or close.
|
||||
`rig terminal views --json` and `rig terminal status --json` include the saved
|
||||
active selectors and latest following status. Active selectors and owned viewer provenance
|
||||
survive daemon restarts; task membership is always re-derived.
|
||||
|
||||
The owner-authorized HTTP operation is `POST /api/terminal/follow` with
|
||||
`{view:"qitem:<id>",provider:"herdr",session?,enabled:true,maxTiles?,pin?,unpin?,
|
||||
suppress?,unsuppress?,suppressionMs?,graceMs?,focusRetentionMs?,expectedPlan?}`.
|
||||
Seat controls are arrays of names from the current/retained view. It uses the
|
||||
existing terminal bearer boundary, like ensure-viewer. The response includes
|
||||
the stable `workspaceId`, normal open partitions, `viewDescriptor`, and
|
||||
`following:{id,enabled,state,maxTiles,tiles,events,overflow,nextAt?}`. Presentation
|
||||
provenance grants no membership or input authority. This is a local native
|
||||
viewer operation; browser input and remote-host provisioning are separate.
|
||||
|
||||
## Ensuring an owner viewer
|
||||
|
||||
The owner-facing HTTP operation `POST /api/terminal/viewer/ensure` accepts
|
||||
`{view:"qitem:<id>", member:{hostId,nodeId}, provider:"herdr", session,
|
||||
expectedPlan}`. Use a current preview's member key and plan. This explicit
|
||||
operation uses the daemon's existing terminal bearer and browser boundary;
|
||||
a bridge must retain its signed-in owner, Origin and expiry controls. It does
|
||||
not grant authority based on task membership or accept a caller terminal ID.
|
||||
|
||||
On the qualified Linux Herdr 0.9.5/protocol 22 endpoint, it creates one viewer
|
||||
or reuses an owned, verified `{workspaceId,paneId,terminalId,readOnly}` mapping.
|
||||
A repeated/concurrent request does not create another viewer. A lost create
|
||||
reply is reconciled before starting anything else; a lost run reply is checked
|
||||
against the actual foreground process, never blindly repeated. Unknown or
|
||||
changed terminal identity remains unconfirmed. The operation does not relayout,
|
||||
close departed viewers, take over control, focus existing panes or launch/stop
|
||||
agents. A first workspace on an otherwise empty Herdr server may become focused.
|
||||
|
||||
Preview remains passive and names `not-provisioned`, `verified` or `unconfirmed`
|
||||
viewer status. Studio can ensure a new member, add its browser frame, and retain
|
||||
a pinned leaver labeled as no longer participating. The provider mapping is
|
||||
small presentation provenance, separate from queue membership. Herdr display
|
||||
labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
Writable tile clients use `tmux attach -f ignore-size`; read-only clients keep
|
||||
`-r` (which also implies ignore-size). This can avoid contributing to sizing
|
||||
when other clients establish the agent window's dimensions. It is **not resize
|
||||
isolation**: opening a tile can change the agent's terminal size when it is the
|
||||
only sizing client. Observers of a Herdr terminal do not themselves resize it;
|
||||
a controlling browser can. No cross-version resize guarantee is implied.
|
||||
+8
-33
@@ -1,38 +1,15 @@
|
||||
# Release Notes
|
||||
|
||||
This directory is the lightweight release history for OpenRig.
|
||||
This directory holds one note per release for OpenRig v0.1.12 through v0.6.3, plus v0.6.12 (for example `v0.1.12.md`).
|
||||
|
||||
It is intentionally simpler than a monolithic `CHANGELOG.md`.
|
||||
|
||||
Each shipped release gets its own note:
|
||||
|
||||
- `v0.1.12.md`
|
||||
- `v0.2.0.md`
|
||||
- `v0.3.0.md`
|
||||
- and so on
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
|
||||
|
||||
- npm package release
|
||||
- optional Git tag
|
||||
- optional GitHub Release
|
||||
- short, human-written summary of what is included
|
||||
|
||||
This keeps release notes:
|
||||
|
||||
- easy to author
|
||||
- easy to link in GitHub Releases
|
||||
- easy to paste into announcements
|
||||
- durable in the repo
|
||||
Notes for later releases are in [`CHANGELOG.md`](../../CHANGELOG.md) and on the [GitHub Releases page](https://github.com/mvschwarz/openrig/releases).
|
||||
|
||||
## Minimal Process
|
||||
|
||||
For each release:
|
||||
|
||||
1. Copy `_template.md` to `vX.Y.Z.md`.
|
||||
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
|
||||
1. Add the release's section to [`CHANGELOG.md`](../../CHANGELOG.md).
|
||||
2. Write the release's GitHub Release notes. No new file is added to this directory.
|
||||
3. Run the **substance gate** at the exact release cut. The gate derives every
|
||||
file under the shipped pack sources, verifies a hash-bound human judgment
|
||||
and an individual disposition for each mechanical candidate, scans the
|
||||
@@ -79,16 +56,16 @@ For each release:
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
5. Create a GitHub Release using the same file:
|
||||
5. Create the GitHub Release with the notes from step 2:
|
||||
|
||||
```bash
|
||||
gh release create vX.Y.Z \
|
||||
--repo mvschwarz/openrig \
|
||||
--title "OpenRig vX.Y.Z" \
|
||||
--notes-file docs/releases/vX.Y.Z.md
|
||||
--title "OpenRig X.Y.Z" \
|
||||
--notes-file <release-notes.md>
|
||||
```
|
||||
|
||||
6. Publish the npm package if that is part of the release flow.
|
||||
6. Publish the npm package.
|
||||
|
||||
## Guidance
|
||||
|
||||
@@ -101,5 +78,3 @@ For each release:
|
||||
## Scope
|
||||
|
||||
This directory is a release-note archive, not a full historical changelog taxonomy.
|
||||
|
||||
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
|
||||
@@ -3,7 +3,7 @@ import { assessLocalDaemonOwner, ForeignLocalDaemonError, type LocalDaemonOwner
|
||||
export { ForeignLocalDaemonError } from "./local-daemon-owner.js";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { FETCH_HEADERS_TIMEOUT_MS, nodeHttpFetch } from "./long-request-fetch.js";
|
||||
|
||||
@@ -230,7 +230,7 @@ export class DaemonClient {
|
||||
const localHeaders = senderIdentityHeaders(undefined, this.sessionName);
|
||||
const session = localHeaders[SENDER_IDENTITY_HEADER];
|
||||
if (!session || session.split("@").length >= 3) return { headers: localHeaders, local: false };
|
||||
const directTarget = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const directTarget = readOpenRigUrl();
|
||||
if (!this.remoteTarget && !directTarget) return { headers: localHeaders, local: true };
|
||||
// Imported here, not at the top: it loads better-sqlite3 and yaml, which only a seat on a direct endpoint needs.
|
||||
const origin = this.originSelfHostId ?? (await import("./local-origin.js")).readLocalOrigin();
|
||||
@@ -256,7 +256,7 @@ export class DaemonClient {
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
} else {
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const envUrl = readOpenRigUrl();
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
|
||||
@@ -13,6 +13,11 @@ function shellQuote(value: string): string {
|
||||
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
|
||||
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
|
||||
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
|
||||
const TMUX_CONTEXT_FORMAT = "#{pane_id} #{window_id} #{session_name}";
|
||||
const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
|
||||
|
||||
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
| { attachmentType: "external_cli" }
|
||||
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
|
||||
@@ -22,15 +27,11 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
|
||||
}
|
||||
|
||||
try {
|
||||
const output = tmuxExec(`tmux display-message -p -t ${JSON.stringify(tmuxPane)} "#{session_name}\n#{window_id}\n#{pane_id}"`);
|
||||
const [tmuxSession, tmuxWindow, resolvedPane] = output.split("\n").map((part) => part.trim());
|
||||
if (tmuxSession && resolvedPane) {
|
||||
return {
|
||||
attachmentType: "tmux",
|
||||
tmuxSession,
|
||||
tmuxWindow: tmuxWindow || undefined,
|
||||
tmuxPane: resolvedPane,
|
||||
};
|
||||
const output = tmuxExec(`tmux display-message -p -t ${shellQuote(tmuxPane)} ${shellQuote(TMUX_CONTEXT_FORMAT)}`);
|
||||
const match = TMUX_CONTEXT_LINE.exec(output.trim());
|
||||
if (match) {
|
||||
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
|
||||
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
|
||||
}
|
||||
} catch {
|
||||
// Fall back to external_cli if tmux metadata cannot be resolved.
|
||||
|
||||
@@ -265,8 +265,10 @@ Supported notes:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
// The capture default is 20: a missing or non-positive count reads as 20, never 0 or negative.
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
@@ -334,7 +336,13 @@ async function runCrossHostCapture(
|
||||
if (opts.rig) argv.push("--rig", opts.rig);
|
||||
if (opts.pod) argv.push("--pod", opts.pod);
|
||||
// Only an explicit --lines is forwarded: the remote's own default stays the default.
|
||||
if (opts.linesGiven || opts.historyPlusPane) argv.push("--lines", opts.lines ?? "20");
|
||||
// A value that parses as a positive count goes over as given (the remote decides);
|
||||
// only a missing, zero, negative or non-numeric count sends the documented default.
|
||||
if (opts.linesGiven || opts.historyPlusPane) {
|
||||
const given = opts.lines ?? "20";
|
||||
const parsed = parseInt(given, 10);
|
||||
argv.push("--lines", Number.isNaN(parsed) || parsed < 1 ? "20" : given);
|
||||
}
|
||||
if (opts.historyPlusPane) argv.push("--history-plus-pane");
|
||||
if (opts.readable) argv.push("--readable");
|
||||
if (opts.size !== undefined) argv.push("--size", opts.size);
|
||||
@@ -375,7 +383,8 @@ async function runHttpHostCapture(
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
const requestedLines = Number.isNaN(lines) || lines < 1 ? 20 : lines;
|
||||
const body: Record<string, unknown> = { lines: requestedLines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
|
||||
@@ -4,7 +4,24 @@ import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/** tmux's arguments for the session that runs `rig chatroom watch`. The watcher goes to tmux as separate arguments, so
|
||||
* tmux runs it without a shell and the rig name arrives as one argument; `--` keeps a name that starts with `-` from
|
||||
* reading as an option. tmux reads the `-s` value as a format, where `##` is one `#` and a run of `#` before `[` is a
|
||||
* style it keeps as written, so every other `#` is doubled and the session is `chatroom@<rig>`. Some tmux versions
|
||||
* still change a session name (3.5a writes `_` for `:` and `.`, and escapes a backslash or a control character such
|
||||
* as a newline or tab), so `-P -F` prints the name tmux used. */
|
||||
export function chatroomWatchTmuxArgs(rig: string): string[] {
|
||||
const session = `chatroom@${rig}`.replace(/#+(\[)?/g, (run, style?: string) => style ? run : run + run);
|
||||
return ["new-session", "-d", "-P", "-F", "#{session_name}", "-s", tmuxArg(session),
|
||||
"rig", "chatroom", "watch", "--", tmuxArg(rig)];
|
||||
}
|
||||
|
||||
/** tmux ends a command at an argument that ends in `;`, unless a `\` comes before it, so a final `;` gets that `\`. */
|
||||
function tmuxArg(value: string): string {
|
||||
return value.endsWith(";") ? `${value.slice(0, -1)}\\;` : value;
|
||||
}
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
@@ -175,9 +192,11 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.tmux) {
|
||||
const sessionName = `chatroom@${rig}`;
|
||||
try {
|
||||
execSync(`tmux new-session -d -s ${JSON.stringify(sessionName)} "rig chatroom watch ${JSON.stringify(rig)}"`, { stdio: "ignore" });
|
||||
console.log(`Started watch in tmux session: ${sessionName}`);
|
||||
console.log(`Attach with: tmux attach -t ${sessionName}`);
|
||||
// The name tmux used, which differs from sessionName when tmux changed it (a `.` in a rig name, for example).
|
||||
const created = execFileSync("tmux", chatroomWatchTmuxArgs(rig), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.trim() || sessionName;
|
||||
console.log(`Started watch in tmux session: ${created}`);
|
||||
console.log(`Attach with: tmux attach -t ${created}`);
|
||||
} catch {
|
||||
console.error(`Failed to create tmux session '${sessionName}'. It may already exist.`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
@@ -54,6 +54,14 @@ function existsOrThrow(path: string): boolean {
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
||||
}
|
||||
|
||||
/** The evidence probe's URL: OPENRIG_URL with one trailing slash trimmed, as the detector trims it, else daemon.json's
|
||||
* host and port. The shared reader leaves a query or fragment URL as written, so the trim stays here. */
|
||||
export function crashCartEvidenceUrl(openrigUrl: string | undefined, state: { host?: string; port: number } | undefined): string {
|
||||
return openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real verdict: LAZY-imports the narrow @openrig/daemon/crash-cart subpath at invocation (dep
|
||||
* rail 2 — rig startup + other verbs never load the daemon module) and composes the shipped detector
|
||||
@@ -63,7 +71,7 @@ function existsOrThrow(path: string): boolean {
|
||||
async function realEmit(): Promise<CrashCartEmit> {
|
||||
const cc = await import("@openrig/daemon/crash-cart");
|
||||
const home = getOpenRigHome();
|
||||
const openrigUrl = process.env.OPENRIG_URL;
|
||||
const openrigUrl = readOpenRigUrl();
|
||||
const probeClassified = (url: string) =>
|
||||
cc.probeHealthz(url, { fetch: (u, init) => fetch(u, init as RequestInit), timeoutMs: 700 });
|
||||
const detectDeps = {
|
||||
@@ -83,9 +91,7 @@ async function realEmit(): Promise<CrashCartEmit> {
|
||||
? `alive (pid ${state.pid})`
|
||||
: `dead (pid ${state.pid})`
|
||||
: "no daemon.json";
|
||||
const url = openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
const url = crashCartEvidenceUrl(openrigUrl, state);
|
||||
const probeResult = await probeClassified(url);
|
||||
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface EdgeEnd {
|
||||
logicalId: string;
|
||||
nodeId: string;
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
interface EdgeSuccess {
|
||||
@@ -39,7 +39,8 @@ function report(result: EdgeOpResult, opts: { json?: boolean }): void {
|
||||
switch (result.outcome) {
|
||||
case "would_add":
|
||||
console.log(`plan add ${line} in rig ${result.rigId}`);
|
||||
console.log(` from node ${edge.from.nodeId}, to node ${edge.to.nodeId}`);
|
||||
console.log(` from node ${edge.from.nodeId}, to ${edge.to.nodeId === null
|
||||
? `${edge.to.logicalId} (resolved when used)` : `node ${edge.to.nodeId}`}`);
|
||||
console.log(" nothing written (--plan)");
|
||||
break;
|
||||
case "added":
|
||||
@@ -124,10 +125,10 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
.description("Ensure one edge between two existing seats; an existing edge is unchanged")
|
||||
.description("Ensure one typed edge; an existing edge is unchanged")
|
||||
.argument("<rig>", "Target rig name or ID")
|
||||
.argument("[from]", "Source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target seat logical ID, such as review.reviewer")
|
||||
.argument("[from]", "Existing source seat logical ID, such as orch.lead")
|
||||
.argument("[to]", "Target logical ID, or a same-daemon address for escalates_to")
|
||||
.option("--kind <kind>", "delegates_to, spawned_by, can_observe, collaborates_with or escalates_to")
|
||||
.option("--plan", "Validate the request and show the change without writing")
|
||||
.option("--json", "JSON output for agents")
|
||||
@@ -135,8 +136,12 @@ export function edgeCommand(depsOverride?: StatusDeps): Command {
|
||||
Examples:
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to
|
||||
rig edge add my-rig orch.lead review.reviewer --kind delegates_to --plan --json
|
||||
rig edge add my-rig work.lead review-advisor@coord --kind escalates_to
|
||||
|
||||
Endpoints are fully qualified logical IDs for both same-pod and cross-pod edges.
|
||||
Use existing, fully qualified logical IDs in <rig> for both same-pod and
|
||||
cross-pod edges. For escalates_to only, the target may instead be a canonical
|
||||
same-daemon seat address (pod-member@rig), resolved when used; that rig or seat
|
||||
need not exist yet. Cross-host and synthetic destinations are unsupported.
|
||||
Run it once per edge. A repeat reports the existing edge and its ID, and writes
|
||||
nothing. No seats or sessions are changed.`)
|
||||
.action(async (rig: string, from: string | undefined, to: string | undefined, opts: { kind?: string; plan?: boolean; json?: boolean }) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executo
|
||||
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
import { parseFeedHostSubscriptionKey } from "../config-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rig host pair — the approval wait is BOUNDED.
|
||||
@@ -71,6 +72,46 @@ export interface DoctorDeps {
|
||||
* sidecar (TOFU + loud-conflict). Optional so existing fixtures keep compiling AND unit runs never
|
||||
* write the real sidecar — absent means learning is skipped, which is the fail-open contract. */
|
||||
learnHostBinding?: (alias: string, observedHostId: string, observedUrl: string) => HostObservationOutcome;
|
||||
/** The registry entry a reply addressed `@<hostId>` resolves to, by the lookup `rig send` makes.
|
||||
* Optional like the learner: absent means doctor makes no claim about where replies go. */
|
||||
replyRoute?: (hostId: string) => HostEntry | null;
|
||||
/** The self-id learned for a registry alias, if any. */
|
||||
learnedHostId?: (alias: string) => string | null;
|
||||
}
|
||||
|
||||
/** Where `rig send` takes a target ending `@<hostId>`: registry id, then a declared hostId, then the
|
||||
* first hosts.yaml entry with that learned binding (cross-host-target.ts). Null when none matches. */
|
||||
export function replyRouteFromDisk(hostId: string): HostEntry | null {
|
||||
const loaded = loadHostRegistry();
|
||||
if (!loaded.ok) return null;
|
||||
const resolved = resolveHost(loaded.registry, hostId, loadHostBindings().bindings);
|
||||
return resolved.ok ? resolved.host : null;
|
||||
}
|
||||
|
||||
const REPLY_ROUTE_ORDER = "rig send resolves it by registry id, then a declared hostId, then the first hosts.yaml entry that learned it";
|
||||
|
||||
/** A reachable entry whose self-id another entry answers for. Unknown, not fail: doctor can't tell
|
||||
* from here whether that other entry still reaches the machine. */
|
||||
function replyRouteElsewhereRow(host: HostEntry, hostId: string, winner: HostEntry): CheckRow {
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "unknown",
|
||||
detail: `replies addressed @${hostId} go to '${winner.id}', not '${host.id}' (${REPLY_ROUTE_ORDER})`,
|
||||
fix: `run rig host doctor ${winner.id}; if it no longer reaches this machine, delete its entry from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** An unreachable entry that replies still resolve to: the failure a stale entry causes, named on
|
||||
* the entry itself. Null when the entry has no self-id or another entry answers for it. */
|
||||
function replyRouteThroughDeadEntryRow(host: HostEntry, deps: DoctorDeps): CheckRow | null {
|
||||
const hostId = host.hostId ?? deps.learnedHostId?.(host.id) ?? null;
|
||||
if (!hostId || deps.replyRoute?.(hostId)?.id !== host.id) return null;
|
||||
return {
|
||||
step: "reply-route",
|
||||
status: "fail",
|
||||
detail: `replies addressed @${hostId} resolve to '${host.id}', so they fail while it is unreachable`,
|
||||
fix: `if another entry reaches this machine, delete '${host.id}' from ${defaultHostRegistryPath()} (and its line in ${defaultHostBindingsPath()}, if any), then run rig host doctor on that entry; otherwise restore this entry's connectivity`,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
|
||||
@@ -114,6 +155,8 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
sock.on("error", () => { sock.destroy(); resolve("closed"); });
|
||||
}),
|
||||
learnHostBinding: (alias, observedHostId, observedUrl) => recordHostObservation({ alias, observedHostId, observedUrl }),
|
||||
replyRoute: replyRouteFromDisk,
|
||||
learnedHostId: (alias) => loadHostBindings().bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +206,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
|
||||
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
|
||||
});
|
||||
const routeRow = reach.failedStep === "ssh-unreachable" ? replyRouteThroughDeadEntryRow(host, deps) : null;
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows; // later legs are meaningless without the shell
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
|
||||
@@ -255,20 +300,28 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
const parsed = JSON.parse(health.body) as { selfHostId?: unknown };
|
||||
if (typeof parsed.selfHostId === "string" && parsed.selfHostId.length > 0) {
|
||||
const obs = deps.learnHostBinding(host.id, parsed.selfHostId, (host as HttpHostEntry).url);
|
||||
rows.push(obs.outcome === "conflict"
|
||||
? {
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
}
|
||||
: {
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${obs.binding.hostId}' (first contact) — reply hints ending @${obs.binding.hostId} now resolve to '${host.id}'`
|
||||
: `host identity '${obs.binding.hostId}' confirmed`,
|
||||
});
|
||||
if (obs.outcome === "conflict") {
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "fail",
|
||||
detail: describeBindingConflict(host.id, obs.binding),
|
||||
fix: `if the re-key is legitimate, delete the '${host.id}' entry in ${defaultHostBindingsPath()} and re-run doctor to re-learn`,
|
||||
});
|
||||
} else {
|
||||
// Ask the route rig send takes rather than assume it: an entry listed earlier in
|
||||
// hosts.yaml with the same identity keeps the replies, even when it no longer answers.
|
||||
const id = obs.binding.hostId;
|
||||
const winner = deps.replyRoute?.(id) ?? null;
|
||||
const routedHere = winner?.id === host.id;
|
||||
rows.push({
|
||||
step: "host-identity-binding",
|
||||
status: "pass",
|
||||
detail: obs.outcome === "bound"
|
||||
? `learned host identity '${id}' (first contact)${routedHere ? ` — reply hints ending @${id} now resolve to '${host.id}'` : ""}`
|
||||
: `host identity '${id}' confirmed`,
|
||||
});
|
||||
if (winner && !routedHere) rows.push(replyRouteElsewhereRow(host, id, winner));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// body not JSON — an older or non-daemon endpoint; learning is skipped, nothing breaks.
|
||||
@@ -276,6 +329,8 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
}
|
||||
} catch (err) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
|
||||
const routeRow = replyRouteThroughDeadEntryRow(host, deps);
|
||||
if (routeRow) rows.push(routeRow);
|
||||
return rows;
|
||||
}
|
||||
rows.push({
|
||||
@@ -481,6 +536,21 @@ function authPointer(h: HostEntry): string {
|
||||
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
|
||||
}
|
||||
|
||||
function printHostNextSteps(host: HostEntry): void {
|
||||
console.log(`Verify it: rig host doctor ${host.id}`);
|
||||
if (host.transport !== "http") {
|
||||
console.log("Remote FEED requires an HTTP host entry. Pair its daemon URL with: rig host pair <url>");
|
||||
return;
|
||||
}
|
||||
const key = `feed.subscriptions.${host.id}.enabled`;
|
||||
if (!parseFeedHostSubscriptionKey(key)) {
|
||||
console.log(`For remote FEED, register this host with a different id: '${host.id}' is reserved by feed settings.`);
|
||||
return;
|
||||
}
|
||||
console.log("Remote FEED is off by default. To include this host's attention items, run locally:");
|
||||
console.log(` rig config set ${key} true`);
|
||||
}
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
|
||||
@@ -516,7 +586,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
|
||||
console.log(`Verify it: rig host doctor ${res.entry.id}`);
|
||||
printHostNextSteps(res.entry);
|
||||
});
|
||||
|
||||
cmd
|
||||
@@ -786,7 +856,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
return;
|
||||
}
|
||||
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
|
||||
console.log(`Verify it: rig host doctor ${id}`);
|
||||
printHostNextSteps(added.entry);
|
||||
});
|
||||
|
||||
cmd
|
||||
|
||||
@@ -55,11 +55,14 @@ stays the endpoint; a proxy in front forwards the metadata path unchanged.`)
|
||||
let daemonUrl: string;
|
||||
if (opts.port) {
|
||||
const daemonPort = parseInt(opts.port, 10);
|
||||
if (isNaN(daemonPort)) {
|
||||
if (Number.isNaN(daemonPort)) {
|
||||
console.error("Invalid port number");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (daemonPort < 1 || daemonPort > 65535) {
|
||||
console.error(`Warning: daemon port ${daemonPort} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`);
|
||||
}
|
||||
daemonUrl = `http://127.0.0.1:${daemonPort}`;
|
||||
} else {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
|
||||
@@ -515,13 +515,10 @@ function notThisCreate(row: Record<string, unknown>, sent: SentCreate, storedSou
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 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: this one for a local row, the destination's registered daemon for a
|
||||
* cross-host one. Only a row that is this create counts as committed; anything else leaves the outcome unknown. 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 }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
/** 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);
|
||||
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) {
|
||||
@@ -529,15 +526,45 @@ async function readBackCreate(deps: QueueDeps, client: DaemonClient, hostId: str
|
||||
const match = registry.ok ? resolveHost(registry.registry, remote, loadHostBindings().bindings) : undefined;
|
||||
if (!match?.ok || match.host.transport !== "http") {
|
||||
const why = match?.ok ? "its entry in this CLI's host registry is SSH, not a daemon URL" : "it isn't in this CLI's host registry";
|
||||
return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${why}).` };
|
||||
return { error: `from host '${remote}' (${why})` };
|
||||
}
|
||||
const bearer = resolveRemoteBearer(match.host);
|
||||
if (!bearer.ok) return { row: null, note: `This CLI couldn't read the row back from host '${remote}' (${bearer.error}).` };
|
||||
if (!bearer.ok) return { error: `from host '${remote}' (${bearer.error})` };
|
||||
reader = deps.clientFactory(match.host.url);
|
||||
if (bearer.token) options = { ...options, headers: { Authorization: `Bearer ${bearer.token}` } };
|
||||
}
|
||||
return { reader, options };
|
||||
}
|
||||
|
||||
/** A successful forward isn't a delivery receipt. Never accept a different remote row as this create's receipt. */
|
||||
function createReceiptReader(deps: QueueDeps, client: DaemonClient, hostId: string | undefined, qitemId: string, sent: SentCreate): Pick<DaemonClient, "get"> {
|
||||
if (!remoteHostOf(hostId)) return client;
|
||||
let target: ReturnType<typeof queueReadTarget> | undefined;
|
||||
let source: ReturnType<typeof storedSourceOf> | undefined;
|
||||
return {
|
||||
get: async <T>(path: string) => {
|
||||
const selected = await (target ??= queueReadTarget(deps, client, hostId));
|
||||
if ("error" in selected) throw new Error(`receipt could not be read ${selected.error}`);
|
||||
const response = await selected.reader.get<T>(path, selected.options);
|
||||
const row = response.data as Record<string, unknown> | undefined;
|
||||
if (response.status === 200 && (!row || row.qitemId !== qitemId
|
||||
|| notThisCreate(row, sent, await (source ??= storedSourceOf(client, sent))) !== null)) {
|
||||
throw new Error("receipt row does not match the created qitem");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 }> {
|
||||
const remote = remoteHostOf(hostId);
|
||||
const where = remote ? ` on host '${remote}'` : "";
|
||||
const target = await queueReadTarget(deps, client, hostId);
|
||||
if ("error" in target) return { row: null, note: `This CLI couldn't read the row back ${target.error}.` };
|
||||
try {
|
||||
const res = await reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, options);
|
||||
const res = await target.reader.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`, target.options);
|
||||
if (res.status === 200 && res.data?.qitemId === qitemId) {
|
||||
const differs = notThisCreate(res.data, sent, await storedSourceOf(client, sent));
|
||||
if (differs === "unread") {
|
||||
@@ -1079,7 +1106,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (opts.verify && status < 400) {
|
||||
const createdId = typeof created.qitemId === "string" ? created.qitemId : null;
|
||||
const delivery = createdId
|
||||
? await waitForDeliveryOutcome(client, createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
? await waitForDeliveryOutcome(createReceiptReader(deps, client, hostResolved.hostId, qitemId, sent), createdId, deps.deliveryVerify, { destinationSession: hostResolved.destination, nudge: opts.nudge })
|
||||
: unreadableDeliveryResult(null, hostResolved.destination, {}, "create response did not include a qitem id; delivery cannot be correlated");
|
||||
await printWriteResult(client, opts.json ?? false, { ...created, qitemId: createdId, persisted: true, delivery }, status, receipt);
|
||||
return;
|
||||
|
||||
@@ -1068,7 +1068,7 @@ function buildResolveNotesCommand(): Command {
|
||||
|
||||
function buildAuditCommand(): Command {
|
||||
return new Command("audit")
|
||||
.description("Read-only scope audit: flag scope findings and show the advisory dependency graph")
|
||||
.description("Read-only scope audit: flag scope structure findings and show the advisory dependency graph. It doesn't read proof readiness: use rig proof show")
|
||||
.requiredOption("--mission <name>", "Mission to audit")
|
||||
.option("--json", "Machine-readable JSON output")
|
||||
.action(async (opts, command) => {
|
||||
@@ -1076,7 +1076,7 @@ function buildAuditCommand(): Command {
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const { classifyScopeItem } = await import("../lib/scope/scope-audit.js");
|
||||
const { classifyScopeItem, SLICE_DIRECTORY_NAME } = await import("../lib/scope/scope-audit.js");
|
||||
const missionName = opts.mission as string;
|
||||
|
||||
const missionDir = path.join(missionsRoot, missionName);
|
||||
@@ -1215,7 +1215,7 @@ function buildAuditCommand(): Command {
|
||||
: null,
|
||||
});
|
||||
|
||||
if (!/^\d{2,}-/.test(entry)) {
|
||||
if (!SLICE_DIRECTORY_NAME.test(entry)) {
|
||||
sliceResult.findings.push({
|
||||
kind: "id_convention_violation",
|
||||
severity: "high",
|
||||
|
||||
@@ -9,17 +9,17 @@ import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host
|
||||
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { requestMayHaveArrived, runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
|
||||
import { COMPOSER_PROMPT_CLASS } from "@openrig/daemon/composer-prompts";
|
||||
import { resolveContextRef, walkSizedWarning } from "../context-resolve.js";
|
||||
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
// A cross-host send waits for the remote to deliver, and the remote has no fixed deadline of its own (its tmux
|
||||
// calls run without a timeout), so no client budget is exact. This one sits well above a normal send, and above
|
||||
// fetch's 10 s connect timeout, so a host that never accepts the connection still reads as unreachable. A send
|
||||
// that outlasts it reads as unconfirmed, never as failed.
|
||||
const CROSS_HOST_SEND_TIMEOUT_MS = 30_000;
|
||||
// The reply follows the guarded paste/Enter (and optional verification), not acceptance of the request.
|
||||
// Successful sends can exceed the ordinary client's 5 s read budget. Keep a finite response wait on both
|
||||
// local and remote paths; a longer send or lost response remains unconfirmed and is never retried here.
|
||||
const SEND_RESPONSE_TIMEOUT_MS = 60_000;
|
||||
const UNCONFIRMED_SEND_GUIDANCE = "A busy agent may hold delivered input off-screen, so absence from a pane capture does not prove non-delivery. " +
|
||||
"Wait for recipient acknowledgment or reconcile with the recipient before resending; another send or queue item risks duplicate work.";
|
||||
|
||||
/** The shared composer glyph set (Claude `❯`, Codex `›`, Codex 0.153 `»`) from the daemon's
|
||||
* composer-prompts surface, so the CLI staged check can never disagree with the daemon guard. */
|
||||
@@ -140,8 +140,7 @@ function printTransportFailure(err: DaemonConnectionError, opts?: { json?: boole
|
||||
? "Delivery UNCONFIRMED — the daemon may have received and delivered the message."
|
||||
: "The message was not sent.";
|
||||
const action = outcomeUnknown
|
||||
? "Reconcile by EFFECT before any resend: check the pane/target for the message (rig capture <session>). " +
|
||||
"A resend without checking risks a duplicate. Then 'rig daemon status' for the probe picture."
|
||||
? "Reconcile by EFFECT before any resend: check the target (rig capture <session>). " + UNCONFIRMED_SEND_GUIDANCE
|
||||
: "Inspect the configured target with 'rig status'; a failed health probe does not prove the daemon is stopped. " +
|
||||
"If the target is wrong, check OPENRIG_URL / RIGGED_URL or daemon.host + daemon.port. " +
|
||||
"If the daemon is confirmed stopped, run 'rig daemon start'.";
|
||||
@@ -187,7 +186,7 @@ export function refuseEmptyMessage(body: string, verb: "send" | "broadcast", jso
|
||||
* keeps the single-seat and fan-out callers from having to sequence it
|
||||
* identically in two places. */
|
||||
async function resolveLocalDaemonUrl(deps: SendDeps): Promise<string> {
|
||||
const envUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const envUrl = readOpenRigUrl();
|
||||
if (envUrl) return envUrl;
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state === "running" && status.port !== undefined) return getDaemonUrl(status);
|
||||
@@ -283,7 +282,11 @@ determined (unknown / missing / stale telemetry) the send PROCEEDS with an
|
||||
advisory note — telemetry is advisory, not authority over whether agents can
|
||||
communicate. Use --wait-for-idle to send only after explicit idle evidence. Use
|
||||
--verify to confirm the message appeared in the pane only; it is not agent
|
||||
acknowledgement. With transport.verify_input_box on, a Claude Code or Codex send
|
||||
acknowledgement. Local and HTTP sends wait up to 60 s for the response, plus any
|
||||
--wait-for-idle duration. A timeout or missing pane text does not prove the
|
||||
message was not delivered: a busy agent can hold input off-screen. Reconcile
|
||||
with the recipient before resending or filing the same work as a queue item.
|
||||
With transport.verify_input_box on, a Claude Code or Codex send
|
||||
reads verified only when the seat was proven idle before the paste (its screen
|
||||
matched an idle layout recorded from Claude Code 2.1.296 or Codex 0.160.0; a
|
||||
screen that names another version reads unconfirmed, and a matching layout that
|
||||
@@ -880,7 +883,7 @@ async function runCrossHostSend(
|
||||
* sender verbatim — honest provenance; unknown on the remote it degrades to
|
||||
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
|
||||
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
|
||||
* locally synthesized). Deadline: CROSS_HOST_SEND_TIMEOUT_MS, plus waitForIdleMs
|
||||
* locally synthesized). Deadline: SEND_RESPONSE_TIMEOUT_MS, plus waitForIdleMs
|
||||
* when --wait-for-idle. A send that may have arrived reads as unconfirmed.
|
||||
*
|
||||
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer
|
||||
@@ -918,7 +921,7 @@ async function runHttpHostSend(
|
||||
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
...(opts.urgent ? { urgent: true } : {}),
|
||||
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
|
||||
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS });
|
||||
|
||||
// A send that may have reached the remote is unconfirmed, not unreachable: it may have been delivered, and a
|
||||
// resend without checking risks a duplicate.
|
||||
@@ -926,7 +929,7 @@ async function runHttpHostSend(
|
||||
const shown = unconfirmed ? { ...result, failedStep: "remote-outcome-unknown" as const } : result;
|
||||
const unconfirmedGuidance = {
|
||||
consequence: "Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.",
|
||||
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. A resend without checking risks a duplicate.`,
|
||||
action: `Check the target before any resend: rig capture ${session} --host ${host.id}. ${UNCONFIRMED_SEND_GUIDANCE}`,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
@@ -1162,18 +1165,11 @@ function parseWaitForIdleMs(value: string | undefined): number | undefined | nul
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
|
||||
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
|
||||
if (waitForIdleMs === undefined) return undefined;
|
||||
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
|
||||
}
|
||||
|
||||
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
|
||||
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
|
||||
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs: number; headers?: Record<string, string> } {
|
||||
const headers = terminalAuthHeaders();
|
||||
const hasHeaders = Object.keys(headers).length > 0;
|
||||
if (!waitOptions && !hasHeaders) return undefined;
|
||||
return {
|
||||
...(waitOptions ?? {}),
|
||||
timeoutMs: (waitForIdleMs ?? 0) + SEND_RESPONSE_TIMEOUT_MS,
|
||||
...(hasHeaders ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,6 +191,13 @@ Examples:
|
||||
When the daemon is restoring a kernel a reboot left down, rig start goes on to
|
||||
your other rigs. Exit 0 then does not mean the kernel is ready (check rig
|
||||
status); a restore that returned without a ready kernel exits 1.
|
||||
|
||||
--last restores a rig unless every seat it launched was stopped on purpose. A
|
||||
rig taken down with rig down before OpenRig 0.6.12, or whose seats exited or
|
||||
were lost without rig down, counts as last running: run rig down <rig> once
|
||||
(its id when names are shared) and --last keeps it down. --last never selects
|
||||
the kernel; under --no-kernel its automatic restore is skipped too, so bring it
|
||||
back with rig up kernel --existing.
|
||||
`);
|
||||
const getDepsF = (): StartDeps =>
|
||||
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { readOpenRigUrl } from "../openrig-compat.js";
|
||||
|
||||
export interface StartupProofDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -33,7 +34,9 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
|
||||
const env = deps.env ?? process.env;
|
||||
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
|
||||
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
|
||||
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
|
||||
// Startup proof has always trimmed these and skipped blank ones; keep that, then pick and
|
||||
// normalize through the shared reader like every other OPENRIG_URL reader.
|
||||
let baseUrl = readOpenRigUrl({ OPENRIG_URL: env["OPENRIG_URL"]?.trim(), RIGGED_URL: env["RIGGED_URL"]?.trim() }) ?? null;
|
||||
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
|
||||
@@ -17,22 +17,30 @@
|
||||
// exit 0 unless the daemon is unreachable.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
|
||||
|
||||
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
|
||||
export interface FollowControls {
|
||||
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
|
||||
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
|
||||
}
|
||||
|
||||
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
|
||||
export interface OpenViewResult {
|
||||
workspaceId?: string;
|
||||
following?: { id: string; enabled: boolean; state: "following" | "empty" | "retained" | "disabled";
|
||||
maxTiles: number; tiles: Array<{ key: string; seat: string; departedAt?: number; reason?: string }>;
|
||||
events: string[]; overflow: Array<{ key: string; seat: string; reason: string }>; nextAt?: number };
|
||||
provider: string;
|
||||
session?: string;
|
||||
ok: boolean;
|
||||
opened: string[];
|
||||
absent: { seat: string; host: string | null; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string; code?: "pane_unconfirmed" }[];
|
||||
pages: number;
|
||||
error?: string;
|
||||
code?: string;
|
||||
@@ -76,14 +84,19 @@ function humanOpen(r: OpenViewResult): string {
|
||||
? `Terminal window requested. Reused ${target} workspace ${r.reusedWorkspace.id} for view ${JSON.stringify(r.reusedWorkspace.view)}.`
|
||||
: tiled > 0
|
||||
? `${r.window ? "Terminal window requested. " : ""}Prepared ${tiled} tile(s) in ${target}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
|
||||
: `No tiles opened in ${target}.`,
|
||||
: `No tiles ${r.degraded.some(d => d.code === "pane_unconfirmed") ? "confirmed" : "opened"} in ${target}.`,
|
||||
);
|
||||
if (r.window) lines.push(` Terminal: ${r.window.app} (${r.window.surface}).`);
|
||||
if (r.space) lines.push(` ${r.provider} session: ${JSON.stringify(r.space.label)} (${r.space.id}).`);
|
||||
if (r.error && r.code !== "terminal_window_failed") lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
|
||||
if (r.following) {
|
||||
lines.push(` Following: ${r.following.state}; tile limit ${r.following.maxTiles}.`);
|
||||
for (const tile of r.following.tiles) if (tile.reason) lines.push(` ${tile.seat}: ${tile.reason}`);
|
||||
for (const hidden of r.following.overflow) lines.push(` ${hidden.seat}: ${hidden.reason}`);
|
||||
}
|
||||
for (const seat of r.opened) lines.push(` ● ${seat}`);
|
||||
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
|
||||
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
|
||||
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — ${d.code === "pane_unconfirmed" ? "unconfirmed" : "skipped"} (${d.host}): ${d.reason}`);
|
||||
for (const n of r.notes ?? []) lines.push(` note: ${n}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -95,7 +108,7 @@ function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
} else {
|
||||
console.log(humanOpen(r));
|
||||
}
|
||||
if (status >= 400 || (r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
|
||||
if (status >= 400 || (r.following ? !r.ok : r.opened.length === 0 && !(r.ok && r.reusedWorkspace))) {
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
@@ -113,27 +126,43 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, mission:<id>, slice:<id>, qitem:<id> (current local obligation owners), or a saved-view id")
|
||||
.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, 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)")
|
||||
.option("--expected-plan <id>", "Open only if the view still matches this preview")
|
||||
.option("--follow", "Keep one qitem:<id> workspace following current obligation owners (Linux Herdr 0.9.5/protocol 22)")
|
||||
.option("--no-follow", "Stop following and keep the existing workspace and tiles as a fixed layout")
|
||||
.option("--max-tiles <count>", "Following tile limit, 1–256; explicitly raise it to show more members", Number)
|
||||
.option("--pin <seats...>", "Retain these following tiles after they leave the task")
|
||||
.option("--unpin <seats...>", "Remove pins from these following tiles")
|
||||
.option("--suppress <seats...>", "Temporarily hide these following tiles; agents keep running")
|
||||
.option("--unsuppress <seats...>", "Show these following members again")
|
||||
.option("--suppression-ms <ms>", "Suppression duration, default 60000, at most 86400000", Number)
|
||||
.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 }) => {
|
||||
.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();
|
||||
const session = opts.session?.trim();
|
||||
const { maxTiles, pin, unpin, suppress, unsuppress, suppressionMs, graceMs, focusRetentionMs } = opts;
|
||||
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;
|
||||
await withClient(deps, async (client) => {
|
||||
// Herdr's control socket can answer with no desktop client attached.
|
||||
// The default requests a desktop; an explicit provider reuses its workspace.
|
||||
if (opts.window || !opts.provider) {
|
||||
const result = await openTerminalWindow(client, view, opts.provider, deps.windowDeps, opts.expectedPlan, session);
|
||||
if (following?.enabled !== false && (opts.window || !opts.provider)) {
|
||||
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;
|
||||
if (session !== undefined) {
|
||||
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider)}&session=${encodeURIComponent(session)}`);
|
||||
const preview = await client.get<{ session?: string; planId?: string; error?: string }>(`/api/terminal/preview?view=${encodeURIComponent(view)}&provider=${encodeURIComponent(opts.provider ?? "herdr")}&session=${encodeURIComponent(session)}`);
|
||||
if (preview.status >= 400 || preview.data.session !== session || typeof preview.data.planId !== "string") {
|
||||
printResult(opts.json ?? false, { error: preview.data.error ?? "The daemon did not confirm the requested Herdr session. Update the daemon before opening a named session.", code: "terminal_session_unconfirmed" }, preview.status >= 400 ? preview.status : 409); return;
|
||||
}
|
||||
@@ -143,7 +172,8 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
expectedPlan = preview.data.planId;
|
||||
}
|
||||
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}), ...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) };
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", body, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS });
|
||||
const res = await client.post<OpenViewResult>(following ? "/api/terminal/follow" : "/api/terminal/open",
|
||||
{ ...body, ...following }, { timeoutMs: TERMINAL_OPEN_TIMEOUT_MS, ...(following ? { headers: terminalAuthHeaders() } : {}) });
|
||||
if (session !== undefined && res.status < 400 && res.data?.session !== session) {
|
||||
printResult(opts.json ?? false, { ...res.data, ok: false, code: "terminal_session_unconfirmed",
|
||||
error: "The open response did not confirm the requested Herdr session. Its outcome is unknown; inspect that session before retrying." }, 409); return;
|
||||
|
||||
@@ -76,7 +76,7 @@ OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0 — relaunch it to restore scrollback.
|
||||
const requestedLines = Number.isNaN(tailLines) || tailLines < 1 ? 50 : tailLines;
|
||||
const apiPath = useGrep
|
||||
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
|
||||
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
|
||||
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${requestedLines}`;
|
||||
|
||||
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
|
||||
// transcript routes — the SAME paths the local path builds, origin
|
||||
|
||||
@@ -7,7 +7,7 @@ export { daemonMayAnswer } from "./daemon-reachability.js";
|
||||
import type { DaemonStartLock } from "./daemon-start-lock.js";
|
||||
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv, readOpenRigUrl } from "./openrig-compat.js";
|
||||
// Types only: the module itself (with the daemon's yaml-reading system world) is imported where an
|
||||
// instance is initialized, so a command that only talks to the daemon never loads it.
|
||||
import type {
|
||||
@@ -72,6 +72,18 @@ export interface GetDaemonStatusOptions {
|
||||
cleanupStaleState?: boolean;
|
||||
}
|
||||
|
||||
/** True for `http://host:port` with an explicit, non-default port and no path, query or
|
||||
* fragment: the only OPENRIG_URL shape getDaemonUrl's host-and-port rebuild reaches unchanged
|
||||
* (without a port, or with :80, which URL reports as no port, status falls back to 7433). */
|
||||
function isPlainHttpOrigin(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "http:" && u.port !== "" && u.pathname === "/" && !u.search && !u.hash && !url.endsWith("/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
|
||||
@@ -905,7 +917,7 @@ function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: Daem
|
||||
export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-target"> {
|
||||
const state = readState(deps);
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const explicitUrl = readOpenRigUrl();
|
||||
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
|
||||
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
|
||||
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
|
||||
@@ -1028,8 +1040,14 @@ export async function getDaemonStatus(
|
||||
deps: LifecycleDeps,
|
||||
options: GetDaemonStatusOptions = {},
|
||||
): Promise<DaemonStatus> {
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly
|
||||
const openrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
// If OPENRIG_URL is set, bypass daemon.json and probe that URL directly. Commands reach
|
||||
// it through getDaemonUrl's http://host:port rebuild, so only a plain http origin uses
|
||||
// the normalized form; any other URL is probed as written, exactly as before.
|
||||
const rawOpenrigUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const normalizedOpenrigUrl = readOpenRigUrl();
|
||||
const openrigUrl = normalizedOpenrigUrl !== undefined && isPlainHttpOrigin(normalizedOpenrigUrl)
|
||||
? normalizedOpenrigUrl
|
||||
: rawOpenrigUrl;
|
||||
if (openrigUrl) {
|
||||
// An unverified daemon keeps the endpoint it was probed at, so a command can still make its request.
|
||||
const endpoint = (() => {
|
||||
|
||||
@@ -166,6 +166,11 @@ export function deriveMissionDependencyGraph(input: MissionDependencyGraphInput)
|
||||
// without this file changing too).
|
||||
export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
|
||||
|
||||
// A slice directory's name: two or more digits, a dash, then the slug. `rig scope slice create` numbers past 99
|
||||
// (100-…), so a two-digit-only check flags names OpenRig made. The CLI audit and the daemon's audit route both read
|
||||
// this one pattern.
|
||||
export const SLICE_DIRECTORY_NAME = /^\d{2,}-/;
|
||||
|
||||
function childPath(parent: string, child: string): string {
|
||||
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
|
||||
}
|
||||
|
||||
@@ -554,8 +554,9 @@ export function findSlice(
|
||||
continue;
|
||||
}
|
||||
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
|
||||
const sliceRoot = path.dirname(candidate);
|
||||
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
|
||||
// The directory and the name both come from the resolved path, so `<slice>/.` and `<slice>/sub/..` select the slice.
|
||||
const resolved = path.resolve(candidate);
|
||||
return buildSliceInfo(mission, path.dirname(resolved), path.basename(resolved));
|
||||
}
|
||||
}
|
||||
if (firstContextError) throw firstContextError;
|
||||
@@ -567,9 +568,18 @@ export function findSlice(
|
||||
}
|
||||
|
||||
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
|
||||
// slice the same way so a relative root and a relative candidate stay comparable.
|
||||
// A path that leaves the root, such as `<root>/../other/m1/slices/x`, then falls back
|
||||
// to the walk as written, where it reads as before.
|
||||
return walkToMission(path.resolve(missionsRoot), path.resolve(slicePath)) ?? walkToMission(missionsRoot, slicePath);
|
||||
}
|
||||
|
||||
function walkToMission(root: string, slicePath: string): string | null {
|
||||
let dir = path.dirname(slicePath);
|
||||
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
|
||||
if (path.dirname(dir) === missionsRoot) return dir;
|
||||
while (dir.startsWith(root) && dir !== root) {
|
||||
if (path.dirname(dir) === root) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -530,12 +530,13 @@ export const MCP_OPERATIONS: ReadonlyArray<{ tool: string; routes: readonly stri
|
||||
|
||||
/** How a member works its inbox, and asks a person while keeping the work: one versioned text, carried in this server's
|
||||
* instructions and quoted in docs/reference/hosted-members.md. Change it here, bump the version, and update the quote. */
|
||||
export const MEMBER_WORKFLOW_VERSION = "member-workflow/v1";
|
||||
export const MEMBER_WORKFLOW_VERSION = "member-workflow/v2";
|
||||
/** What the connection itself authorizes. */
|
||||
const MEMBER_AUTHORITY = "If the person connected you, that's basically authorized: claim the items in your inbox and do them without asking whether you may.";
|
||||
export const MEMBER_WORKFLOW = [
|
||||
`Member workflow (${MEMBER_WORKFLOW_VERSION}). Your inbox is the items addressed to your seat: rig_queue_list with owned set. It's yours, not the team's queue. A wake, a ring or a message only points at it: list your inbox, then act.`,
|
||||
"For each item: claim it (rig_queue_claim), do it, then close it done with the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it.",
|
||||
"For each item: claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update), or hand it on (rig_queue_handoff). Listing or reading an item doesn't take it.",
|
||||
"Say what you can reach from your grant, not from the tool list: a listed tool isn't access. rig_artifact_read's answer for an item names the shared roots you may read, and none means no shared files.",
|
||||
MEMBER_AUTHORITY,
|
||||
"When an item needs a person's approval before you can finish it, don't close it and don't take the action: keep the item and ask.",
|
||||
"If it came from a person (an address ending in @external that isn't a shared channel; the item's 'Shared channel:' line names one), set it blocked with blockedOn that person and, as its summary, the question with a short form of what you'll do: the person sees the summary and the item, not your note, which keeps the record. Link a longer draft as an https evidenceRef. Their answer returns the item to you in progress.",
|
||||
@@ -1349,7 +1350,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().describe("The queue item to update"),
|
||||
state: z.string().optional().describe("New state (e.g. in-progress, blocked, done)"),
|
||||
closureReason: z.string().optional().describe("Why it closed (as rig queue update --closure-reason)"),
|
||||
closureReason: z.string().optional().describe("Why it closed: no-follow-on for finished work, or handed_off_to, denied, canceled, escalation or superseded (as rig queue update --closure-reason)"),
|
||||
closureTarget: z.string().optional().describe("Closure target, when the reason needs one"),
|
||||
note: z.string().optional().describe("Transition note: progress, or why this transition happened (required to reopen)"),
|
||||
resolution: z.string().optional().describe("The outcome the item keeps, stored only by the update that closes an open item (as rig queue update --resolution)"),
|
||||
|
||||
@@ -55,12 +55,12 @@ function warnOnce(key: string, message: string): void {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
|
||||
const primaryValue = process.env[primary];
|
||||
export function readOpenRigEnv(primary: string, legacy?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const primaryValue = env[primary];
|
||||
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
|
||||
|
||||
if (legacy) {
|
||||
const legacyValue = process.env[legacy];
|
||||
const legacyValue = env[legacy];
|
||||
if (legacyValue !== undefined && legacyValue !== "") {
|
||||
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
|
||||
return legacyValue;
|
||||
@@ -70,6 +70,23 @@ export function readOpenRigEnv(primary: string, legacy?: string): string | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** An explicit daemon URL with trailing slashes removed, so `http://h:7433/` addresses the
|
||||
* same daemon as `http://h:7433`. A URL with a query or fragment is returned exactly as
|
||||
* written, and so is a value that would strip down to no host (such as `/`), which stays
|
||||
* an explicit (if invalid) target. */
|
||||
export function normalizeOpenRigUrl(raw: string): string {
|
||||
if (/[?#]/.test(raw)) return raw;
|
||||
const stripped = raw.replace(/\/+$/, "");
|
||||
return /:\/\/[^/]/.test(stripped) ? stripped : raw;
|
||||
}
|
||||
|
||||
/** OPENRIG_URL (legacy RIGGED_URL), normalized by normalizeOpenRigUrl. `env` defaults to
|
||||
* process.env; callers with an injected environment pass theirs. */
|
||||
export function readOpenRigUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const raw = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL", env);
|
||||
return raw === undefined ? undefined : normalizeOpenRigUrl(raw);
|
||||
}
|
||||
|
||||
export function getPreferredOpenRigHome(): string {
|
||||
const openrigHome = getOpenRigHome();
|
||||
const legacyRiggedHome = getLegacyRiggedHome();
|
||||
|
||||
@@ -4,11 +4,11 @@ import { homedir, hostname, networkInterfaces } from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import type { DaemonClient } from "./client.js";
|
||||
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
|
||||
import { shellQuote } from "./cross-host-executor.js";
|
||||
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
|
||||
import { openRexView } from "./terminal-window-rex.js";
|
||||
import type { OpenViewResult } from "./commands/terminal.js";
|
||||
import type { FollowControls, OpenViewResult } from "./commands/terminal.js";
|
||||
|
||||
interface Pane { seat: string; label: string; paneCommand: string; recoveryCommand?: string }
|
||||
interface HerdrTab { workspace_id: string; tab_id: string; label: string }
|
||||
@@ -226,7 +226,8 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
|
||||
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
|
||||
// Compare the composer's exact command, including session aliases and read-only mode.
|
||||
// SSH/custom commands cannot be confirmed from this local client inventory.
|
||||
clients.set(pid, ["tmux", quote(tmux)].map(binary => `${binary} attach ${readOnly === "1" ? "-r " : ""}-t ${quote(session)}`));
|
||||
const flags = readOnly === "1" ? ["-r "] : ["", "-f ignore-size "];
|
||||
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary} attach ${flag}-t ${quote(session)}`)));
|
||||
}
|
||||
const used = new Set<string>();
|
||||
for (const [index, page] of pages.entries()) {
|
||||
@@ -259,12 +260,12 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
|
||||
}
|
||||
|
||||
/** Render the daemon's existing composition; never rediscover/relaunch kernel seats here. */
|
||||
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string): Promise<OpenViewResult> {
|
||||
export async function openTerminalWindow(client: DaemonClient, view: string, requestedProvider?: string, deps = defaultWindowDeps(), expectedPlan?: string, requestedSession?: string, following?: FollowControls): Promise<OpenViewResult> {
|
||||
const session = requestedSession?.trim();
|
||||
if (session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
|
||||
if (!following && session === undefined && (requestedProvider === "rex" || (requestedProvider === undefined && inRex(deps.env)))) {
|
||||
return openRexView(client, view, deps, expectedPlan);
|
||||
}
|
||||
const selectedProvider = requestedProvider ?? (session !== undefined ? "herdr" : undefined);
|
||||
const selectedProvider = requestedProvider ?? (session !== undefined || following ? "herdr" : undefined);
|
||||
const sessionFlag = session === undefined ? "" : ` --session ${shellQuote(session)}`;
|
||||
const sessionQuery = session === undefined ? "" : `&session=${encodeURIComponent(session)}`;
|
||||
let provider = selectedProvider ?? "herdr";
|
||||
@@ -282,6 +283,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
const windowNotes: string[] = [];
|
||||
try {
|
||||
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
|
||||
if (following && selectedProvider !== "herdr") throw new Error("Following requires the qualified Herdr provider.");
|
||||
if (session !== undefined && selectedProvider !== "herdr") throw new Error("--session selects a Herdr session; use --provider herdr.");
|
||||
if (selectedProvider && !["herdr", "tmux"].includes(selectedProvider)) throw new Error("--window supports herdr, rex or tmux. Use cmux without --window.");
|
||||
const launchWindow = await windowLauncher(deps, windowNotes);
|
||||
@@ -306,7 +308,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
if (expectedPlan !== undefined && expectedPlan !== planId) {
|
||||
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
|
||||
}
|
||||
if (!composed.opened.length) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
|
||||
if (!composed.opened.length && !following) return { ...failed("No conversations are attachable."), absent: composed.absent, degraded: composed.degraded };
|
||||
const measureWindow = async () => {
|
||||
if (window && "columns" in window && window.columns !== viewportColumns && composed.kernelLayout) {
|
||||
viewportColumns = window.columns;
|
||||
@@ -392,6 +394,26 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
|
||||
// Inventory is read-only: a failed read must not prevent the first open.
|
||||
// Scope each tab list to its workspace, on the daemon's exact endpoint.
|
||||
const herdrArgs = ["-u", "TMUX", "-u", "HERDR_SESSION", "-u", "HERDR_SOCKET_PATH", `HERDR_SOCKET_PATH=${endpoint.socketPath}`, herdr];
|
||||
if (following) {
|
||||
// Follow has a stable journaled workspace. A changed membership plan
|
||||
// must never enter the one-shot plan-label/new-workspace path below.
|
||||
const answer = await client.post<OpenViewResult>("/api/terminal/follow", { view, provider: "herdr", ...following,
|
||||
...(session !== undefined ? { session } : {}), ...(expectedPlan !== undefined ? { expectedPlan } : {}) },
|
||||
{ timeoutMs: 45_000, headers: terminalAuthHeaders() });
|
||||
if (!answer.data.ok || !answer.data.workspaceId) return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
|
||||
const selectedArgs = herdrArgs;
|
||||
const selectedStatus = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "status", "server", "--json"]));
|
||||
const selectedBody = selectedStatus?.result ?? selectedStatus;
|
||||
if (selectedStatus.error || selectedBody?.running !== true || selectedBody.socket !== endpoint.socketPath) {
|
||||
throw new Error("The Herdr CLI did not confirm the daemon's exact following endpoint; no tab lookup or focus was requested.");
|
||||
}
|
||||
const listed = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "list", "--workspace", answer.data.workspaceId]));
|
||||
const tab = (listed?.result?.tabs as HerdrTab[] | undefined)?.find(t => t.workspace_id === answer.data.workspaceId);
|
||||
if (!tab) throw new Error("The stable following workspace has no confirmed tab. Inspect it before retrying.");
|
||||
const focused = JSON.parse(await deps.exec("/usr/bin/env", [...selectedArgs, "tab", "focus", tab.tab_id]));
|
||||
if (focused?.result?.tab?.tab_id !== tab.tab_id || focused.result.tab.workspace_id !== tab.workspace_id) throw new Error("Following tab selection was not confirmed.");
|
||||
return { ...answer.data, window, notes: [...(answer.data.notes ?? []), ...windowNotes] };
|
||||
}
|
||||
const closeCommand = (id: string) => `${session === undefined ? "herdr" : `env -u HERDR_SESSION HERDR_SOCKET_PATH=${shellQuote(endpoint.socketPath)} ${shellQuote(herdr)}`} workspace close ${shellQuote(id)}`;
|
||||
let tabs: HerdrTab[] = [];
|
||||
const spaceLabels = new Map<string, string>();
|
||||
|
||||
@@ -31,7 +31,10 @@ import type { TmuxAdapter } from "../../daemon/src/adapters/tmux.js";
|
||||
|
||||
vi.mock("node:child_process", async (actual) => ({ ...await actual<typeof import("node:child_process")>(), execSync: (cmd: string) => {
|
||||
if (!cmd.startsWith("tmux display-message ")) throw new Error(`Unexpected shell command in inert fixture: ${cmd}`);
|
||||
return `${process.env.TEST_PHYSICAL_SESSION}\n@12\n%42\n`;
|
||||
// Render the requested format as real tmux does, which writes a control character as "_".
|
||||
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1] ?? "";
|
||||
return `${format.replace(/#\{session_name\}/g, process.env.TEST_PHYSICAL_SESSION ?? "").replace(/#\{window_id\}/g, "@12")
|
||||
.replace(/#\{pane_id\}/g, "%42").replace(/[\u0000-\u001f]/g, "_")}\n`;
|
||||
} }));
|
||||
|
||||
it.each(["0", "person-terminal"])("real attach -> returned env -> actual From -> reply to original %s pane", async (physical) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { execFileSync, execSync, spawnSync } from "node:child_process";
|
||||
import { Command } from "commander";
|
||||
import { attachCommand, resolveAttachContext } from "../src/commands/attach.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
@@ -170,15 +171,8 @@ describe("Attach CLI", () => {
|
||||
});
|
||||
|
||||
it("resolveAttachContext detects tmux session metadata when TMUX_PANE is set", () => {
|
||||
const savedPane = process.env["TMUX_PANE"];
|
||||
process.env["TMUX_PANE"] = "%42";
|
||||
const mockTmuxExec = vi.fn(() => "dev1-impl2@rigged-buildout\n@12\n%42");
|
||||
|
||||
const result = resolveAttachContext(mockTmuxExec);
|
||||
|
||||
if (savedPane === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = savedPane;
|
||||
|
||||
const result = withTmuxPane("%42", () =>
|
||||
resolveAttachContext(fakeTmux({ session: "dev1-impl2@rigged-buildout", window: "@12", pane: "%42" })));
|
||||
expect(result).toEqual({
|
||||
attachmentType: "tmux",
|
||||
tmuxSession: "dev1-impl2@rigged-buildout",
|
||||
@@ -186,4 +180,65 @@ describe("Attach CLI", () => {
|
||||
tmuxPane: "%42",
|
||||
});
|
||||
});
|
||||
|
||||
// Real tmux rewrote the newlines of a combined format as "_" ("unrelated-shell_@2_%2"), and the session fell back to
|
||||
// external_cli, so replies to the self-attached member had no transport.
|
||||
it.each([
|
||||
["the shape real tmux returned", "unrelated-shell"],
|
||||
["a name holding underscores, spaces and id-like words", "team_1 rig_b @7 %9"],
|
||||
])("resolveAttachContext resolves the pane from %s", (_label, session) => {
|
||||
const result = withTmuxPane("%2", () => resolveAttachContext(fakeTmux({ session, window: "@2", pane: "%2" })));
|
||||
expect(result).toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: "@2", tmuxPane: "%2" });
|
||||
});
|
||||
|
||||
it("resolveAttachContext keeps external_cli outside tmux, and when tmux can't answer", () => {
|
||||
const notCalled = vi.fn(() => "");
|
||||
expect(withTmuxPane(undefined, () => resolveAttachContext(notCalled))).toEqual({ attachmentType: "external_cli" });
|
||||
expect(notCalled).not.toHaveBeenCalled();
|
||||
for (const tmux of [vi.fn(() => { throw new Error("no server running"); }), vi.fn(() => ""), vi.fn(() => "unexpected")]) {
|
||||
expect(withTmuxPane("%2", () => resolveAttachContext(tmux))).toEqual({ attachmentType: "external_cli" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const TMUX_SOCKET = `openrig-attach-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
|
||||
describe.runIf(tmuxAvailable)("resolveAttachContext against a real tmux server (private socket)", () => {
|
||||
afterAll(() => { spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]); });
|
||||
|
||||
it("resolves the session, window and pane of a session whose name holds spaces and underscores", () => {
|
||||
const session = "unrelated_shell b";
|
||||
execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", session]);
|
||||
const read = (format: string) => execFileSync("tmux", ["-L", TMUX_SOCKET, "list-panes", "-t", `=${session}`, "-F", format],
|
||||
{ encoding: "utf8" }).trim();
|
||||
const pane = read("#{pane_id}");
|
||||
const window = read("#{window_id}");
|
||||
const onSocket = (cmd: string) => execSync(cmd.replace(/^tmux /, `tmux -L ${TMUX_SOCKET} `), { encoding: "utf8" }).trim();
|
||||
expect(withTmuxPane(pane, () => resolveAttachContext(onSocket)))
|
||||
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane });
|
||||
});
|
||||
});
|
||||
|
||||
/** A `tmux display-message -p` double that renders the requested format as real tmux does, control characters
|
||||
* included: tmux writes each one as "_". */
|
||||
function fakeTmux(values: { session: string; window: string; pane: string }) {
|
||||
return vi.fn((cmd: string) => {
|
||||
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1];
|
||||
if (format === undefined) throw new Error(`unexpected tmux command: ${cmd}`);
|
||||
return format.replace(/#\{session_name\}/g, values.session).replace(/#\{window_id\}/g, values.window)
|
||||
.replace(/#\{pane_id\}/g, values.pane).replace(/[\u0000-\u001f]/g, "_");
|
||||
});
|
||||
}
|
||||
|
||||
function withTmuxPane<T>(pane: string | undefined, run: () => T): T {
|
||||
const saved = process.env["TMUX_PANE"];
|
||||
if (pane === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = pane;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["TMUX_PANE"];
|
||||
else process.env["TMUX_PANE"] = saved;
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,17 @@ describe("Capture CLI", () => {
|
||||
expect(out).toEqual(["Do you want to\n"]);
|
||||
expect(err).toEqual([`[rig capture: small@my-rig: ${SMALL_PANE_HINT}]`]);
|
||||
});
|
||||
|
||||
it("sends 20 lines for non-positive --lines values", async () => {
|
||||
for (const lines of ["0", "-1"]) {
|
||||
const before = requests.length;
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "capture", "tall@my-rig", `--lines=${lines}`]);
|
||||
});
|
||||
expect(requests.length).toBeGreaterThan(before);
|
||||
expect(requests[requests.length - 1]!.lines).toBe(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("--readable reads a small seat at a readable size", () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { chatroomWatchTmuxArgs } from "../src/commands/chatroom.js";
|
||||
|
||||
const sessionArg = (rig: string) => { const args = chatroomWatchTmuxArgs(rig); return args[args.indexOf("-s") + 1]; };
|
||||
|
||||
describe("chatroom watch --tmux arguments", () => {
|
||||
it("hands tmux the watcher as separate arguments, with the rig name after --, and asks for the name it used", () => {
|
||||
expect(chatroomWatchTmuxArgs("my rig")).toEqual(["new-session", "-d", "-P", "-F", "#{session_name}", "-s", "chatroom@my rig",
|
||||
"rig", "chatroom", "watch", "--", "my rig"]);
|
||||
});
|
||||
|
||||
// tmux reads -s as a format: `##` is one `#`, and a run of `#` before `[` is a style it keeps as written.
|
||||
it.each([
|
||||
["my-rig", "chatroom@my-rig"],
|
||||
["a#b", "chatroom@a##b"],
|
||||
["a##b", "chatroom@a####b"],
|
||||
["a#", "chatroom@a##"],
|
||||
["a#{b}", "chatroom@a##{b}"],
|
||||
["a#[fg=red]b", "chatroom@a#[fg=red]b"],
|
||||
["a##[b", "chatroom@a##[b"],
|
||||
])("encodes %j for tmux's session-name format as %j", (rig, session) => {
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
});
|
||||
|
||||
// tmux ends a command at an argument ending in `;` unless a `\` comes before it.
|
||||
it.each([
|
||||
["a;", "chatroom@a\\;", "a\\;"],
|
||||
[";", "chatroom@\\;", "\\;"],
|
||||
["a\\;", "chatroom@a\\\\;", "a\\\\;"],
|
||||
["a;b", "chatroom@a;b", "a;b"],
|
||||
])("puts a \\ before a final ; in %j", (rig, session, watcherArg) => {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
expect(sessionArg(rig)).toBe(session);
|
||||
expect(args[args.length - 1]).toBe(watcherArg);
|
||||
});
|
||||
});
|
||||
|
||||
const TMUX_SOCKET = `openrig-chatroom-test-${process.pid}`;
|
||||
const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
|
||||
const names = ["my-rig", "my rig", "it's", 'say "hi"', "$(echo pwned)", "`id`", "a;b", "a$HOME", "-rig",
|
||||
"a#b", "a##b", "a#{b}", "a#[fg=red]b", "a##[b", "a;", ";"];
|
||||
|
||||
describe.runIf(tmuxAvailable)("chatroom watch --tmux against a real tmux server (private socket)", () => {
|
||||
let dir = "";
|
||||
let recorder = "";
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "openrig-chatroom-watch-"));
|
||||
// Stands in for `rig`: records the arguments the watcher gets, then stays up so the session lives.
|
||||
recorder = path.join(dir, "record.cjs");
|
||||
writeFileSync(recorder, "require('node:fs').writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));\nsetTimeout(() => {}, 30000);\n");
|
||||
});
|
||||
afterAll(() => {
|
||||
spawnSync("tmux", ["-L", TMUX_SOCKET, "kill-server"]);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The exact arguments, with only the `rig` program swapped for the recorder; tmux prints the session it made.
|
||||
function start(rig: string, out: string): string {
|
||||
const args = chatroomWatchTmuxArgs(rig);
|
||||
const program = args.indexOf("-s") + 2;
|
||||
return execFileSync("tmux", ["-L", TMUX_SOCKET, "-f", "/dev/null", ...args.slice(0, program), process.execPath, recorder, out,
|
||||
...args.slice(program + 1)], { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
it.each(names.map((rig, i) => [rig, i] as const))("keeps the session chatroom@%s and hands the watcher the name as one argument", async (rig, i) => {
|
||||
const out = path.join(dir, `argv-${i}.json`);
|
||||
expect(start(rig, out)).toBe(`chatroom@${rig}`);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", rig]), { timeout: 5000 });
|
||||
});
|
||||
|
||||
// Whether tmux changes a `.` depends on its version (3.5a writes `_`; the tmux CI runs keeps the `.`), so the CLI
|
||||
// prints the name tmux reports, which is always the session tmux has.
|
||||
it("reports the name tmux used for a rig name with a ., whichever form its version keeps", async () => {
|
||||
const out = path.join(dir, "argv-dot.json");
|
||||
const reported = start("release-0.7", out);
|
||||
expect(["[email protected]", "chatroom@release-0_7"]).toContain(reported);
|
||||
expect(execFileSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" }).split("\n"))
|
||||
.toContain(reported);
|
||||
await vi.waitFor(() => expect(JSON.parse(readFileSync(out, "utf8"))).toEqual(["chatroom", "watch", "--", "release-0.7"]), { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -285,6 +285,32 @@ describe("capture --host (cross-host short-circuit)", () => {
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "5x"]);
|
||||
});
|
||||
|
||||
it("forwards a fractional --lines as given: a positive parseInt means the remote decides", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines=3.5"], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "3.5"]);
|
||||
});
|
||||
|
||||
it("forwards a non-positive or non-numeric --lines as the documented default of 20", async () => {
|
||||
for (const value of ["0", "-1", "abc"]) {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
run: async (_h, argv) => {
|
||||
captureCalls.argv = argv;
|
||||
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
||||
},
|
||||
}));
|
||||
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", `--lines=${value}`], { from: "user" });
|
||||
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "20"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards --history-plus-pane with the --lines it applies to", async () => {
|
||||
const captureCalls: { argv?: readonly string[] } = {};
|
||||
const cmd = captureCommand(deps({
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("send --host (http branch)", () => {
|
||||
const h = mockClient(() => ({ status: 200, data: {} }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "30"], { from: "user" });
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 30_000);
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000 + 60_000);
|
||||
expect((h.calls[0]!.body as Record<string, unknown>).waitForIdleMs).toBe(30_000);
|
||||
});
|
||||
|
||||
@@ -359,14 +359,14 @@ describe("send --host (http branch)", () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { ok: true } }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--wait-for-idle", "400"], { from: "user" });
|
||||
expect(h.calls.map((c) => [(c.body as Record<string, unknown>).waitForIdleMs, c.options?.timeoutMs])).toEqual([[400_000, 400_000 + 30_000]]);
|
||||
expect(h.calls.map((c) => [(c.body as Record<string, unknown>).waitForIdleMs, c.options?.timeoutMs])).toEqual([[400_000, 400_000 + 60_000]]);
|
||||
});
|
||||
|
||||
it("a cross-host send waits 30 s for the remote to deliver, not the 5 s read default", async () => {
|
||||
it("a cross-host send waits 60 s for the remote to deliver, not the 5 s read default", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: {} }));
|
||||
const cmd = sendCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello"], { from: "user" });
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(30_000);
|
||||
expect(h.calls[0]!.options?.timeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("a remote that delivers after more than 5 s reports the send, not a failure", { timeout: 20_000 }, async () => {
|
||||
@@ -414,7 +414,10 @@ describe("send --host (http branch)", () => {
|
||||
const err = captured.stderrLines.join("\n");
|
||||
expect(err).toContain("cross-host (host=vps-b, http://vps-b:7433): http remote-outcome-unknown:");
|
||||
expect(err).toContain("Delivery UNCONFIRMED — the remote daemon may have received and delivered the message.");
|
||||
expect(err).toContain("Check the target before any resend: rig capture dev-impl@my-rig --host vps-b. A resend without checking risks a duplicate.");
|
||||
expect(err).toContain("rig capture dev-impl@my-rig --host vps-b");
|
||||
expect(err).toContain("off-screen");
|
||||
expect(err).toContain("does not prove non-delivery");
|
||||
expect(err).toContain("duplicate");
|
||||
expect(err).not.toContain("remote-daemon-unreachable");
|
||||
expect(captured.stdoutLines).not.toContain("Sent to dev-impl@my-rig");
|
||||
expect(process.exitCode).toBe(1);
|
||||
@@ -438,7 +441,7 @@ describe("send --host (http branch)", () => {
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "hello", "--json"], { from: "user" });
|
||||
const parsed = JSON.parse(captured.stdoutLines[0]!) as Record<string, any>;
|
||||
expect(parsed.result).toMatchObject({ ok: false, failedStep: "remote-outcome-unknown", outcomeUnknown: true });
|
||||
expect(parsed.error.action).toContain("Check the target before any resend");
|
||||
expect(parsed.error.action).toContain("does not prove non-delivery");
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -501,6 +504,13 @@ describe("capture --host (http branch)", () => {
|
||||
expect(captured.stdoutLines).toContain("pane content here");
|
||||
});
|
||||
|
||||
it("http host: a non-positive --lines posts the documented default of 20, not 0", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
|
||||
const cmd = captureCommand(httpDeps(h));
|
||||
await cmd.parseAsync(["--host", "vps-b", "dev-impl@my-rig", "--lines=0"], { from: "user" });
|
||||
expect(h.calls[0]!.body).toEqual({ lines: 20, session: "dev-impl@my-rig", inputBox: true });
|
||||
});
|
||||
|
||||
it("anonymous (URL-only) http host: capture POSTs with NO Authorization header; still succeeds", async () => {
|
||||
const h = mockClient(() => ({ status: 200, data: { content: "pane content here" } }));
|
||||
const cmd = captureCommand(httpDeps(h));
|
||||
|
||||
@@ -52,6 +52,14 @@ describe("rig edge", () => {
|
||||
beforeEach(() => { vi.stubEnv("OPENRIG_HOST_SELECTED", "local"); });
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
|
||||
it("plans a deferred target by its address rather than printing a null node", async () => {
|
||||
const named = { ...edge, kind: "escalates_to", to: { logicalId: "review-advisor@coord", nodeId: null } };
|
||||
const post = vi.fn(async () => ({ status: 200, data: { ok: true, rigId: "rig-1", outcome: "would_add", edge: named } }));
|
||||
const result = await run({ post } as never, ["add", "rig-1", "orch.lead", "review-advisor@coord", "--kind", "escalates_to", "--plan"]);
|
||||
expect(result.out.join("\n")).toContain("review-advisor@coord (resolved when used)");
|
||||
expect(result.out.join("\n")).not.toContain("node null");
|
||||
});
|
||||
|
||||
it.each(["add", "remove"].flatMap((operation) =>
|
||||
["ECONNREFUSED", "EPERM"].map((causeCode) => ({ operation, causeCode })),
|
||||
))("returns JSON without an edge request when the daemon is unavailable: $operation / $causeCode", async ({ operation, causeCode }) => {
|
||||
|
||||
@@ -232,6 +232,80 @@ describe("doctorLegs — Source-1 learning from the healthz body already in hand
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "host-identity-binding")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("first contact where replies do resolve here keeps the claim and adds no route row", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const rows = await doctorLegs(anonHost, {
|
||||
...baseDeps,
|
||||
httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-84c37990" }) }),
|
||||
learnHostBinding: (_alias: string, id: string) => ({ outcome: "bound", binding: { hostId: id, firstObservedAt: "x", lastObservedAt: "x" } }),
|
||||
replyRoute: () => anonHost,
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "host-identity-binding")!.detail).toContain("now resolve to 'mm2-host'");
|
||||
expect(rows.find((r) => r.step === "reply-route")).toBeUndefined();
|
||||
});
|
||||
|
||||
// An earlier hosts.yaml entry that learned the same identity keeps the replies, even after its
|
||||
// address stops answering. Real learner and real resolver over one sidecar file.
|
||||
it("an earlier entry with the same identity: doctor names it on the working entry and fails it when dead", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const p = tmpPath();
|
||||
const stale = { id: "old-box-b", transport: "http", url: "http://10.0.0.9:7433" } as const;
|
||||
const working = { id: "box-b", transport: "http", url: "http://10.0.0.5:7433" } as const;
|
||||
const registry: HostRegistry = { hosts: [stale, working] };
|
||||
recordHostObservation({ alias: stale.id, observedHostId: "host-0b0b0b0b", observedUrl: stale.url, path: p });
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
learnHostBinding: (alias: string, id: string, url: string) => recordHostObservation({ alias, observedHostId: id, observedUrl: url, path: p }),
|
||||
replyRoute: (id: string) => {
|
||||
const r = resolveHost(registry, id, loadHostBindings(p).bindings);
|
||||
return r.ok ? r.host : null;
|
||||
},
|
||||
learnedHostId: (alias: string) => loadHostBindings(p).bindings[alias]?.hostId ?? null,
|
||||
};
|
||||
|
||||
const live = await doctorLegs(working, { ...deps, httpGet: async () => ({ status: 200, body: JSON.stringify({ selfHostId: "host-0b0b0b0b" }) }) } as never);
|
||||
const identity = live.find((r) => r.step === "host-identity-binding")!;
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.detail).not.toContain("now resolve to");
|
||||
const route = live.find((r) => r.step === "reply-route")!;
|
||||
expect(route.status).toBe("unknown");
|
||||
expect(route.detail).toContain("go to 'old-box-b', not 'box-b'");
|
||||
expect(route.fix).toContain("rig host doctor old-box-b");
|
||||
// The entry doctor names is the one rig send's own resolver picks.
|
||||
const sent = resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined, () => ({ ok: true as const, registry }), undefined, () => loadHostBindings(p));
|
||||
expect(sent).toMatchObject({ ok: true, sugarHost: "old-box-b" });
|
||||
|
||||
const dead = await doctorLegs(stale, { ...deps, httpGet: async () => { throw new Error("read ECONNRESET"); } } as never);
|
||||
expect(dead.find((r) => r.step === "transport-reachability")!.status).toBe("fail");
|
||||
const deadRoute = dead.find((r) => r.step === "reply-route")!;
|
||||
expect(deadRoute.status).toBe("fail");
|
||||
expect(deadRoute.detail).toContain("@host-0b0b0b0b resolve to 'old-box-b'");
|
||||
expect(deadRoute.fix).toContain("delete 'old-box-b'");
|
||||
});
|
||||
|
||||
it("an unreachable entry that replies don't resolve to adds no route row", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const rows = await doctorLegs(anonHost, {
|
||||
...baseDeps,
|
||||
httpGet: async () => { throw new Error("connect ECONNREFUSED"); },
|
||||
learnedHostId: () => "host-84c37990",
|
||||
replyRoute: () => ({ id: "other-host", transport: "http", url: "http://y:7433" }),
|
||||
} as never);
|
||||
expect(rows.map((r) => r.step)).toEqual(["transport-reachability"]);
|
||||
});
|
||||
|
||||
it("an unreachable ssh entry whose declared hostId replies resolve to gets the route row too", async () => {
|
||||
const { doctorLegs } = await import("../src/commands/host.js");
|
||||
const sshHost = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig", hostId: "host-84c37990" } as const;
|
||||
const rows = await doctorLegs(sshHost, {
|
||||
...baseDeps,
|
||||
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }),
|
||||
httpGet: async () => ({ status: 200, body: "" }),
|
||||
replyRoute: (id: string) => (id === "host-84c37990" ? sshHost : null),
|
||||
} as never);
|
||||
expect(rows.find((r) => r.step === "reply-route")).toMatchObject({ status: "fail" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2 — concurrency guard: conflict records survive concurrent writers", () => {
|
||||
|
||||
@@ -69,12 +69,20 @@ describe("rig host pair (OPR.0.4.6.MH1 FR-6)", () => {
|
||||
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
||||
}
|
||||
|
||||
it("approved walk: registry entry with bearer_file + a 0600 token file; code printed", async () => {
|
||||
it.each([false, true])("approved walk: registry entry with bearer_file + a 0600 token file (json=%s)", async (json) => {
|
||||
const deps = fakeDeps({ poll: [{ status: 200, body: { status: "approved", token: "issued-bearer" } }] });
|
||||
const { out, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10"]));
|
||||
const { out, err, exitCode } = await capture(() => run(deps, ["pair", "vps-a:7433", "--id", "vps-a", "--timeout", "10", ...(json ? ["--json"] : [])]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(out.join("\n")).toContain("Pairing code: 424242");
|
||||
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
|
||||
expect(err).toEqual([]);
|
||||
if (json) {
|
||||
expect(JSON.parse(out.join("\n"))).toMatchObject({ ok: true, entry: { id: "vps-a", transport: "http" } });
|
||||
} else {
|
||||
expect(out.join("\n")).toContain("Pairing code: 424242");
|
||||
expect(out.join("\n")).toContain("Paired. Host 'vps-a' registered");
|
||||
expect(out.join("\n")).toContain("rig host doctor vps-a");
|
||||
expect(out.join("\n")).toContain("rig config set feed.subscriptions.vps-a.enabled true");
|
||||
}
|
||||
expect(fs.existsSync(path.join(dir, "config.json"))).toBe(false);
|
||||
|
||||
const tokenPath = path.join(dir, "secrets", "host-vps-a.token");
|
||||
expect(fs.readFileSync(tokenPath, "utf8")).toBe("issued-bearer\n");
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Command } from "commander";
|
||||
import { hostCommand, doctorLegs, postureCheck, type DoctorDeps, type CheckRow } from "../src/commands/host.js";
|
||||
import type { HostEntry } from "../src/host-registry.js";
|
||||
import type { CrossHostResult } from "../src/cross-host-executor.js";
|
||||
import { ConfigStore } from "../src/config-store.js";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
|
||||
// OPR.0.4.4.13 FR-1 — rig host add / list. Registry path is steered via
|
||||
// OPENRIG_HOME so tests never touch the operator's real ~/.openrig.
|
||||
@@ -50,6 +52,38 @@ describe("rig host add/list", () => {
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(out.join("\n")).toContain("Added host 'vps-1' (ssh)");
|
||||
expect(out.join("\n")).toContain("rig host doctor vps-1");
|
||||
expect(out.join("\n")).toContain("Remote FEED requires an HTTP host entry");
|
||||
expect(out.join("\n")).not.toContain("rig config set feed.subscriptions.");
|
||||
});
|
||||
|
||||
it.each(["laptop", "laptop.prod-1"])("HTTP add advertises a working opt-in command for %s", async (id) => {
|
||||
const configPath = path.join(dir, "config.json");
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("feed.subscriptions.progress", "false");
|
||||
const before = fs.readFileSync(configPath, "utf8");
|
||||
const { out, exitCode } = await capture(() => run(["add", "--id", id, "--transport", "http", "--url", "http://laptop:7433"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(configPath, "utf8")).toBe(before);
|
||||
|
||||
const nextStep = out.map((line) => line.trim()).find((line) => line.startsWith("rig config set "));
|
||||
expect(nextStep).toBeDefined();
|
||||
const program = new Command().addCommand(configCommand(configPath));
|
||||
const applied = await capture(() => program.parseAsync(["node", ...nextStep!.split(/\s+/)]));
|
||||
expect(applied.exitCode).toBeUndefined();
|
||||
expect(applied.err).toEqual([]);
|
||||
const saved = new ConfigStore(configPath);
|
||||
expect(saved.get(`feed.subscriptions.${id}.enabled`)).toBe(true);
|
||||
expect(saved.get("feed.subscriptions.progress")).toBe(false);
|
||||
expect(saved.get("feed.subscriptions.other-host.enabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("an HTTP host with a reserved feed name stays registered without an unusable command", async () => {
|
||||
const { out, exitCode } = await capture(() => run(["add", "--id", "progress", "--transport", "http", "--url", "http://laptop:7433"]));
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(fs.readFileSync(path.join(dir, "hosts.yaml"), "utf8")).toContain("id: progress");
|
||||
expect(out.join("\n")).toContain("reserved by feed settings");
|
||||
expect(out.join("\n")).not.toContain("rig config set feed.subscriptions.");
|
||||
expect(fs.existsSync(path.join(dir, "config.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("add surfaces the loader's own validation error at add-time (both bearers)", async () => {
|
||||
@@ -60,7 +94,9 @@ describe("rig host add/list", () => {
|
||||
|
||||
it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => {
|
||||
vi.stubEnv("FACTORY_TOKEN", "super-secret-bearer-value");
|
||||
await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN"]));
|
||||
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 { out } = await capture(() => run(["list"]));
|
||||
const text = out.join("\n");
|
||||
expect(text).toContain("env:FACTORY_TOKEN");
|
||||
@@ -340,6 +376,26 @@ describe("rig host doctor — stepwise distinct errors + three-valued posture",
|
||||
expect(err.join("\n")).toContain("unknown host id 'nope'");
|
||||
});
|
||||
|
||||
it("doctor's reply route reads the same hosts.yaml and host-bindings.json rig send does", async () => {
|
||||
const { replyRouteFromDisk } = await import("../src/commands/host.js");
|
||||
const { resolveCrossHostTarget } = await import("../src/cross-host-target.js");
|
||||
fs.writeFileSync(path.join(dir, "hosts.yaml"), [
|
||||
"hosts:",
|
||||
" - id: old-box-b",
|
||||
" transport: http",
|
||||
" url: http://10.0.0.9:7433",
|
||||
" - id: box-b",
|
||||
" transport: http",
|
||||
" url: http://10.0.0.5:7433",
|
||||
].join("\n") + "\n");
|
||||
// Both aliases learned one identity; hosts.yaml order, not sidecar order, picks the entry.
|
||||
const learned = { hostId: "host-0b0b0b0b", firstObservedAt: "x", lastObservedAt: "x" };
|
||||
fs.writeFileSync(path.join(dir, "host-bindings.json"), JSON.stringify({ version: 1, bindings: { "box-b": learned, "old-box-b": learned } }));
|
||||
expect(replyRouteFromDisk("host-0b0b0b0b")?.id).toBe("old-box-b");
|
||||
expect(resolveCrossHostTarget("pm@some-rig@host-0b0b0b0b", undefined)).toMatchObject({ ok: true, sugarHost: "old-box-b" });
|
||||
expect(replyRouteFromDisk("host-unknown")).toBeNull();
|
||||
});
|
||||
|
||||
// seed a registry for the doctor CLI test above
|
||||
async function capturePrelude(): Promise<void> {
|
||||
const prog = new Command();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
||||
import { mcpCommand } from "../src/commands/mcp.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import * as lifecycle from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
vi.mock("@modelcontextprotocol/server/stdio", () => ({
|
||||
serveStdio: vi.fn(() => {
|
||||
queueMicrotask(() => process.stdin.emit("end"));
|
||||
return { close: vi.fn(async () => {}) };
|
||||
}),
|
||||
}));
|
||||
|
||||
function deps(): { deps: StatusDeps; clientFactory: ReturnType<typeof vi.fn> } {
|
||||
const clientFactory = vi.fn((url: string) => new DaemonClient(url));
|
||||
return { deps: { lifecycleDeps: { exists: () => false } as never, clientFactory }, clientFactory };
|
||||
}
|
||||
|
||||
describe("rig mcp serve --port", () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
afterEach(() => { process.exitCode = undefined; vi.restoreAllMocks(); });
|
||||
|
||||
it("refuses a non-numeric port before starting the server", async () => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { deps: d, clientFactory } = deps();
|
||||
await mcpCommand(d).parseAsync(["serve", "--port", "abc"], { from: "user" });
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(err).toHaveBeenCalledWith(expect.stringContaining("Invalid port number"));
|
||||
expect(clientFactory).not.toHaveBeenCalled();
|
||||
expect(serveStdio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["0", "-1", "65536", "99999"])("warns once and starts with port %s unchanged", async (port) => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { deps: d, clientFactory } = deps();
|
||||
await mcpCommand(d).parseAsync(["serve", "--port", port], { from: "user" });
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(err).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(`Warning: daemon port ${port}`));
|
||||
expect(clientFactory).toHaveBeenCalledExactlyOnceWith(`http://127.0.0.1:${port}`);
|
||||
expect(serveStdio).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["1", "7433", "65535", "7433suffix"])("keeps the existing parseInt behavior for %s", async (port) => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { deps: d, clientFactory } = deps();
|
||||
await mcpCommand(d).parseAsync(["serve", "--port", port], { from: "user" });
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
expect(clientFactory).toHaveBeenCalledExactlyOnceWith(`http://127.0.0.1:${parseInt(port, 10)}`);
|
||||
});
|
||||
|
||||
it.each([{ args: [] }, { args: ["--port", ""] }])("uses the daemon endpoint for empty or omitted port ($args)", async ({ args }) => {
|
||||
const status = vi.spyOn(lifecycle, "getDaemonStatus").mockResolvedValue({ state: "running", host: "::1", port: 8123 });
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { deps: d, clientFactory } = deps();
|
||||
await mcpCommand(d).parseAsync(["serve", ...args], { from: "user" });
|
||||
expect(status).toHaveBeenCalledOnce();
|
||||
expect(clientFactory).toHaveBeenCalledExactlyOnceWith("http://[::1]:8123");
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -537,8 +537,23 @@ describe("MCP Server", () => {
|
||||
expect(instructions).toContain(MEMBER_WORKFLOW);
|
||||
// The inbox, the claim before work, and keeping an item blocked while a person is asked.
|
||||
for (const part of ["rig_queue_list with owned set", "rig_queue_claim", "blockedOn that person", "blockedOn that new qitemId"]) expect(MEMBER_WORKFLOW).toContain(part);
|
||||
// v2: the closure reason a finished item takes, and scope described from the grant, not the tool list.
|
||||
expect(MEMBER_WORKFLOW_VERSION).toBe("member-workflow/v2");
|
||||
for (const part of ["closure reason no-follow-on", "a listed tool isn't access", "names the shared roots you may read"]) expect(MEMBER_WORKFLOW).toContain(part);
|
||||
const doc = fs.readFileSync(new URL("../../../docs/reference/hosted-members.md", import.meta.url), "utf8");
|
||||
expect(doc).toContain(`> ${MEMBER_WORKFLOW}`);
|
||||
expect(doc).not.toContain("member-workflow/v1");
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("names the closure reasons rig_queue_update takes, finished work first", async () => {
|
||||
await setup();
|
||||
const { tools } = await mcpClient.listTools();
|
||||
const update = tools.find((tool) => tool.name === "rig_queue_update");
|
||||
const reason = (update?.inputSchema as { properties?: Record<string, { description?: string }> } | undefined)?.properties?.closureReason?.description ?? "";
|
||||
expect(reason).toContain("no-follow-on for finished work");
|
||||
for (const value of ["handed_off_to", "denied", "canceled", "escalation", "superseded"]) expect(reason).toContain(value);
|
||||
expect(reason).not.toContain("completed");
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
it.each(["0", "99999"])("initializes and lists tools over stdio despite daemon port %s", async (port) => {
|
||||
const source = new URL("../src/commands/mcp.ts", import.meta.url).href;
|
||||
const script = `import { mcpCommand } from ${JSON.stringify(source)}; await mcpCommand().parseAsync(["serve", "--port", ${JSON.stringify(port)}], { from: "user" });`;
|
||||
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let err = ""; let buffer = ""; let initialized = false; let tools: unknown; let timedOut = false;
|
||||
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 10_000);
|
||||
child.stderr.on("data", (chunk) => { err += chunk; });
|
||||
const send = (message: unknown) => child.stdin.write(JSON.stringify(message) + "\n");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
||||
if (!line.trim()) continue;
|
||||
const reply = JSON.parse(line);
|
||||
if (reply.id === 1 && reply.result) {
|
||||
initialized = true;
|
||||
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
||||
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
||||
} else if (reply.id === 2) {
|
||||
tools = reply.result?.tools;
|
||||
child.stdin.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "port-startup-test", version: "1" } } });
|
||||
const code = await new Promise<number | null>((resolve, reject) => { child.once("close", resolve); child.once("error", reject); });
|
||||
clearTimeout(timer);
|
||||
expect({ code, timedOut, initialized }, err).toEqual({ code: 0, timedOut: false, initialized: true });
|
||||
expect(tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "rig_status" })]));
|
||||
expect(err.split("\n").filter((line) => line.startsWith("Warning:"))).toEqual([
|
||||
`Warning: daemon port ${port} is outside [1, 65535]; MCP will start, but daemon tool calls may fail. Check --port.`,
|
||||
]);
|
||||
}, 15_000);
|
||||
|
||||
it.each(["eof", "sigterm"])("finishes rig mcp serve after native %s", async (mode) => {
|
||||
const source = new URL("../src/commands/mcp.ts", import.meta.url).href;
|
||||
const script = `import { mcpCommand } from ${JSON.stringify(source)}; console.error("module-ready"); await mcpCommand().parseAsync(["serve", "--port", "7433"], { from: "user" }); console.error("command-closed");`;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { getDaemonStatus, stopDaemon, type LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
import { normalizeOpenRigUrl } from "../src/openrig-compat.js";
|
||||
import { resolveStartupProofEndpoint } from "../src/commands/startup-proof.js";
|
||||
import { crashCartEvidenceUrl } from "../src/commands/crash-cart.js";
|
||||
|
||||
// OPENRIG_URL=http://host:port/ (trailing slash) addresses the same daemon as
|
||||
// http://host:port; the CLI must not request //healthz or //api/... from it.
|
||||
describe("OPENRIG_URL with a trailing slash", () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
function probeDeps(ok: (url: string) => boolean) {
|
||||
const urls: string[] = [];
|
||||
const fetch = vi.fn(async (url: string) => {
|
||||
urls.push(url);
|
||||
return { ok: ok(url) };
|
||||
});
|
||||
const exists = vi.fn(() => false);
|
||||
const readFile = vi.fn(() => null);
|
||||
return { urls, exists, readFile, deps: { fetch, exists, readFile } as unknown as LifecycleDeps };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7433/", "http://127.0.0.1:7433"],
|
||||
["http://127.0.0.1:7433//", "http://127.0.0.1:7433"],
|
||||
["http://h:7433/p/", "http://h:7433/p"],
|
||||
["https://h/", "https://h"],
|
||||
["http://h:7433/?a=b/", "http://h:7433/?a=b/"],
|
||||
["http://h:7433/p/?x", "http://h:7433/p/?x"],
|
||||
["http://h:7433/p/#x/", "http://h:7433/p/#x/"],
|
||||
["http://h:7433", "http://h:7433"],
|
||||
["/", "/"],
|
||||
["//", "//"],
|
||||
["http://", "http://"],
|
||||
])("normalizeOpenRigUrl(%s) is %s (a query or fragment URL stays as written)", (raw, normalized) => {
|
||||
expect(normalizeOpenRigUrl(raw)).toBe(normalized);
|
||||
});
|
||||
|
||||
it("getDaemonStatus probes <origin>/healthz, not <origin>//healthz", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555/");
|
||||
const { urls, deps } = probeDeps((url) => url === "http://127.0.0.1:7555/healthz");
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(urls).toEqual(["http://127.0.0.1:7555/healthz"]);
|
||||
expect(status).toMatchObject({ state: "running", healthy: true, port: 7555, host: "127.0.0.1" });
|
||||
});
|
||||
|
||||
// Commands reach OPENRIG_URL through getDaemonUrl's http://host:port rebuild, so the probe
|
||||
// only changes for a plain http origin; a prefix or https URL is probed exactly as before.
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/p/", "http://127.0.0.1:7555/p//healthz"],
|
||||
["https://127.0.0.1:7555/", "https://127.0.0.1:7555//healthz"],
|
||||
["http://127.0.0.1:7555/?x=1", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
// No explicit port (or :80, which URL reports as none): status would hand commands 7433,
|
||||
// so the probe must not succeed on port 80 for them.
|
||||
["http://127.0.0.1/", "http://127.0.0.1//healthz"],
|
||||
["http://127.0.0.1:80/", "http://127.0.0.1:80//healthz"],
|
||||
])("getDaemonStatus probes %s as main does (%s)", async (url, probed) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const { urls, deps } = probeDeps(() => false);
|
||||
await getDaemonStatus(deps);
|
||||
expect(urls).toEqual([probed]);
|
||||
});
|
||||
|
||||
it("a slash-only OPENRIG_URL stays an explicit target and never selects the local daemon", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "/");
|
||||
const { urls, exists, readFile, deps } = probeDeps(() => true);
|
||||
await getDaemonStatus(deps);
|
||||
expect(urls).toEqual(["//healthz"]);
|
||||
expect(exists).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(new DaemonClient().baseUrl).toBe("/");
|
||||
});
|
||||
|
||||
it("a default DaemonClient requests <url>/api/..., not <url>//api/...", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", "http://127.0.0.1:7555//");
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
||||
urls.push(String(url));
|
||||
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
const client = new DaemonClient(undefined, { fetchImpl });
|
||||
await client.get("/api/ps");
|
||||
expect(urls).toContain("http://127.0.0.1:7555/api/ps");
|
||||
expect(urls.some((url) => url.includes("7555//"))).toBe(false);
|
||||
});
|
||||
|
||||
it("a default DaemonClient keeps a path prefix and scheme", () => {
|
||||
vi.stubEnv("OPENRIG_URL", "https://h:7433/p/");
|
||||
expect(new DaemonClient().baseUrl).toBe("https://h:7433/p");
|
||||
});
|
||||
|
||||
it("the startup-proof endpoint reads OPENRIG_URL through the same normalization", () => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { OPENRIG_URL: "http://127.0.0.1:7555/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7555", token: "tok" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/p/?x=1", "http://127.0.0.1:7555/p/?x=1/api/ps"],
|
||||
["http://127.0.0.1:7555/p/#frag", "http://127.0.0.1:7555/p/#frag/api/ps"],
|
||||
])("a default DaemonClient keeps main's base for %s", async (url, requested) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const urls: string[] = [];
|
||||
const fetchImpl = vi.fn(async (u: string | URL | Request) => {
|
||||
urls.push(String(u));
|
||||
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as unknown as typeof fetch;
|
||||
const client = new DaemonClient(undefined, { fetchImpl });
|
||||
expect(client.baseUrl).toBe(url);
|
||||
await client.get("/api/ps");
|
||||
expect(urls).toContain(requested);
|
||||
});
|
||||
|
||||
it("the startup-proof endpoint picks RIGGED_URL like every other reader when OPENRIG_URL is empty", () => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { OPENRIG_URL: "", RIGGED_URL: "http://127.0.0.1:7556/", OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl: "http://127.0.0.1:7556", token: "tok" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ OPENRIG_URL: " ", RIGGED_URL: "http://127.0.0.1:7556" }, "http://127.0.0.1:7556"],
|
||||
[{ OPENRIG_URL: " http://127.0.0.1:7555/ " }, "http://127.0.0.1:7555"],
|
||||
])("the startup-proof endpoint trims and skips blank URLs as before (%o)", (urls, baseUrl) => {
|
||||
const endpoint = resolveStartupProofEndpoint({
|
||||
env: { ...urls, OPENRIG_ACTIVITY_HOOK_TOKEN: "tok" },
|
||||
readFile: () => { throw new Error("no endpoint file"); },
|
||||
});
|
||||
expect(endpoint).toEqual({ baseUrl, token: "tok" });
|
||||
});
|
||||
|
||||
// The shared reader leaves a query or fragment URL as written, so these two callers keep main's own trim before
|
||||
// /healthz: rig daemon stop trims every trailing slash, and crash-cart's evidence probe one, as its detector does.
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/?x=1///", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
|
||||
])("rig daemon stop with OPENRIG_URL=%s checks %s", async (url, check) => {
|
||||
vi.stubEnv("OPENRIG_URL", url);
|
||||
const { urls, deps } = probeDeps(() => true);
|
||||
await expect(stopDaemon(deps)).rejects.toThrow(`Checked ${check};`);
|
||||
expect(urls).toEqual([check]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:7555/?x=1/", "http://127.0.0.1:7555/?x=1/healthz"],
|
||||
["http://127.0.0.1:7555/?x=1//", "http://127.0.0.1:7555/?x=1//healthz"],
|
||||
["http://127.0.0.1:7555/#frag/", "http://127.0.0.1:7555/#frag/healthz"],
|
||||
])("crash-cart's evidence probe for OPENRIG_URL=%s is %s", (url, probe) => {
|
||||
expect(crashCartEvidenceUrl(normalizeOpenRigUrl(url), undefined)).toBe(probe);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,96 @@ describe("a queue create that got no answer is read back once by its ID", () =>
|
||||
const posts = (calls: Call[]) => calls.filter((c) => c.method === "post");
|
||||
const readBacks = (calls: Call[]) => calls.filter((c) => c.method === "get" && c.path === `/api/queue/${ID}`);
|
||||
|
||||
it.each([
|
||||
["an explicit host", "dev@rig-b", ["--host", "vps-b"]],
|
||||
["a host-qualified destination", "dev@rig-b@vps-b", []],
|
||||
])("create --verify reads the remote receipt with %s and its bearer, never the local row", async (_case, destination, extra) => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n bearer_env: XH_AUTH\n`);
|
||||
vi.stubEnv("XH_AUTH", AUTH_VALUE);
|
||||
const { calls, error } = await run(destination, [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 404, data: { error: "qitem_not_found" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify", ...extra]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toEqual([expect.objectContaining({ base: LOCAL, path: "/api/queue/create" })]);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE,
|
||||
options: { timeoutMs: 10_000, headers: { Authorization: `Bearer ${AUTH_VALUE}` } } })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ qitemId: ID, persisted: true,
|
||||
delivery: { receiptSource: "terminal", outcome: "verified", wakeResult: "verified" } });
|
||||
expect(stdout.join("\n") + stderr.join("\n")).not.toContain(AUTH_VALUE);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", { status: 404, data: { error: "qitem_not_found" } }],
|
||||
["unreachable", new DaemonTimeoutError("remote receipt timed out")],
|
||||
["wrong id", { status: 200, data: { ...REMOTE_ROW, qitemId: "another-id", lastNudgeResult: "verified" } }],
|
||||
["wrong destination", { status: 200, data: { ...REMOTE_ROW, destinationSession: "other@rig-b", lastNudgeResult: "verified" } }],
|
||||
["wrong source", { status: 200, data: { ...REMOTE_ROW, sourceSession: "other@rig-a", lastNudgeResult: "verified" } }],
|
||||
["wrong body", { status: 200, data: { ...REMOTE_ROW, body: "different", lastNudgeResult: "verified" } }],
|
||||
] satisfies Array<[string, Reply]>)("create --verify keeps a %s remote receipt indeterminate without resending", async (_case, receipt) => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: receipt,
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: REMOTE })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
});
|
||||
|
||||
it("create --verify does not guess a receipt host missing from this CLI's registry", async () => {
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 201, data: REMOTE_ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(0);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
});
|
||||
|
||||
it("does not verify an older stored body returned by a successful colliding create", async () => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const oldRow = { ...REMOTE_ROW, body: "older body", lastNudgeResult: "verified" };
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [{ status: 200, data: { ...oldRow,
|
||||
createWarning: { code: "qitem_body_not_saved", message: "The supplied body was not saved." } } }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: oldRow },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(1);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "indeterminate" } });
|
||||
expect(stderr.join("\n")).toContain("not saved");
|
||||
});
|
||||
|
||||
it("create --verify reads locally when --host names this daemon, without requiring a registry entry", async () => {
|
||||
const { calls, error } = await run("dev@rig-b", [{ status: 201, data: ROW }], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${LOCAL}/api/queue/${ID}`]: { status: 200, data: { ...ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--host", "host-a", "--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toEqual([expect.objectContaining({ base: LOCAL })]);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
|
||||
});
|
||||
|
||||
it("a reconciled remote create still verifies its remote receipt without another write", async () => {
|
||||
fs.writeFileSync(path.join(home, "hosts.yaml"), `hosts:\n - id: vps-b\n transport: http\n url: ${REMOTE}\n`);
|
||||
const { calls, error } = await run("dev@rig-b@vps-b", [FORWARD_UNKNOWN], {
|
||||
[`${LOCAL}/healthz`]: { status: 200, data: { selfHostId: "host-a" } },
|
||||
[`${REMOTE}/api/queue/${ID}`]: { status: 200, data: { ...REMOTE_ROW, lastNudgeResult: "verified" } },
|
||||
}, ["--verify"]);
|
||||
expect(error).toBeNull();
|
||||
expect(posts(calls)).toHaveLength(1);
|
||||
expect(readBacks(calls)).toHaveLength(2);
|
||||
expect(readBacks(calls).every(c => c.base === REMOTE)).toBe(true);
|
||||
expect(JSON.parse(stdout.join("\n"))).toMatchObject({ persisted: true, delivery: { outcome: "verified" } });
|
||||
});
|
||||
|
||||
it("a local create that committed after the CLI stopped waiting reads back as created, and isn't sent again", async () => {
|
||||
const { calls, error } = await run("dev@rig-b", [new DaemonTimeoutError(`Request to ${LOCAL}/api/queue/create timed out after 5000ms`)],
|
||||
{ [`${LOCAL}/api/queue/${ID}`]: { status: 200, data: ROW } });
|
||||
|
||||
@@ -356,6 +356,43 @@ describe("findSlice + resolution variants", () => {
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("finds the owning mission when the missions root ends in a separator", () => {
|
||||
const withSlash = missionsRoot + path.sep;
|
||||
expect(findSlice(withSlash, "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
expect(findSlice(withSlash, abs).missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("finds the owning mission for a relative missions root with a bare name and --mission, or a mission path", () => {
|
||||
const prev = process.cwd();
|
||||
process.chdir(root);
|
||||
try {
|
||||
const hinted = findSlice("missions", "07-target", "release-0.3.2");
|
||||
expect(hinted.missionName).toBe("release-0.3.2");
|
||||
expect(hinted.absPath).toBe(path.join(fs.realpathSync(root), "missions", "release-0.3.2", "slices", "07-target"));
|
||||
expect(findSlice("missions/", "07-target", "release-0.3.2").missionName).toBe("release-0.3.2");
|
||||
expect(findSlice("missions", "release-0.3.2/slices/07-target").missionName).toBe("release-0.3.2");
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["/.", "/sub/.."])("resolves an absolute slice path ending in %s to the slice itself", (suffix) => {
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
fs.mkdirSync(path.join(abs, "sub"));
|
||||
const slice = findSlice(missionsRoot, abs + suffix.replace(/\//g, path.sep));
|
||||
expect(slice.absPath).toBe(abs);
|
||||
expect(slice.name).toBe("07-target");
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("keeps an absolute slice path that leaves the missions root pointing at the requested folder", () => {
|
||||
const outside = path.join(root, "other", "m1", "slices", "x");
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
const slice = findSlice(missionsRoot, [missionsRoot, "..", "other", "m1", "slices", "x"].join(path.sep));
|
||||
expect(slice.absPath).toBe(outside);
|
||||
});
|
||||
|
||||
it.each(["workspace", "missions", "declared mission"])(
|
||||
"resolves a hinted bare slice past an unrelated %s directory",
|
||||
(collision) => {
|
||||
|
||||
@@ -158,6 +158,14 @@ describe("Send CLI", () => {
|
||||
const parsed = JSON.parse(body);
|
||||
lastSendBody = parsed;
|
||||
sendBodies.push(parsed);
|
||||
if (parsed.session === "slow-session") {
|
||||
// A completed send can answer after the ordinary client's five-second deadline.
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: parsed.session }));
|
||||
}, 6_000);
|
||||
return;
|
||||
}
|
||||
if (parsed.session === "lost-response-session") {
|
||||
// The message was accepted, but no response headers reach the caller.
|
||||
res.destroy();
|
||||
@@ -433,6 +441,25 @@ describe("Send CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("waits for a slow successful send without repeating it (json=%s)", { timeout: 15_000 }, async (json) => {
|
||||
const { stdout, stderr, exitCode } = await captureChannels(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "slow-session", "hello", ...(json ? ["--json"] : [])]);
|
||||
});
|
||||
expect(sendBodies).toHaveLength(1);
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(stderr).toEqual([]);
|
||||
if (json) expect(JSON.parse(stdout[0])).toMatchObject({ ok: true, sessionName: "slow-session" });
|
||||
else expect(stdout).toContain("Sent to slow-session");
|
||||
});
|
||||
|
||||
it("gives a local fan-out the send response budget", async () => {
|
||||
const post = vi.fn().mockResolvedValue({ status: 200, data: { total: 1, sent: 1, failed: 0, results: [] } });
|
||||
await captureLogs(() => makeCmd(runningDeps(port, () => ({ post } as unknown as DaemonClient)))
|
||||
.parseAsync(["node", "rig", "send", "--to", "a@r", "hello"]).then(() => {}));
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post.mock.calls[0][2]).toMatchObject({ timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it.each([false, true])("losing the response after acceptance is unconfirmed (json=%s), with no resend", async (json) => {
|
||||
const { stdout, stderr, exitCode } = await captureChannels(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "lost-response-session", "hello", ...(json ? ["--json"] : [])]);
|
||||
@@ -443,6 +470,8 @@ describe("Send CLI", () => {
|
||||
const output = json ? JSON.parse(stdout[0]).error : { consequence: stderr[1], action: stderr[2] };
|
||||
expect(output.consequence).toContain("Delivery UNCONFIRMED");
|
||||
expect(output.action).toContain("duplicate");
|
||||
expect(output.action).toContain("off-screen");
|
||||
expect(output.action).toContain("does not prove non-delivery");
|
||||
expect([...stdout, ...stderr].join("\n")).not.toMatch(/was not sent|Sent to/);
|
||||
if (json) { expect(stdout).toHaveLength(1); expect(stderr).toEqual([]); }
|
||||
else expect(stdout).toEqual([]);
|
||||
@@ -578,7 +607,7 @@ describe("Send CLI", () => {
|
||||
text: expect.stringContaining("hello"),
|
||||
waitForIdleMs: 30000,
|
||||
}),
|
||||
{ timeoutMs: 35000 },
|
||||
{ timeoutMs: 90000 },
|
||||
);
|
||||
const sentText = postFn.mock.calls[0]?.[1] as { text: string } | undefined;
|
||||
expect(sentText?.text).toContain("To: dev-impl@my-rig");
|
||||
@@ -625,13 +654,13 @@ describe("Send CLI", () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd(deps).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello", "--wait-for-idle", "600"]);
|
||||
});
|
||||
expect(postFn.mock.calls.map((call) => [(call[1] as { waitForIdleMs: number }).waitForIdleMs, call[2]])).toEqual([[600000, { timeoutMs: 605000 }]]);
|
||||
expect(postFn.mock.calls.map((call) => [(call[1] as { waitForIdleMs: number }).waitForIdleMs, call[2]])).toEqual([[600000, { timeoutMs: 660000 }]]);
|
||||
expect(logs.join("\n")).toContain("when the 600000ms wait ended (8 checks). No text was sent.");
|
||||
expect(logs.join("\n")).not.toMatch(/did not respond|consecutive/i);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("send without wait-for-idle uses default client timeout path", async () => {
|
||||
it("send without wait-for-idle uses the 60 s response budget", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 200,
|
||||
data: { ok: true, sessionName: "dev-impl@my-rig" },
|
||||
@@ -640,7 +669,7 @@ describe("Send CLI", () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd(deps).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
|
||||
});
|
||||
expect(postFn.mock.calls[0]?.[2]).toBeUndefined();
|
||||
expect(postFn.mock.calls[0]?.[2]).toEqual({ timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
// Slice-03 Atom 6b — --context delivery flag.
|
||||
@@ -1296,6 +1325,19 @@ describe("Send CLI", () => {
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("single-seat send with a trailing-slash OPENRIG_URL reaches <url>/api/..., not <url>//api/...", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}/`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
lastSendBody = null;
|
||||
const rc = recordingRealClient();
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd(probeFailNoStateDeps(rc.factory)).parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
|
||||
expect(rc.seen()).toBe(`http://127.0.0.1:${port}`);
|
||||
expect(String(lastSendBody?.text)).toContain("To: dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("R2 RED: fan-out --to + OPENRIG_URL + probe-stopped -> actual /broadcast lands with per-recipient results", async () => {
|
||||
vi.stubEnv("OPENRIG_URL", `http://127.0.0.1:${port}`);
|
||||
vi.stubEnv("RIGGED_URL", "");
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("terminal open request budget", () => {
|
||||
const connections = new Set<net.Socket>();
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
let page = 0;
|
||||
const panes: Array<{ tab_id: string; label: string }> = [];
|
||||
const socketServer = net.createServer(socket => {
|
||||
connections.add(socket);
|
||||
socket.on("close", () => connections.delete(socket));
|
||||
@@ -35,8 +36,16 @@ describe("terminal open request budget", () => {
|
||||
let result: Record<string, unknown> = { type: request.method };
|
||||
if (request.method === "ping") result = { type: "pong", version: "0.7.1", protocol: 1 };
|
||||
if (request.method === "workspace.create") result = { type: "workspace_create", workspace: { workspace_id: "owned-workspace" } };
|
||||
if (request.method === "layout.apply") result = { type: "layout_apply", layout: { tab_id: `owned-tab-${++page}` } };
|
||||
if (request.method === "pane.list") result = { type: "pane_list", panes: [] };
|
||||
if (request.method === "layout.apply") {
|
||||
const tab_id = `owned-tab-${++page}`;
|
||||
const collect = (node: any): void => {
|
||||
if (node.type === "pane") panes.push({ tab_id, label: node.label });
|
||||
else { collect(node.first); collect(node.second); }
|
||||
};
|
||||
collect(request.params.root);
|
||||
result = { type: "layout_apply", layout: { tab_id } };
|
||||
}
|
||||
if (request.method === "pane.list") result = { type: "pane_list", panes };
|
||||
const reply = () => socket.end(`${JSON.stringify({ id: request.id, result })}\n`);
|
||||
if (request.method === "layout.apply") {
|
||||
// Each 2.8s response is within the real socket transport's 5s bound.
|
||||
|
||||
@@ -73,6 +73,33 @@ describe("reopening a Herdr view in a desktop window", () => {
|
||||
});
|
||||
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
|
||||
|
||||
it.each([[undefined, true], ["daemon-named", true], [undefined, false], ["daemon-named", false]] as const)("following binds the exact custom endpoint before tab selection (session=%s matches=%s)", async (session, matches) => {
|
||||
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel", session);
|
||||
const namedSocket = "/run private/custom.sock";
|
||||
f.get.mockImplementation(async (url: string) => ({ status: 200, data: url.includes("preview")
|
||||
? { ...(session ? { session } : {}), planId, composed: f.composed, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }
|
||||
: { ...(session ? { session } : {}), providers: [{ liveness: { alive: true }, status: { launch: { socketPath: namedSocket, session: "daemon-named" } } }] } }) as never);
|
||||
f.post.mockResolvedValue({ status: 200, data: { provider: "herdr", ok: true, workspaceId: "follow-w", opened: ["operator"], absent: [], degraded: [], pages: 1,
|
||||
...(session ? { session } : {}),
|
||||
following: { id: "same", enabled: true, state: "following", maxTiles: 8, tiles: [], events: [], overflow: [] } } } as never);
|
||||
const original = f.exec.getMockImplementation()!;
|
||||
f.exec.mockImplementation(async (file, args) => args.includes("status") && args.includes("server")
|
||||
? JSON.stringify({ result: { running: true, socket: matches ? namedSocket : "/wrong/default.sock" } }) : original(file, args));
|
||||
await f.run(["--follow", "--json", ...(session ? ["--session", session] : [])]);
|
||||
const parsed = JSON.parse(logs[0]!);
|
||||
if (matches) {
|
||||
expect(parsed).toMatchObject({ ok: true, workspaceId: "follow-w" });
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("follow-t");
|
||||
for (const [, args] of f.exec.mock.calls.filter(([, args]) => args.includes("status") || args.includes("tab"))) {
|
||||
expect(args).toContain(`HERDR_SOCKET_PATH=${namedSocket}`);
|
||||
expect(args).not.toContain("--session");
|
||||
}
|
||||
} else {
|
||||
expect(parsed.ok).toBe(false); expect(f.focus).not.toHaveBeenCalled(); expect(f.list).not.toHaveBeenCalled();
|
||||
expect(parsed.error).toContain("exact following endpoint");
|
||||
}
|
||||
});
|
||||
|
||||
it("a named session scopes every reuse inventory and focus command to the selected socket", async () => {
|
||||
const f = fixture([{ workspace_id: "existing", tab_id: "existing-tab", label: label() }], "kernel", "prod");
|
||||
await f.run(["--session", "prod", "--json"]);
|
||||
@@ -171,6 +198,16 @@ describe("reopening a Herdr view in a desktop window", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("also reuses live viewers with the legacy writable attach spelling", async () => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
for (const pane of f.composed.opened) {
|
||||
pane.recoveryCommand = pane.recoveryCommand!.replace("-f ignore-size ", "");
|
||||
}
|
||||
await f.run();
|
||||
expect(f.focus).toHaveBeenCalledExactlyOnceWith("first");
|
||||
expect(f.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms an absolute tmux command, quoted session alias and read-only attachment", async () => {
|
||||
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }]);
|
||||
f.composed.opened[0]!.recoveryCommand = "'/fixture/bin/tmux' attach -r -t 'alias'\"'\"'s session'";
|
||||
|
||||
@@ -46,6 +46,26 @@ function makeDeps(opts?: { routes?: Record<string, StubResponse> }): {
|
||||
};
|
||||
}
|
||||
|
||||
describe("task-following controls", () => {
|
||||
beforeEach(() => { process.exitCode = 0; vi.spyOn(console, "log").mockImplementation(() => {}); });
|
||||
const body = { provider: "herdr", ok: true, opened: [], absent: [], degraded: [], pages: 1,
|
||||
following: { id: "stable", enabled: true, state: "empty", maxTiles: 4, tiles: [], events: [], overflow: [] } };
|
||||
it("posts one owner operation with explicit follow controls and accepts a complete empty view", async () => {
|
||||
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/follow": { status: 200, data: body } } });
|
||||
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "terminal", "open", "qitem:task", "--provider", "herdr", "--follow", "--max-tiles", "4", "--pin", "a@team", "--json"]);
|
||||
expect(calls).toEqual([{ method: "POST", path: "/api/terminal/follow", body: { view: "qitem:task", provider: "herdr", enabled: true,
|
||||
maxTiles: 4, pin: ["a@team"], unpin: undefined, suppress: undefined, unsuppress: undefined, suppressionMs: undefined, graceMs: undefined, focusRetentionMs: undefined } }]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
it("disables without opening a desktop or creating a replacement workspace", async () => {
|
||||
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/follow": { status: 200, data: { ...body, following: { ...body.following, enabled: false, state: "disabled" } } } } });
|
||||
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "terminal", "open", "qitem:task", "--no-follow", "--json"]);
|
||||
expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ path: "/api/terminal/follow", body: { enabled: false } });
|
||||
});
|
||||
});
|
||||
|
||||
const opened = (seats: string[], extra: Record<string, unknown> = {}) => ({
|
||||
provider: "herdr", ok: seats.length > 0, opened: seats, absent: [], degraded: [], pages: 1, ...extra,
|
||||
});
|
||||
@@ -140,6 +160,32 @@ describe("rig terminal CLI", () => {
|
||||
} finally { log.mockRestore(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("names unconfirmed panes without claiming none opened (json=%s)", async json => {
|
||||
const result = opened([], { degraded: [{ seat: "a-seat", host: "herdr", code: "pane_unconfirmed", reason: "Inspect the terminal before retrying." }] });
|
||||
const { deps, calls } = makeDeps({ routes: { "POST /api/terminal/open": { status: 200, data: result } } });
|
||||
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux", ...(json ? ["--json"] : [])]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter(c => c.method === "POST")).toHaveLength(1);
|
||||
if (json) expect(JSON.parse(logs[0]!)).toEqual(result);
|
||||
else {
|
||||
expect(logs[0]).toContain("No tiles confirmed in herdr.");
|
||||
expect(logs[0]).toContain("a-seat — unconfirmed (herdr): Inspect");
|
||||
expect(logs[0]).not.toMatch(/No tiles opened|skipped|Prepared/);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a confirmed seat while naming an unconfirmed sibling", async () => {
|
||||
const { deps } = makeDeps({ routes: { "POST /api/terminal/open": { status: 200, data: opened(["a-seat"], {
|
||||
degraded: [{ seat: "b-seat", host: "herdr", code: "pane_unconfirmed", reason: "pane list unreadable" }],
|
||||
}) } } });
|
||||
const program = createProgram({ terminalDeps: deps }); program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "terminal", "open", "acme-build", "--provider", "cmux"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(logs[0]).toContain("Prepared 1 tile(s)");
|
||||
expect(logs[0]).toContain("b-seat — unconfirmed (herdr): pane list unreadable");
|
||||
});
|
||||
|
||||
it("open with ZERO panes opened is a failure — non-zero exit", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: { "POST /api/terminal/open": { status: 200, data: opened([], { ok: false, code: "herdr_unavailable", error: "no binary" }) } },
|
||||
|
||||
@@ -188,4 +188,17 @@ describe("Transcript CLI", () => {
|
||||
// grep mode: should show matched lines, not tail content
|
||||
expect(output).toContain("decision made");
|
||||
});
|
||||
|
||||
it("sends exactly 50 lines for non-positive --tail values", async () => {
|
||||
for (const tail of ["0", "-1"]) {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "transcript", "dev-impl@long-rig", `--tail=${tail}`]);
|
||||
});
|
||||
// The fixture generates exactly as many lines as the request asks for:
|
||||
// line 49 present and line 50 absent pins the wire value at lines=50.
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("long line 49");
|
||||
expect(output).not.toContain("long line 50");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolBatch": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${OPENRIG_NODE:-node}\" \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\" --from-plugin",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -84,6 +84,11 @@ function buildOpenRigPayload(providerPayload, env = process.env, now = () => new
|
||||
generation,
|
||||
hookEvent,
|
||||
subtype,
|
||||
// Claude's main session omits agent_id; subagents and in-process teammates carry it.
|
||||
// Keep that distinction for permission completion, without forwarding tool inputs/results.
|
||||
...(runtime === "claude-code" && (hookEvent === "Notification" || hookEvent === "PostToolBatch")
|
||||
&& (providerPayload.agent_id == null || firstString(providerPayload.agent_id))
|
||||
? { hookAgentId: firstString(providerPayload.agent_id) } : {}),
|
||||
turnId: firstString(providerPayload.turn_id, providerPayload.turnId),
|
||||
occurredAt: now().toISOString(),
|
||||
relayRoute: argv.includes("--from-plugin") ? "plugin" : "managed",
|
||||
|
||||
@@ -31,7 +31,11 @@ without an owner.
|
||||
receiver gets a wake-ping plus the durable queue item.
|
||||
- **Blocked on another agent's work.** Use `rig queue block <id> --on <child>`
|
||||
(or `rig queue update <id> --state blocked --blocked-on <child>`). The item
|
||||
stays active and returns to pending when its blocker completes.
|
||||
stays active and returns to pending when its blocker completes. Waiting on
|
||||
something outside the queue, such as CI or a long job? Park the row:
|
||||
`rig queue block <id> --on external:<what> --wake-after <duration>
|
||||
--continuation "<what resumes>"`. It stays yours and reads parked, not
|
||||
stalled, and the timer wakes you.
|
||||
- **Escalating to the human.** Make the escalation a durable attention
|
||||
item, not just a chat message.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.3`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.4`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -88,7 +88,10 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
- **`rig roster list` / `show` / `find`** — who across your rigs and hosts is recommended for a
|
||||
kind of work, from JSON rosters in `<workspace.root>/rosters`. Read-only: it dispatches nothing
|
||||
and does not claim anyone is available. The built-in starter and factory leads publish their
|
||||
team's roster at first start. `show` and `find` include each seat's last observed model.
|
||||
team's roster at first start. `show` and `find` include each seat's last observed model. A
|
||||
roster entry marked `"hosted": true` is a member that lives outside a terminal (an assistant
|
||||
connected over MCP); `show` and `find` say so, and when it last called the team's MCP server.
|
||||
With no evidence that reads unknown, never idle.
|
||||
- **`rig telemetry events` / `transitions` / `tenures <node-id>`** — past events, queue
|
||||
transitions and a seat's occupants, one bounded page at a time with gaps reported. Reach for it
|
||||
before querying the database yourself.
|
||||
@@ -210,6 +213,12 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
existing view was reused, which is success. `--provider herdr` or `--provider cmux` opens the
|
||||
view in a provider that is already running, without opening a window; each such open adds a new
|
||||
workspace, so to come back to a view, focus its open space.
|
||||
**`qitem:<id>` as the view** previews or opens the current local owners of that task: its
|
||||
pending, in-progress and blocked rows through explicitly linked successors, without closed
|
||||
predecessors or past senders. It's a read-only membership view: stopped, missing, external and
|
||||
unresolved owners are named, and incomplete lineage is reported rather than cut short. It doesn't
|
||||
follow the task as it moves, and an explicit open still has the provider's usual workspace
|
||||
effects. Preview and open responses carry `viewDescriptor` (`kind`, `name`).
|
||||
Add `--session <name>` to select a Herdr session for that request; `--session default`
|
||||
explicitly selects its default session. `rig terminal status --session <name>` inspects
|
||||
the same endpoint. Omitting the flag keeps the daemon's configured endpoint. Herdr tiles come
|
||||
@@ -594,6 +603,12 @@ because someone lost work once.
|
||||
When your user says OpenRig isn't working, start with **`rig context get help`**: the one help guide for the installed
|
||||
version, from checking the environment to a useful report to the OpenRig team.
|
||||
|
||||
- **`rig seat history --rig <id> --node-id <id>`** — which recorded Claude or Codex conversations this exact node
|
||||
could resume, read-only (`--json`; check one reference with `--snapshot-id` and `--history-session-id` together).
|
||||
The IDs are `rigId` in `rig ps --nodes --json` and `identity.nodeId` in `rig whoami --session <seat> --json`.
|
||||
A client can then ask the daemon's startup review for that node to resume
|
||||
the history the person reviewed; it rechecks the choice and refuses rather than substitute another
|
||||
conversation or start fresh.
|
||||
- **`rig start`** — the box rebooted and everything is gone: bring the whole topology back in one
|
||||
move, rather than hand-restoring rig by rig. Selected-rig recovery now demands positive runtime
|
||||
evidence and refuses stale identity by name rather than substituting an occupant. `--last`
|
||||
@@ -604,6 +619,9 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
Stop distinguishes process/listener exit from a clean asynchronous drain: inspect the matching
|
||||
`daemon-shutdown.json` and log when completion is incomplete or unverified. No target is a
|
||||
distinct no-op, and a timeout never proves down. Use command help for exact limits and recovery.
|
||||
Startup brings back a kernel that this start found down (what a reboot leaves). A kernel that
|
||||
was already down before this start stays down: `rig start` and `rig daemon start
|
||||
--wait-for-kernel` say so, and `rig up kernel --existing` brings it back.
|
||||
`/healthz` reports the daemon's effective UID as `euid`, beside `pid`. Running several OpenRig
|
||||
homes on one machine on purpose: `rig config set daemon.db_conflict stop` refuses a start whose
|
||||
database another live daemon already holds (the default, `warn`, only warns), and
|
||||
@@ -1279,14 +1297,28 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
shell command. Its queue tools (`rig_queue_create`, `rig_queue_show`, `rig_queue_list`,
|
||||
`rig_queue_claim`, `rig_queue_update`, `rig_queue_handoff`, `rig_queue_unclaim`) act as the seat
|
||||
named by `OPENRIG_SESSION_NAME`, so an agent with no shell can hand work to the team, read the
|
||||
closing note as its answer, and pass on or put back work it holds. Nothing
|
||||
wakes such an agent; it sees new work when it checks. Its tools cover the whole delegation loop:
|
||||
closing note as its answer, and pass on or put back work it holds. Unless it declares a webhook
|
||||
trigger (below), nothing wakes such an agent; it sees new work when it checks. Its tools cover
|
||||
the whole delegation loop:
|
||||
find its seat, team and lead (`rig_whoami`, `rig_team`), hand work on and follow the handoff's
|
||||
successor, find finished work and read its artifact, decide inside a person's decision room,
|
||||
follow a restore it started (`rig_restore_status`) and read the event log
|
||||
(`rig_telemetry_events`). **`rig mcp serve --http`** serves the same tools to a client on another
|
||||
host; `rig mcp token add <name> --seat <session>` gives each caller its own grant, acting as that
|
||||
seat.
|
||||
seat. `--grant-service <url>` checks each request's key with an external grant service instead
|
||||
of the token file (`--grant-service-header-file` adds one header to each lookup), and
|
||||
`--oauth-resource` with `--oauth-issuer` lets an OAuth client discover how to authenticate and
|
||||
get a standard challenge; the key and its grant still decide what a caller may do. The rows a
|
||||
granted caller's actions write record whose grant acted: attribution, not authentication.
|
||||
Clients on MCP protocol revision 2026-07-28 and on the 2025 revisions share the same endpoint.
|
||||
**An assistant connected this way can be a hosted member of a rig:** mark its roster entry
|
||||
`"hosted": true` and `rig_team` lists it with its declared capabilities and caveat, activity
|
||||
`unknown`. The server's instructions teach it the loop: its inbox, claiming, closing or handing
|
||||
on, and asking a person while keeping the item (it blocks the item on that person, and the
|
||||
answer returns it in progress). A roster entry can declare a webhook trigger (`wake.webhook`,
|
||||
with secret names, never values): OpenRig then POSTs a pointer to the row when work lands, and a
|
||||
2xx records only that the ring was accepted, not that anything woke. When work waits for a
|
||||
member nothing can wake, or a rung member doesn't claim it in time, its person is told.
|
||||
- **`rig skill loadout --runtime <claude-code|codex>`** — inspect the exact catalog revision,
|
||||
selectors, target, and current/missing/shadowed/conflicting state for one working directory.
|
||||
`--apply` writes only the managed ownership set, is idempotent, and refuses local edits or
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- developing-openrig
|
||||
- openrig-upgrade
|
||||
- forming-an-openrig-mental-model
|
||||
|
||||
@@ -16,7 +16,6 @@ metadata:
|
||||
stage: factory-approved
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- forming-an-openrig-mental-model
|
||||
---
|
||||
|
||||
|
||||
+11
@@ -68,6 +68,17 @@ structurally distinct.
|
||||
3. **A bundle declares topology but omits proof expectations or workflow mode.** Topology alone doesn't tell users what "working as advertised" means.
|
||||
4. **Users cannot inspect what a bundle will create before installing it.** `rig bundle inspect` must show what will be created without side effects.
|
||||
|
||||
## From an exported team to a submission
|
||||
|
||||
`rig export <rig> -o <folder>/rig.yaml` writes the spec, not a README,
|
||||
so an exported team has none of its own. Before you bundle or submit
|
||||
it, add a `README.md` to the folder, declare it in `rig.yaml` as
|
||||
`docs: [{ path: README.md }]`, and run `rig bundle check <folder>`
|
||||
until it reports no findings. The openrig.dev/rigs registry check runs
|
||||
the same check on the folder at the submitted ref, and any finding
|
||||
fails it. The full walk-through is the publishing guide,
|
||||
`publishing-a-rig-bundle.md` in `$OPENRIG_HOME/reference/`.
|
||||
|
||||
## Proof standard
|
||||
|
||||
Proof should:
|
||||
|
||||
@@ -48,7 +48,7 @@ states.
|
||||
## Don't use this when
|
||||
|
||||
- The operation is single-command and deterministic — use `openrig-user` skill for CLI surface
|
||||
- The work is operator-level configuration of OpenRig itself — use `openrig-operator`
|
||||
- The work is operator-level configuration or troubleshooting of OpenRig itself — run `rig context get help`
|
||||
- The work is rig spec authoring — use `openrig-architect`
|
||||
|
||||
## Failure modes (4)
|
||||
@@ -120,7 +120,7 @@ different claims; don't conflate.
|
||||
## See also
|
||||
|
||||
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
|
||||
- `openrig-operator` skill — operator-level discipline for OpenRig itself
|
||||
- `rig context get help` — the help guide for the installed version, for when OpenRig itself isn't working
|
||||
- `seat-continuity-and-handover` skill — sub-primitive: occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
|
||||
- `session-source-fork` skill — `forked` continuity outcome for fork-based restore
|
||||
- `permission-and-capability-preflight` skill — provider-auth blocker → escalation path
|
||||
|
||||
@@ -47,7 +47,8 @@ machine and have things happen.
|
||||
marks them healthy. A HOST reboot drops tmux entirely; the
|
||||
reconciler then marks the kernel's member sessions detached, and
|
||||
the operator pod owns the agent-restart workflow ("bring my
|
||||
sessions back online") per the openrig-operator skill.
|
||||
sessions back online") per "Incomplete setup and restart":
|
||||
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
|
||||
Other rigs are NOT auto-instantiated by the daemon at any point —
|
||||
they require explicit operator-initiated `rig up` / `rig restore`
|
||||
via the agent-driven workflow.
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
} from "../domain/runtime-adapter.js";
|
||||
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
|
||||
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
|
||||
import { assessNativeResumeProbe, hasClaudeDrawnInput } from "../domain/native-resume-probe.js";
|
||||
import { resumeProbeEvidence } from "../domain/native-resume-probe-evidence.js";
|
||||
import { resolveReadinessTimeoutMs } from "../domain/readiness-timeout.js";
|
||||
import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProcesses, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
|
||||
@@ -259,6 +259,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
opts: { name: string; resumeToken?: string; forkSource?: import("../domain/runtime-adapter.js").ForkSource; freshInPlace?: boolean; beforeNativeDispatch?: () => boolean },
|
||||
): Promise<HarnessLaunchResult> {
|
||||
binding = { ...binding };
|
||||
const claudeCommand = binding.claudeExecutable ? `exec ${shellQuote(binding.claudeExecutable)}` : "claude";
|
||||
this.autoLaunches.delete(binding.nodeId);
|
||||
opts = { ...opts, ...(opts.forkSource ? { forkSource: { ...opts.forkSource } } : {}) };
|
||||
if (!binding.tmuxSession) {
|
||||
@@ -277,6 +278,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
try {
|
||||
if (!this.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.claudeManagedLaunch!.prepare({ nodeId: binding.nodeId, cwd: binding.cwd,
|
||||
...(binding.claudeExecutable ? { executable: binding.claudeExecutable } : {}),
|
||||
session: binding.tmuxSession, pane: binding.tmuxPane, generation: binding.launchGeneration,
|
||||
...(binding.runtimeSwitchFrom ? { runtimeSwitch: { fromRuntime: binding.runtimeSwitchFrom.runtime, session: binding.runtimeSwitchFrom.session,
|
||||
pane: binding.runtimeSwitchFrom.pane, generation: binding.runtimeSwitchFrom.generation } } : {}) }, binding.permissionMode);
|
||||
@@ -356,7 +358,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
// The non-managed command text is parsed by the pane shell, so the
|
||||
// parent id needs the same quoting as --model/--effort above (and as
|
||||
// the codex fork path) — a hostile id must not leave --resume.
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(parentId)} --fork-session --session-id ${forkSessionId} --name ${opts.name}`;
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, managed.assertCurrent)
|
||||
: await sendClassic(cmd);
|
||||
if (!textResult.ok) {
|
||||
@@ -377,8 +379,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const cmd = managed ? managed.command(["--permission-mode", binding.permissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs,
|
||||
...(opts.resumeToken ? ["--resume", opts.resumeToken] : ["--session-id", generatedSessionId!]), "--name", opts.name],
|
||||
opts.resumeToken ? claudeResumeLaunchEnv(opts.resumeToken) : undefined) : opts.resumeToken
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
? `${claudeResumeLaunchPrefix(opts.resumeToken)}${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(opts.resumeToken)} --name ${opts.name}`
|
||||
: `${rendererPrefix}${claudeCommand} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --session-id ${generatedSessionId} --name ${opts.name}`;
|
||||
|
||||
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Claude launch preparation; no native command was sent.", recovery: "attention_required" };
|
||||
const textResult = managed ? await this.tmux.sendShellCommand(binding.tmuxSession, cmd, beforeManagedInput)
|
||||
@@ -424,7 +426,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
const paneCommand = await this.tmux.getPaneCommand(binding.tmuxSession);
|
||||
const paneContent = (await this.tmux.capturePaneContent(binding.tmuxSession, 40)) ?? "";
|
||||
const historyProbe = await this.assessManagedProbe(binding, paneCommand, paneContent, options);
|
||||
const probe = await this.assessFreshInPlaceProbe(binding, paneCommand, historyProbe);
|
||||
const probe = await this.assessFreshInPlaceProbe(binding, historyProbe);
|
||||
|
||||
// A script-launched Claude reads as `sh` until the script hands over the foreground, so the label alone
|
||||
// is not a return to a shell. Say so only for a proven idle shell (the rule Codex's checkReady uses).
|
||||
@@ -453,11 +455,10 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
/** A fresh in-place launch keeps the retiree's scrollback. Qualify a history-based ready result
|
||||
* with the current frame, only for the exact launch that requested it. Unavailable observations
|
||||
* retain the old admission (including its ambiguity); visible gates and conflicting identity do not. */
|
||||
/** A fresh in-place launch keeps the retiree's scrollback. Its readiness requires a current
|
||||
* prompt and stable process proof for that exact launch. Missing observations stay inconclusive. */
|
||||
private async assessFreshInPlaceProbe(
|
||||
binding: NodeBinding, paneCommand: string | null,
|
||||
binding: NodeBinding,
|
||||
historyProbe: ReturnType<typeof assessNativeResumeProbe>,
|
||||
) {
|
||||
const launch = this.autoLaunches.get(binding.nodeId);
|
||||
@@ -468,8 +469,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
|
||||
let screen: string | null = null;
|
||||
try { screen = await this.tmux.capturePaneScreen(binding.tmuxPane); } catch { /* unavailable */ }
|
||||
// Missing process evidence is unknown, not a conflicting recipient. Reuse the launch observer's
|
||||
// positive conflict distinction without requiring process proof for ordinary ready frames.
|
||||
// Missing process evidence is unknown, not a conflicting recipient. Keep that distinction
|
||||
// while requiring positive launch evidence before admitting the current prompt below.
|
||||
let occupant: "own" | "another" | "unknown" | "absent" = "unknown";
|
||||
try { occupant = await this.paneOccupant(binding, false, { positiveConflictOnly: true }); } catch { /* unavailable */ }
|
||||
if (this.autoLaunches.get(binding.nodeId) !== launch) return {
|
||||
@@ -479,31 +480,48 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
|
||||
status: "inconclusive" as const, code: "claude_launch_identity_conflict",
|
||||
detail: "A different runtime identity occupies the fresh launch's pane.",
|
||||
};
|
||||
if (!screen?.trim()) return historyProbe;
|
||||
const current = await this.assessManagedProbe(binding, paneCommand, screen);
|
||||
if (!screen?.trim()) return {
|
||||
status: "inconclusive" as const, code: "awaiting_runtime",
|
||||
detail: "The fresh launch's current Claude prompt could not be observed.",
|
||||
};
|
||||
// A process label alone cannot establish an interactive prompt. This probe uses the current
|
||||
// frame and the same exact, stable launch proof as managed headerless readiness.
|
||||
const current = await this.assessManagedProbe(binding, null, screen, { requireCurrentLaunch: true });
|
||||
if (this.autoLaunches.get(binding.nodeId) !== launch) return {
|
||||
status: "inconclusive" as const, code: "awaiting_runtime", detail: "The fresh launch changed during readiness observation.",
|
||||
};
|
||||
// A shell prompt can also start with ❯. Keep the headerless fallback only when process
|
||||
// evidence cannot rule Claude out; a readable shell/script-only pane uses the current result.
|
||||
const withIdentity = assessNativeResumeProbe({ runtime: "claude-code", paneCommand, paneContent: screen,
|
||||
claudeAutoIdentityVerified: true, claudeResumeIdentityVerified: true });
|
||||
return withIdentity.status === "resumed" && occupant !== "absent" ? historyProbe : current;
|
||||
return current;
|
||||
}
|
||||
|
||||
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions) {
|
||||
private async assessManagedProbe(binding: NodeBinding, paneCommand: string | null, paneContent: string, options?: ReadinessOptions & { requireCurrentLaunch?: boolean }) {
|
||||
const input = { runtime: "claude-code", paneCommand, paneContent };
|
||||
const probe = assessNativeResumeProbe(input);
|
||||
let probe = assessNativeResumeProbe(input);
|
||||
// Headerless managed launches (including bypass mode) use the same readiness
|
||||
// precedence as resume. This candidate result is returned only after the
|
||||
// exact launch identity below is proved; screen text alone is insufficient.
|
||||
const verifiedProbe = assessNativeResumeProbe({ ...input,
|
||||
claudeAutoIdentityVerified: true, claudeResumeIdentityVerified: true });
|
||||
if (probe.status === "resumed" || verifiedProbe.status !== "resumed") return probe;
|
||||
if ((!options?.requireCurrentLaunch && probe.status === "resumed") || verifiedProbe.status !== "resumed") return probe;
|
||||
if (options?.requireCurrentLaunch) {
|
||||
// The shell may use the same ❯ glyph while its new Claude process is still
|
||||
// starting. Preserve gate results above, and require Claude's drawn input
|
||||
// before pinning that process as the ready launch.
|
||||
if (!hasClaudeDrawnInput(paneContent)) return {
|
||||
status: "inconclusive" as const, code: "awaiting_runtime",
|
||||
detail: "Waiting for the fresh Claude launch to draw its current input frame.",
|
||||
};
|
||||
probe = {
|
||||
status: "inconclusive", code: "awaiting_runtime",
|
||||
detail: "Waiting to confirm the new Claude process in this pane.",
|
||||
};
|
||||
}
|
||||
const launch = this.autoLaunches.get(binding.nodeId);
|
||||
// No launch record (this daemon did not launch the seat): the caller's real binding and the seat's recorded
|
||||
// conversation are the proof's inputs. A record for another launch keeps today's result below.
|
||||
if (!launch) return await this.proveWithoutLaunch(binding, options) ? { ...verifiedProbe, provedWithoutLaunch: true } : probe;
|
||||
if (!launch) {
|
||||
if (options?.requireCurrentLaunch) return probe;
|
||||
return await this.proveWithoutLaunch(binding, options) ? { ...verifiedProbe, provedWithoutLaunch: true } : probe;
|
||||
}
|
||||
if (!binding.tmuxPane || !binding.tmuxSession
|
||||
|| launch.binding.id !== binding.id || launch.binding.tmuxPane !== binding.tmuxPane
|
||||
|| launch.binding.tmuxSession !== binding.tmuxSession
|
||||
|
||||
@@ -79,6 +79,7 @@ export class ClaudeResumeAdapter {
|
||||
launchAdditions?: SeatLaunchAdditions,
|
||||
claudePermissions?: ClaudePermissionRules,
|
||||
beforeNativeDispatch?: () => boolean,
|
||||
executable?: string,
|
||||
): Promise<ResumeResult> {
|
||||
if (!this.canResume(resumeType, resumeToken)) {
|
||||
return { ok: false, code: "no_resume", message: "Claude resume not available" };
|
||||
@@ -97,7 +98,8 @@ export class ClaudeResumeAdapter {
|
||||
if (selectedPermissionMode !== undefined) {
|
||||
try {
|
||||
if (!nodeId || !this.options.claudeManagedLaunch) await unresolvedClaudePermissionModes();
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName }, selectedPermissionMode);
|
||||
managed = await this.options.claudeManagedLaunch!.prepare({ nodeId: nodeId!, cwd, session: tmuxSessionName,
|
||||
...(executable ? { executable } : {}) }, selectedPermissionMode);
|
||||
} catch (error) { return { ok: false, code: "permission_selection_refused", message: (error as Error).message }; }
|
||||
}
|
||||
// A seat's own subscription token, as on a fresh launch (claude-code-adapter.ts).
|
||||
@@ -112,7 +114,7 @@ export class ClaudeResumeAdapter {
|
||||
});
|
||||
const permissionMode = posture + launchArgs.map(arg => ` ${shellQuote(arg)}`).join("");
|
||||
const cmd = managed ? managed.command(["--permission-mode", selectedPermissionMode!, ...launchArgs, ...(model ? ["--model", model] : []), ...(effort ? ["--effort", effort] : []), ...(advisor ? ["--advisor", advisor] : []), ...additionArgs, "--resume", resumeToken!], claudeResumeLaunchEnv(resumeToken!))
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}claude ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
: `${claudeResumeLaunchPrefix(resumeToken!)}${claudeClassicRendererEnvPrefix(process.env)}${executable ? `exec ${shellQuote(executable)}` : "claude"} ${permissionMode}${modelArg}${effortArg}${advisorArg}${additionArg} --resume ${shellQuote(resumeToken!)}`;
|
||||
|
||||
const assertSelected = beforeNativeDispatch ? () => { if (!beforeNativeDispatch()) throw new Error("Selected history changed before Claude resume input"); } : undefined;
|
||||
const beforeManagedInput = beforeNativeDispatch ? () => { assertSelected?.(); managed?.assertCurrent?.(); } : managed?.assertCurrent;
|
||||
|
||||
@@ -174,7 +174,9 @@ export const OMP_PROVIDER_EXTRA_ENV_VARS: Record<string, readonly string[]> = {
|
||||
};
|
||||
|
||||
// Baseline process needs. No host credential families or shell customization.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
// SSH_AUTH_SOCK lets Pi's shell tools run git over SSH (#1159): the path isn't key material, but the socket lets the
|
||||
// agent use the keys loaded in ssh-agent.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
|
||||
// NodeLauncher supplies identity and instance routing on both fresh and resumed
|
||||
// seats. Pi's shell tools inherit this child env: dropping these values makes
|
||||
|
||||
@@ -899,7 +899,7 @@ export class TmuxAdapter {
|
||||
const identity = this.freshManaged.get(target)?.nodeId ?? target;
|
||||
const checkDraft = guard?.needsInputCheck(identity);
|
||||
const policy = checkDraft || options?.beforePaste ? {
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity); await options?.beforePaste?.(); },
|
||||
beforePaste: async () => { if (checkDraft) await guard!.beforeInput(identity, false, text); await options?.beforePaste?.(); },
|
||||
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
|
||||
} : undefined;
|
||||
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
|
||||
@@ -1194,7 +1194,7 @@ export class TmuxAdapter {
|
||||
beforeWrite();
|
||||
const result = await this.respawnPaneUnchecked(pane, command, opts);
|
||||
const guard = this.deliveryGuard;
|
||||
const target = guard?.maybeTarget(pane);
|
||||
const target = guard?.maybeTarget(paneTarget);
|
||||
if (result.ok && target && guard!.ownsLifecycle(target.nodeId)) guard!.noteFreshLifecyclePane(target.nodeId, pane);
|
||||
return result;
|
||||
}, false, "respawn");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
|
||||
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
|
||||
// The canonical ordered migration list. SINGLE SOURCE: the daemon boot path
|
||||
// (startup.ts) and any test/tool that needs a schema-faithful DB both migrate from THIS array,
|
||||
@@ -265,6 +266,7 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueDelegatedDecisionSchema,
|
||||
contextUsageWatchdogUnitsSchema,
|
||||
nodeAdvisorSchema,
|
||||
escalationTargetRefsSchema,
|
||||
operationCorrelationSchema,
|
||||
terminalPolicySchema,
|
||||
streamDispositionsSchema,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** #605: a remote-rig escalation keeps its declared address even while that rig is absent. */
|
||||
export const escalationTargetRefsSchema: Migration = {
|
||||
name: "126_escalation_target_refs.sql",
|
||||
sql: `
|
||||
CREATE TABLE edges_with_refs (
|
||||
id TEXT PRIMARY KEY,
|
||||
rig_id TEXT NOT NULL REFERENCES rigs(id) ON DELETE CASCADE,
|
||||
source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
target_id TEXT REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
target_ref TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK ((target_id IS NOT NULL AND target_ref IS NULL) OR
|
||||
(target_id IS NULL AND target_ref IS NOT NULL AND kind = 'escalates_to'))
|
||||
);
|
||||
INSERT INTO edges_with_refs (id, rig_id, source_id, target_id, kind, created_at)
|
||||
SELECT id, rig_id, source_id, target_id, kind, created_at FROM edges;
|
||||
DROP TABLE edges;
|
||||
ALTER TABLE edges_with_refs RENAME TO edges;
|
||||
CREATE TRIGGER edge_same_rig_insert BEFORE INSERT ON edges BEGIN
|
||||
SELECT RAISE(ABORT, 'edge source and target must belong to the same rig')
|
||||
WHERE NEW.target_id IS NOT NULL AND
|
||||
(SELECT rig_id FROM nodes WHERE id = NEW.source_id) !=
|
||||
(SELECT rig_id FROM nodes WHERE id = NEW.target_id);
|
||||
SELECT RAISE(ABORT, 'edge rig_id must match source node rig_id')
|
||||
WHERE NEW.rig_id != (SELECT rig_id FROM nodes WHERE id = NEW.source_id);
|
||||
END;
|
||||
`,
|
||||
};
|
||||
@@ -134,7 +134,7 @@ export interface ActivityEvidence {
|
||||
livenessSeconds?: number;
|
||||
activity?: ActivityValue;
|
||||
needsInput?: NeedsInput;
|
||||
permissionRequest?: { id: string };
|
||||
permissionRequest?: { id: string; hookAgentId?: string | null };
|
||||
permissionResolved?: boolean;
|
||||
/** Which relay route posted hook evidence, when it said. One event can arrive by more than one route (a Codex seat's
|
||||
* plugin hook and its managed hook), so promotion counts agreements per route. Absent for an untagged post. */
|
||||
|
||||
@@ -8,6 +8,7 @@ export const AGENT_ACTIVITY_FRESHNESS_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface HookActivityInput {
|
||||
turnId?: string | null;
|
||||
hookAgentId?: string | null;
|
||||
|
||||
runtime: string | null;
|
||||
sessionName?: string | null;
|
||||
@@ -120,6 +121,7 @@ export class AgentActivityStore {
|
||||
generation,
|
||||
});
|
||||
activity.turnId = input.turnId ?? null;
|
||||
if (input.hookAgentId !== undefined) activity.hookAgentId = input.hookAgentId;
|
||||
if (input.sourceSequence) activity.sourceSequence = input.sourceSequence;
|
||||
if (input.livenessSeconds) activity.livenessSeconds = input.livenessSeconds;
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ export class ClaimService {
|
||||
const input = { target, tmux: this.tmuxAdapter, listProcesses: this.listProcesses };
|
||||
try {
|
||||
if (runtime === "claude-code") return !!await verifyClaudePaneRuntime(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess(input);
|
||||
if (runtime === "codex") return !!await verifyCodexPaneProcess({ ...input, requireArgv: true });
|
||||
} catch { /* unproven */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTo
|
||||
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
|
||||
export interface ClaudeLaunchTarget {
|
||||
/** A running seat's verified executable, carried through its in-place continuation. */
|
||||
executable?: string;
|
||||
nodeId: string;
|
||||
cwd?: string;
|
||||
session?: string;
|
||||
@@ -221,7 +223,7 @@ export class ClaudeManagedLaunch {
|
||||
return this.selectedConfigDir(nodeId) ? this.configPaths(cwd, nodeId).statePath : undefined;
|
||||
}
|
||||
|
||||
private context(cwd: string, nodeId: string) {
|
||||
private context(cwd: string, nodeId: string, selectedExecutable?: string) {
|
||||
const { PATH, HOME } = this.sessionEnv;
|
||||
const CLAUDE_CONFIG_DIR = this.selectedConfigDir(nodeId);
|
||||
if (!PATH || !HOME || !path.isAbsolute(HOME)) throw new Error("Claude managed launch context is unresolved: managed PATH and absolute HOME are required.");
|
||||
@@ -235,8 +237,9 @@ export class ClaudeManagedLaunch {
|
||||
if (CLAUDE_CONFIG_DIR !== undefined) env.CLAUDE_CONFIG_DIR = configDir;
|
||||
if (claudeClassicRendererEnvPrefix(this.rendererEnv)) env.CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN = "1";
|
||||
let executable: string | undefined;
|
||||
for (const dir of search) {
|
||||
const candidate = path.join(dir, "claude");
|
||||
const candidates = selectedExecutable ? [selectedExecutable] : search.map(dir => path.join(dir, "claude"));
|
||||
for (const candidate of candidates) {
|
||||
if (!path.isAbsolute(candidate)) continue;
|
||||
try { accessSync(candidate, constants.X_OK); if (!statSync(candidate).isFile()) continue; }
|
||||
catch { continue; }
|
||||
executable = realpathSync(candidate); break;
|
||||
@@ -272,7 +275,7 @@ export class ClaudeManagedLaunch {
|
||||
|| (!staged && target.pane != null && target.pane !== before.pane)) {
|
||||
throw new Error("Claude managed launch target disagrees with the current binding; no input or selection changed.");
|
||||
}
|
||||
const context = this.context(cwd, target.nodeId);
|
||||
const context = this.context(cwd, target.nodeId, target.executable);
|
||||
// This is the existing managed session channel, not arbitrary shell variables.
|
||||
// Filter out the explicit capability environment and overwrite launch identity.
|
||||
const inherited = Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
@@ -280,7 +283,7 @@ export class ClaudeManagedLaunch {
|
||||
if (inherited.some(key => !/^[A-Z_][A-Z0-9_]*$/.test(key))) throw new Error("Invalid managed environment key.");
|
||||
const assertCurrent = () => {
|
||||
if (JSON.stringify(this.target(target.nodeId, switching?.fromRuntime)) !== JSON.stringify(before)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(this.context(cwd, target.nodeId, target.executable)) !== JSON.stringify(context)
|
||||
|| JSON.stringify(Object.keys(this.sessionEnv).filter(key => this.sessionEnv[key] !== undefined
|
||||
&& !["PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"].includes(key))) !== JSON.stringify(inherited)) {
|
||||
throw new Error("Claude managed launch context changed during capability discovery/input; retry explicitly.");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import { observeCodexPaneProcess, codexCommandResumeToken, processArgv, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { observeCodexPaneProcess, codexProcessArgv, codexProcessResumeToken, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { lstartToMinTs } from "./codex-thread-id.js";
|
||||
import { parseSqliteUtcMs } from "./sqlite-time.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
@@ -106,13 +106,15 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
return unverified("The predecessor's current occupant and pane binding could not be established.");
|
||||
}
|
||||
try {
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses };
|
||||
const probe = { target: binding.tmuxPane, tmux: input.tmux, listProcesses: input.listProcesses, requireArgv: true };
|
||||
const first = await observeCodexPaneProcess(probe);
|
||||
const start = lstartToMinTs(first?.process.startedAt);
|
||||
const boot = parseSqliteUtcMs(tenure.bootAt);
|
||||
if (!first || start === undefined || !Number.isFinite(boot)) return unverified("The predecessor's native process identity or start time was not established.");
|
||||
const argv = codexProcessArgv(first.process);
|
||||
if (!argv) return unverified("The predecessor's exact native arguments could not be read.");
|
||||
const thread = await input.readThreadId(first.process.pid, first.process.startedAt!);
|
||||
const argvThread = codexCommandResumeToken(first.process.command);
|
||||
const argvThread = codexProcessResumeToken(first.process);
|
||||
if (!thread || (argvThread !== undefined && argvThread !== thread)) return unverified("The predecessor's process and native conversation could not be joined.");
|
||||
const path = input.rollout(thread);
|
||||
const record = path ? readCodexPermissionRecord(path, thread, input.cwd, Math.max(start * 1000 + 999, boot)) : null;
|
||||
@@ -124,7 +126,6 @@ export async function observeCodexRebuildPermissions(input: {
|
||||
|| current?.tmuxPane !== binding.tmuxPane || current?.tmuxSession !== binding.tmuxSession) {
|
||||
return unverified("The predecessor changed while its native permission policy was read.");
|
||||
}
|
||||
const argv = processArgv(first.process.command);
|
||||
const profileAt = argv.findIndex(arg => arg === "-p" || arg === "--profile");
|
||||
return { state: "observed", ...record, processStartedAt: first.process.startedAt!,
|
||||
profile: profileAt >= 0 ? argv[profileAt + 1] ?? null : null };
|
||||
|
||||
@@ -137,6 +137,8 @@ export interface ComposerInput {
|
||||
state: "empty" | "text" | "unknown";
|
||||
rows: string[];
|
||||
columns: number;
|
||||
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
|
||||
pasteViewport?: { columns: number; rows: number };
|
||||
cursor?: ComposerSnapshot["cursor"];
|
||||
unknownReason?: string;
|
||||
suggestionRows?: string[];
|
||||
@@ -177,6 +179,30 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Conservative uncertainty before a protected Codex paste, never Enter authority.
|
||||
* Codex 0.164 collapses >1000 code points to [Pasted Content N chars], which
|
||||
* the current ownership reader cannot own. Other wrapped text gets a row
|
||||
* upper bound; a line fitting the native width needs only one row.
|
||||
*/
|
||||
export function composerPasteUncertainty(input: ComposerInput, text: string): "controls" | "long" | "viewport" | null {
|
||||
const viewport = input.pasteViewport;
|
||||
if (!viewport) return null;
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines.some(line => /[\x00-\x1f\x7f-\x9f]/.test(line))) return "controls";
|
||||
// rust-v0.164.0 chat_composer/paste_input.rs:184-192 (Unicode scalar count).
|
||||
if ([...normalized].length > 1000) return "long";
|
||||
if (viewport.columns < 1 || viewport.rows < 1) return "viewport";
|
||||
let rows = 0;
|
||||
for (const line of lines) {
|
||||
// Two cells per non-ASCII code point conservatively covers wide glyphs.
|
||||
const cells = [...line].reduce((sum, ch) => sum + (ch >= " " && ch <= "~" ? 1 : 2), 0);
|
||||
rows += cells <= viewport.columns ? 1 : Math.ceil(2 * cells / viewport.columns) + 1;
|
||||
if (rows > viewport.rows) return "viewport";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recognize this lease's paste immediately after pasting or before its Enter
|
||||
* in the original composer. A later lease cannot acquire ownership from a label.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { defaultResolveOrchestrator, resolveSessionNodeId } from "./queue-owner.js";
|
||||
|
||||
export const ESCALATION_HOP_TAG = "escalation-route:one-hop";
|
||||
/** A consumed hop belongs to this recovery's handoff ancestry, never its recipient's other work. */
|
||||
export function hasConsumedEscalationHop(db: Database.Database, qitemId: string): boolean {
|
||||
return Boolean(db.prepare(`WITH RECURSIVE ancestry(qitem_id, handed_off_from, tags) AS (
|
||||
SELECT qitem_id, handed_off_from, tags FROM queue_items WHERE qitem_id = ?
|
||||
UNION
|
||||
SELECT q.qitem_id, q.handed_off_from, q.tags FROM queue_items q
|
||||
JOIN ancestry a ON q.qitem_id = a.handed_off_from
|
||||
) SELECT 1 FROM ancestry WHERE json_valid(tags)
|
||||
AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?) LIMIT 1`).get(qitemId, ESCALATION_HOP_TAG));
|
||||
}
|
||||
|
||||
export interface EscalationOwner {
|
||||
owner: string | null;
|
||||
outcome: "parent" | "none" | "routed" | "unavailable" | "ambiguous" | "self" | "cycle" | "unknown";
|
||||
reason: string;
|
||||
}
|
||||
interface Seat { id: string; address: string; session_name: string | null; status: string | null }
|
||||
interface Arc { source_id: string; target_id: string | null; target_ref: string | null; kind: string }
|
||||
|
||||
/** Resolve one delivery hop. Graph inspection is topology-only, including stopped seats;
|
||||
* runtime status is consulted only after proving the selected target isn't in a cycle. */
|
||||
export function resolveEscalationOwner(db: Database.Database, session: string, acceptsDestination?: (destination: string) => boolean): EscalationOwner {
|
||||
const source = resolveSessionNodeId(db, session);
|
||||
const parent = defaultResolveOrchestrator(db, session);
|
||||
if (!source) return { owner: parent, outcome: "none", reason: "no recorded source topology" };
|
||||
// A declared parent without a session still wins over a newly declared fallback.
|
||||
if (db.prepare("SELECT 1 FROM edges WHERE target_id=? AND kind='delegates_to'").get(source)) {
|
||||
return { owner: parent, outcome: "parent", reason: "declared delegation parent retained" };
|
||||
}
|
||||
if (!db.prepare("SELECT 1 FROM edges WHERE source_id=? AND kind='escalates_to'").get(source)) {
|
||||
return { owner: null, outcome: "none", reason: "no escalation destination declared" };
|
||||
}
|
||||
// Curated/older test stores without the new column have no deferred targets.
|
||||
const hasRefs = (db.prepare("PRAGMA table_info(edges)").all() as Array<{ name: string }>).some(c => c.name === "target_ref");
|
||||
const arcs = db.prepare(`SELECT source_id, target_id, ${hasRefs ? "target_ref" : "NULL AS target_ref"}, kind
|
||||
FROM edges WHERE kind IN ('delegates_to','escalates_to')`).all() as Arc[];
|
||||
const declared = arcs.filter(a => a.kind === "escalates_to" && a.source_id === source);
|
||||
if (!declared.length) return { owner: null, outcome: "none", reason: "no escalation destination declared" };
|
||||
const fail = (outcome: EscalationOwner["outcome"], detail: string): EscalationOwner =>
|
||||
({ owner: null, outcome, reason: `escalates_to ${outcome}: ${detail}; existing fallback retained` });
|
||||
if (declared.length !== 1) return fail("ambiguous", "multiple declared destinations");
|
||||
const seats = db.prepare(`SELECT n.id, replace(n.logical_id,'.','-') || '@' || r.name AS address,
|
||||
s.session_name, s.status FROM nodes n JOIN rigs r ON r.id=n.rig_id
|
||||
LEFT JOIN sessions s ON s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE r.archived_at IS NULL`).all() as Seat[];
|
||||
const endpoints = (arc: Arc): Seat[] => arc.target_ref
|
||||
? (() => {
|
||||
const exact = seats.filter(s => s.session_name === arc.target_ref);
|
||||
return exact.length ? exact : seats.filter(s => s.address === arc.target_ref);
|
||||
})()
|
||||
: seats.filter(s => s.id === arc.target_id);
|
||||
const targets = endpoints(declared[0]!);
|
||||
if (!targets.length) return fail("unavailable", "destination absent from this daemon's active topology");
|
||||
if (targets.length !== 1) return fail("ambiguous", "destination names more than one seat");
|
||||
const target = targets[0]!;
|
||||
if (target.id === source) return fail("self", "destination is the source seat");
|
||||
|
||||
const visiting = new Set<string>([source]);
|
||||
const visited = new Set<string>();
|
||||
let unknown = false;
|
||||
const cyclic = (id: string): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const parents = arcs.filter(a => a.kind === "delegates_to" && a.target_id === id);
|
||||
const next = parents.length ? parents.map(a => seats.filter(s => s.id === a.source_id))
|
||||
: arcs.filter(a => a.kind === "escalates_to" && a.source_id === id).map(endpoints);
|
||||
if (next.length > 1) unknown = true;
|
||||
for (const matches of next) {
|
||||
if (matches.length !== 1) unknown = true;
|
||||
for (const seat of matches) if (cyclic(seat.id)) return true;
|
||||
}
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
if (cyclic(target.id)) return fail("cycle", "declared escalation/delegation topology contains a cycle");
|
||||
if (unknown) return fail("unknown", "cycle check includes an unresolved or ambiguous declared endpoint");
|
||||
if (!target.session_name || !["running", "idle"].includes(target.status ?? "")) {
|
||||
return fail("unavailable", "destination has no running recorded session");
|
||||
}
|
||||
if (acceptsDestination && !acceptsDestination(target.session_name)) {
|
||||
return fail("unavailable", "destination not admitted by queue");
|
||||
}
|
||||
return { owner: target.session_name, outcome: "routed", reason: `escalates_to routed: ${session} -> ${target.session_name} (one hop)` };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** A same-daemon canonical seat address, not a host-qualified or synthetic destination. */
|
||||
export function isEscalationTargetRef(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+$/.test(value)
|
||||
&& !value.startsWith("human@") && !value.endsWith("@external");
|
||||
}
|
||||
@@ -32,7 +32,8 @@
|
||||
// specimen class) is gateway-routable: a PANELLESS virtual identity whose
|
||||
// delivery rides the gateway, exactly like its @external address.
|
||||
// 4. A hosted member whose roster entry declares a trigger is hosted. One with no
|
||||
// trigger stays unroutable, so its person is told.
|
||||
// trigger stays unroutable, so its person is told; its teaching says it reads the
|
||||
// queue when its owner next starts a session, not to fix the address.
|
||||
// 5. Anything else is unroutable, with teaching that names BOTH checks.
|
||||
import { parseSessionName } from "../session-name.js";
|
||||
import type { RosterWake } from "../rosters.js";
|
||||
@@ -52,6 +53,8 @@ export interface ClassifyDeps {
|
||||
hasTerminalTransport: (destination: string) => boolean;
|
||||
/** The trigger a hosted member's roster entry declares, when it declares one. */
|
||||
hostedWake?: (destination: string) => RosterWake | null;
|
||||
/** Whether a roster lists the destination as a hosted member, trigger or not. */
|
||||
isHostedMember?: (destination: string) => boolean;
|
||||
}
|
||||
|
||||
export function classifyDestination(destination: string, deps: ClassifyDeps): DestinationClass {
|
||||
@@ -69,6 +72,14 @@ export function classifyDestination(destination: string, deps: ClassifyDeps): De
|
||||
}
|
||||
const wake = deps.hostedWake?.(destination) ?? null;
|
||||
if (wake) return { class: "hosted", wake };
|
||||
if (deps.isHostedMember?.(destination)) {
|
||||
// The member reads this teaching too: the address is right, and it isn't a person to register.
|
||||
return {
|
||||
class: "unroutable",
|
||||
teaching: `unroutable: '${destination}' is a hosted member with no wake trigger, so nothing can push work to it. ` +
|
||||
`It reads the queue when its owner next starts a session.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
class: "unroutable",
|
||||
teaching:
|
||||
|
||||
@@ -205,26 +205,19 @@ export function makeQueuePorts(
|
||||
opts: { loadHumanRegistry?: () => LoadResult; sharedChannels?: () => ReadonlyArray<{ address: string }> } = {},
|
||||
): InboundQueuePort & OutboundQueuePort & { listOpenAsks(filter: AlertFilterOpts): Promise<OpenAskEpisode[]> } {
|
||||
/** Each active row's current owner-notification episode, projected to its person or shared channel. Posted episodes
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered. */
|
||||
* are skipped unless asked for (the alert sweep never wants them). Null when nobody is registered.
|
||||
*
|
||||
* Eligibility lives in the query now: an active row must carry an owner notification, and — for the alert sweep —
|
||||
* it must still be unposted to Slack. Only eligible rows reach the per-row lookup, so a pathological backlog of
|
||||
* active rows without any notification costs a single read instead of one latest-transition and one receipt lookup
|
||||
* per row. There is no row cap: an eligible item older than any window still comes back in both views. */
|
||||
const currentEpisodes = (includePosted: boolean): Array<{ item: QueueItem; posted: boolean }> | null => {
|
||||
// Nobody registered yet isn't a broken registry: rows to a registered shared channel still post (0.7.3).
|
||||
const people = registeredPeople((opts.loadHumanRegistry ?? (() => loadHumanRegistry()))());
|
||||
if (!people) return null;
|
||||
const channels = (opts.sharedChannels ?? (() => sharedChannelAddresses()))();
|
||||
// Avoid building waiting/recovery views for rows with no unposted owner notification.
|
||||
// Keep list(activeOnly)'s selection, ordering and cap; this remains an O(N) ID scan.
|
||||
const ids = queueRepo.db.prepare(
|
||||
`SELECT qitem_id FROM queue_items
|
||||
WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
ORDER BY CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC
|
||||
LIMIT ?`,
|
||||
).all(1000000) as Array<{ qitem_id: string }>;
|
||||
return ids.flatMap(({ qitem_id: qitemId }) => {
|
||||
const transition = queueRepo.transitionLog.latestOwnerNotificationForQitem(qitemId);
|
||||
if (!transition) return [];
|
||||
const notificationKey = `${qitemId}:${transition.transitionId}`;
|
||||
const posted = queueRepo.transitionLog.hasOwnerNotificationReceipt(qitemId, notificationKey);
|
||||
if (posted && !includePosted) return [];
|
||||
const episodes = queueRepo.transitionLog.activeOwnerNotificationEpisodes(includePosted);
|
||||
return episodes.flatMap(({ qitemId, transition, posted }) => {
|
||||
const row = queueRepo.getById(qitemId);
|
||||
if (!row) return [];
|
||||
const item = project(row, transition, people, channels);
|
||||
|
||||
@@ -233,7 +233,8 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
|
||||
});
|
||||
}
|
||||
|
||||
const edges: RFEdge[] = rigEdges.map((edge) => ({
|
||||
// Deferred cross-rig addresses are not nodes in this rig-local graph.
|
||||
const edges: RFEdge[] = rigEdges.filter(edge => !edge.targetRef).map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.sourceId,
|
||||
target: edge.targetId,
|
||||
|
||||
@@ -103,13 +103,25 @@ interface HeaderScan {
|
||||
unterminatedFenceLine: number | null;
|
||||
}
|
||||
|
||||
/** Scan for real headers, skipping fenced code blocks (``` or ~~~, any info string;
|
||||
/** Scan for real headers, skipping HTML comment blocks and fenced code blocks (``` or ~~~, any info string;
|
||||
* a fence closes only on the same marker at the same or greater length). */
|
||||
function scanHeaders(lines: string[]): HeaderScan {
|
||||
const hits: HeaderHit[] = [];
|
||||
let fence: { marker: string; length: number; line: number } | null = null;
|
||||
let comment = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
// HTML comment blocks end on the line containing --> (or at EOF). Their
|
||||
// headings and fences are literal content, just as comment markers inside
|
||||
// a fenced code block are literal content. Keep the original span bytes.
|
||||
if (comment) {
|
||||
if (line.includes("-->")) comment = false;
|
||||
continue;
|
||||
}
|
||||
if (!fence && /^ {0,3}<!--/.test(line)) {
|
||||
comment = !line.includes("-->");
|
||||
continue;
|
||||
}
|
||||
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1]![0]!;
|
||||
|
||||
@@ -77,6 +77,8 @@ export class NtfyNotificationAdapter implements NotificationAdapter {
|
||||
headers,
|
||||
body: payload.body,
|
||||
});
|
||||
// Only the status is used; release unread bodies without delaying delivery on cleanup.
|
||||
void res.body?.cancel().catch(() => {});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `ntfy POST ${res.status}` };
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ export class WebhookNotificationAdapter implements NotificationAdapter {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Only the status is used; release unread bodies without delaying delivery on cleanup.
|
||||
void res.body?.cancel().catch(() => {});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `webhook POST ${res.status}` };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_PIDS = 128;
|
||||
const MAX_BYTES = 1024 * 1024;
|
||||
const MAX_ARGS = 4096;
|
||||
const MAX_OUTPUT = 2 * MAX_BYTES;
|
||||
|
||||
// KERN_PROCARGS2 begins with argc and the executable path, followed by argc NUL-delimited
|
||||
// arguments and then the environment. Only the arguments are returned by this helper.
|
||||
const MAC_ARGUMENTS = `ObjC.import("Foundation");
|
||||
ObjC.bindFunction("sysctl", ["int", ["void *", "uint32_t", "void *", "void *", "void *", "size_t"]]);
|
||||
function number(bytes, count) {
|
||||
var value = 0;
|
||||
for (var i = count - 1; i >= 0; i--) value = value * 256 + bytes.charCodeAt(i);
|
||||
return value;
|
||||
}
|
||||
function parse(data, used) {
|
||||
if (used < 7 || used > 1048576) return null;
|
||||
var exact = data.subdataWithRange({location: 0, length: used});
|
||||
var bytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact, $.NSISOLatin1StringEncoding));
|
||||
if (typeof bytes !== "string" || bytes.length !== used) return null;
|
||||
var argc = number(bytes, 4);
|
||||
if (argc < 1 || argc > 4096) return null;
|
||||
var pathEnd = bytes.indexOf("\\u0000", 4);
|
||||
if (pathEnd <= 4) return null;
|
||||
// XNU aligns ip_startargv after the saved path to the target's pointer size.
|
||||
// KERN_PROCARGS2 strips the 16-byte executable_path= prefix. Supported macOS
|
||||
// processes are 64-bit, so the path and its padding occupy whole 8-byte units.
|
||||
// Skipping every zero would also skip an empty argv[0] and read env as argv.
|
||||
var offset = 4 + Math.ceil((pathEnd - 4 + 1) / 8) * 8;
|
||||
if (offset >= used) return null;
|
||||
for (var padding = pathEnd + 1; padding < offset; padding++) {
|
||||
if (bytes.charCodeAt(padding) !== 0) return null;
|
||||
}
|
||||
var args = [];
|
||||
for (var index = 0; index < argc; index++) {
|
||||
if (offset >= used) return null;
|
||||
var end = bytes.indexOf("\\u0000", offset);
|
||||
if (end < 0 || end >= used) return null;
|
||||
var argument = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(exact.subdataWithRange({location: offset, length: end - offset}), $.NSUTF8StringEncoding));
|
||||
if (typeof argument !== "string") return null;
|
||||
// NSString consumes a UTF-8 BOM at the start of each decoded unit. Restore
|
||||
// it so a BOM-prefixed token cannot become an exact conversation UUID.
|
||||
if (end - offset >= 3 && bytes.charCodeAt(offset) === 239 && bytes.charCodeAt(offset + 1) === 187
|
||||
&& bytes.charCodeAt(offset + 2) === 191) argument = String.fromCharCode(65279) + argument;
|
||||
args.push(argument);
|
||||
offset = end + 1;
|
||||
}
|
||||
return args[0] ? args : null;
|
||||
}
|
||||
function run(inputs) {
|
||||
var data = $.NSMutableData.dataWithLength(1048576);
|
||||
var outputSize = 0;
|
||||
return JSON.stringify(inputs.map(function(input) {
|
||||
var request = JSON.parse(input);
|
||||
if (outputSize > 2097152) return [request.pid, null];
|
||||
if (request.fixture) {
|
||||
var fixture = $.NSData.alloc.initWithBase64EncodedStringOptions(request.fixture, 0);
|
||||
return [request.pid, parse(fixture, Number(fixture.length))];
|
||||
}
|
||||
var mib = $.NSData.alloc.initWithBase64EncodedStringOptions(request.mib, 0);
|
||||
var size = $.NSData.alloc.initWithBase64EncodedStringOptions(request.size, 0).mutableCopy;
|
||||
if ($.sysctl(mib.bytes, 3, data.mutableBytes, size.mutableBytes, null, 0) !== 0) return [request.pid, null];
|
||||
var sizeBytes = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(size, $.NSISOLatin1StringEncoding));
|
||||
var used = typeof sizeBytes === "string" && sizeBytes.length === 8 ? number(sizeBytes, 8) : 0;
|
||||
var args = parse(data, used);
|
||||
if (args) {
|
||||
var answerSize = JSON.stringify(args).length;
|
||||
if (outputSize + answerSize > 2097152) return [request.pid, null];
|
||||
outputSize += answerSize;
|
||||
}
|
||||
return [request.pid, args];
|
||||
}));
|
||||
}`;
|
||||
|
||||
/** Parse a Linux /proc cmdline without ever treating malformed bytes as an argument boundary. */
|
||||
export function parseLinuxProcessArguments(bytes: Buffer): string[] | null {
|
||||
if (bytes.length < 2 || bytes.length > MAX_BYTES || bytes.at(-1) !== 0) return null;
|
||||
const args: string[] = [];
|
||||
// TextDecoder strips an initial BOM by default, even when decoding each argv
|
||||
// unit separately. Keep that byte sequence as U+FEFF so it cannot become a UUID.
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
let start = 0;
|
||||
try {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
if (bytes[index] !== 0) continue;
|
||||
args.push(decoder.decode(bytes.subarray(start, index)));
|
||||
if (args.length > MAX_ARGS) return null;
|
||||
start = index + 1;
|
||||
}
|
||||
} catch { return null; }
|
||||
return args[0] ? args : null;
|
||||
}
|
||||
|
||||
async function boundedLinuxCmdline(pid: number, signal: AbortSignal): Promise<string[] | null> {
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for await (const chunk of createReadStream(`/proc/${pid}/cmdline`, { highWaterMark: 64 * 1024, signal })) {
|
||||
length += chunk.length;
|
||||
if (length > MAX_BYTES) return null;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return parseLinuxProcessArguments(Buffer.concat(chunks, length));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Exercise the same macOS KERN_PROCARGS2 parser with a crafted buffer, without reading any process. */
|
||||
export async function parseMacProcessArgumentsForTest(bytes: Buffer): Promise<string[] | null> {
|
||||
if (process.platform !== "darwin") return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS,
|
||||
JSON.stringify({ pid: 0, fixture: bytes.toString("base64") })], { encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT });
|
||||
const parsed: unknown = JSON.parse(stdout);
|
||||
return Array.isArray(parsed) && Array.isArray(parsed[0]) && Array.isArray(parsed[0][1]) ? parsed[0][1] as string[] : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Exact OS argv for bounded native Codex candidates. Unavailable or malformed reads have no witness. */
|
||||
export async function readNativeProcessArguments(requested: number[]): Promise<Map<number, string[]>> {
|
||||
const pids = [...new Set(requested.filter(pid => Number.isInteger(pid) && pid > 0 && pid <= 2147483647))].slice(0, MAX_PIDS);
|
||||
const result = new Map<number, string[]>();
|
||||
if (pids.length === 0) return result;
|
||||
let entries: unknown;
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const found: Array<[number, string[] | null]> = [];
|
||||
// Bounded concurrency and one overall deadline keep a slow /proc from holding observation indefinitely.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
for (let start = 0; start < pids.length; start += 16) {
|
||||
const batch = await Promise.all(pids.slice(start, start + 16).map(async (pid): Promise<[number, string[] | null]> =>
|
||||
[pid, await boundedLinuxCmdline(pid, controller.signal)]));
|
||||
found.push(...batch);
|
||||
if (controller.signal.aborted) break;
|
||||
}
|
||||
} finally { clearTimeout(timer); }
|
||||
entries = found;
|
||||
} else if (process.platform === "darwin") {
|
||||
const size = Buffer.alloc(8);
|
||||
size.writeBigUInt64LE(BigInt(MAX_BYTES));
|
||||
const requests = pids.map(pid => {
|
||||
const mib = Buffer.alloc(12);
|
||||
mib.writeInt32LE(1, 0); // CTL_KERN
|
||||
mib.writeInt32LE(49, 4); // KERN_PROCARGS2
|
||||
mib.writeInt32LE(pid, 8);
|
||||
return JSON.stringify({ pid, mib: mib.toString("base64"), size: size.toString("base64") });
|
||||
});
|
||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_ARGUMENTS, ...requests], {
|
||||
encoding: "utf8", timeout: 2000, maxBuffer: MAX_OUTPUT,
|
||||
});
|
||||
entries = JSON.parse(stdout);
|
||||
} else return result;
|
||||
if (!Array.isArray(entries)) return result;
|
||||
const requestedPids = new Set(pids);
|
||||
for (const entry of entries) {
|
||||
if (!Array.isArray(entry) || !requestedPids.has(entry[0])) continue;
|
||||
const args: unknown = entry[1];
|
||||
if (Array.isArray(args) && args.length > 0 && args.length <= MAX_ARGS && typeof args[0] === "string" && args[0].length > 0
|
||||
&& args.every(arg => typeof arg === "string" && !arg.includes("\0"))) result.set(entry[0], args);
|
||||
}
|
||||
} catch { /* No OS argv is unknown, never a fallback to flattened ps text. */ }
|
||||
return result;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
import { readNativeExecutablePaths } from "./native-process-executable.js";
|
||||
import { readNativeProcessArguments } from "./native-process-arguments.js";
|
||||
import { readProcessTable } from "./process-table.js";
|
||||
|
||||
export interface NativeProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
/** Exact OS argv for native Codex. Missing, malformed, or unreadable argv never proves Codex identity. */
|
||||
argv?: string[] | null;
|
||||
pgid?: number;
|
||||
tpgid?: number;
|
||||
executableName?: string;
|
||||
@@ -128,21 +131,18 @@ function mayBeClaudeAfterScript(row: NativeProcessRow): boolean {
|
||||
}
|
||||
|
||||
function commandUsesExpectedToken(command: string, runtime: NativeRuntime, expectedToken: string): boolean {
|
||||
// Codex takes the exact-argv selector in findExactNativeResumeProcess before this Claude-only search.
|
||||
if (runtime !== "claude-code") return false;
|
||||
const argv = tokens(command);
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
const executableIndex = argv.findIndex((token) => runtime === "claude-code" ? claudeExecutable(token) : executableName(token) === executable);
|
||||
const executableIndex = argv.findIndex((token) => claudeExecutable(token));
|
||||
if (executableIndex < 0) return false;
|
||||
const args = argv.slice(executableIndex + 1);
|
||||
if (runtime === "claude-code") {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
|
||||
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
|
||||
}
|
||||
return false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]!;
|
||||
if ((arg === "--resume" || arg === "--session-id") && args[index + 1] === expectedToken) return true;
|
||||
if (arg === `--resume=${expectedToken}` || arg === `--session-id=${expectedToken}`) return true;
|
||||
}
|
||||
|
||||
return codexConversationToken(args, "resume") === expectedToken;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Interactive options can appear before or after `resume`/`fork`. Their values
|
||||
@@ -167,7 +167,7 @@ function codexOptionEnd(args: string[], index: number): number {
|
||||
}
|
||||
|
||||
// undefined is another command; null is the requested command without an exact token.
|
||||
function codexConversationToken(args: string[], subcommand: "resume" | "fork"): string | null | undefined {
|
||||
function codexConversationToken(args: string[], subcommand: "resume" | "fork", exactArgv = false): string | null | undefined {
|
||||
let resumeIndex = -1;
|
||||
for (let index = 0; index < args.length;) {
|
||||
const arg = args[index]!;
|
||||
@@ -186,10 +186,10 @@ function codexConversationToken(args: string[], subcommand: "resume" | "fork"):
|
||||
if (arg === "--") { conversation ??= resumeArgs[index + 1] ?? null; break; }
|
||||
if (arg === "--last") last = true;
|
||||
if (arg.startsWith("-")) { index = codexOptionEnd(resumeArgs, index); continue; }
|
||||
// ps has lost argv boundaries: after a session and the first prompt word,
|
||||
// later option-looking words belong to the prompt. A real --last conflicts
|
||||
// with a session plus a prompt in Codex's interactive grammar.
|
||||
if (conversation !== null) break;
|
||||
// A flattened ps command cannot tell a quoted prompt from several argv entries. Exact
|
||||
// argv can: keep scanning for a real --last after a prompt, while a literal --last
|
||||
// inside one prompt argument never equals the option.
|
||||
if (conversation !== null) { if (!exactArgv) break; index += 1; continue; }
|
||||
conversation = arg;
|
||||
index += 1;
|
||||
}
|
||||
@@ -215,6 +215,25 @@ export function codexCommandResumeToken(command: string): string | null | undefi
|
||||
return codexConversationToken(tokens(command).slice(1), "resume");
|
||||
}
|
||||
|
||||
/** The native process's own resume identity. Flattened ps text is never positive proof. */
|
||||
export function codexProcessResumeToken(row: Pick<NativeProcessRow, "argv">): string | null | undefined {
|
||||
const argv = codexProcessArgv(row);
|
||||
return argv ? codexConversationToken(argv.slice(1), "resume", true) : undefined;
|
||||
}
|
||||
|
||||
function validCodexArgv(row: Pick<NativeProcessRow, "argv">): row is { argv: string[] } {
|
||||
let bytes = 0;
|
||||
return Array.isArray(row.argv) && row.argv.length > 0 && row.argv.length <= 4096
|
||||
&& typeof row.argv[0] === "string" && row.argv[0].length > 0
|
||||
&& row.argv.every(arg => typeof arg === "string" && !arg.includes("\0")
|
||||
&& (bytes += Buffer.byteLength(arg)) <= 1024 * 1024);
|
||||
}
|
||||
|
||||
/** Exact, bounded argv from a process whose argv[0] also names Codex. No ps-text fallback. */
|
||||
export function codexProcessArgv(row: Pick<NativeProcessRow, "argv">): string[] | null {
|
||||
return validCodexArgv(row) && executableName(row.argv[0]!) === "codex" ? row.argv : null;
|
||||
}
|
||||
|
||||
type ClaudeArgvIds = { resume: string | null; "session-id": string | null };
|
||||
|
||||
// OPR.0.7.0.9: a seat's launch additions, read the same way by both selectors. The options OpenRig already emitted
|
||||
@@ -496,6 +515,19 @@ export async function listNativeProcesses(options: { claudeLaunchPaths?: boolean
|
||||
const paths = await readNativeExecutablePaths(candidates.map(row => row.pid));
|
||||
for (const row of candidates) row.executablePath = paths.get(row.pid);
|
||||
}
|
||||
// Linux argv was captured with this same /proc table sample; never read real /proc
|
||||
// again after a caller has selected a fixture or alternate process-table root.
|
||||
// Only foreground rows can be selected below. Skip macOS helper reads for
|
||||
// background Codex processes; every foreground seat still gets fresh argv.
|
||||
const codexCandidates = process.platform === "darwin"
|
||||
? rows.filter(row => row.executableName === "codex" && row.pgid !== undefined && row.pgid > 0 && row.pgid === row.tpgid)
|
||||
: [];
|
||||
if (codexCandidates.length > 0) {
|
||||
// A failed or timed-out helper read loses argv evidence, not the process table.
|
||||
// The foreground Codex still participates in delivery's uniqueness check.
|
||||
const argumentsByPid = await readNativeProcessArguments(codexCandidates.map(row => row.pid)).catch(() => new Map<number, string[]>());
|
||||
for (const row of codexCandidates) row.argv = argumentsByPid.get(row.pid) ?? null;
|
||||
}
|
||||
return rows;
|
||||
} catch { return []; }
|
||||
}
|
||||
@@ -532,24 +564,30 @@ function candidatesMatching(rows: NativeProcessRow[], panePid: number, runtime:
|
||||
const executable = runtime === "claude-code" ? "claude" : "codex";
|
||||
for (const row of rows) {
|
||||
const osExecutable = runtime === "claude-code" ? executableName(row.executableName ?? "") : row.executableName;
|
||||
if ((runtime === "claude-code" ? !claudeRow(row)
|
||||
: osExecutable !== executable || executableName(tokens(row.command)[0] ?? "") !== executable)
|
||||
|| row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
if (row.pgid !== root.tpgid || row.tpgid !== root.tpgid) continue;
|
||||
if (runtime === "claude-code" ? !claudeRow(row) : osExecutable !== executable) continue;
|
||||
// OS-named Codex with unreadable argv still competes for the foreground.
|
||||
// A readable argv[0] naming a different executable is positive contrary evidence.
|
||||
if (runtime === "codex" && validCodexArgv(row) && !codexProcessArgv(row)) return [];
|
||||
const chain: NativeProcessRow[] = [];
|
||||
const visited = new Set<number>();
|
||||
let current: NativeProcessRow | undefined = row;
|
||||
let belongsToPane = false;
|
||||
while (current && !visited.has(current.pid) && current.startedAt) {
|
||||
visited.add(current.pid);
|
||||
chain.push(current);
|
||||
if (current.pid === panePid) { matches.push({ process: row, chain }); break; }
|
||||
if (current.pid === panePid) { matches.push({ process: row, chain }); belongsToPane = true; break; }
|
||||
current = byPid.get(current.ppid);
|
||||
}
|
||||
if (runtime === "codex" && !belongsToPane) return [];
|
||||
}
|
||||
return matches.map(({ process, chain }) => ({ panePid, process,
|
||||
fingerprint: JSON.stringify(chain.map(row => [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
|
||||
fingerprint: JSON.stringify(chain.map(row => runtime === "codex"
|
||||
? [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath, row.argv]
|
||||
: [row.pid, row.ppid, row.startedAt, row.pgid, row.tpgid, row.executableName, row.command, row.executablePath])) }));
|
||||
}
|
||||
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean): NativeProcessObservation | null {
|
||||
function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expectedToken?: string | null, requireResume = false, runtime: NativeRuntime = "codex", selectedExecutable?: string, rotation?: ClaudeResumeRotation | null, scriptLaunch?: boolean, requireArgv = false): NativeProcessObservation | null {
|
||||
const matches = nativeProcessCandidates(rows, panePid, runtime, selectedExecutable, scriptLaunch);
|
||||
// #1079: a Codex launcher that spawns Codex is one runtime on one chain.
|
||||
const chain = runtime === "codex" ? launcherChain(matches, rows) : matches;
|
||||
@@ -565,8 +603,11 @@ function selectNativeProcess(rows: NativeProcessRow[], panePid: number, expected
|
||||
// links naming different conversations stay refused. Exact resume proof
|
||||
// needs the deepest process's own argv: a launcher's token is never
|
||||
// inherited by a child that names nothing, which stays deliverable but unproved.
|
||||
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
|
||||
const identities = chain.map((link) => codexProcessResumeToken(link.process));
|
||||
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return null;
|
||||
// Exact resume proof needs readable argv across the whole foreground chain:
|
||||
// an opaque launcher may name another conversation even when its child agrees.
|
||||
if ((requireResume || requireArgv) && chain.some((link) => !codexProcessArgv(link.process))) return null;
|
||||
if (requireResume && (!expectedToken || identities[0] !== expectedToken)) return null;
|
||||
if (expectedToken !== undefined && identities.some((value) => value !== undefined
|
||||
&& (!expectedToken || value !== expectedToken))) return null;
|
||||
@@ -620,10 +661,11 @@ export function explainNativeRejection(rows: NativeProcessRow[], panePid: number
|
||||
if (launched === rotation?.token) return rotationProcess(process, rotation) ? { kind: "passes" } : { kind: "rotation_process_mismatch", conversation: launched };
|
||||
return { kind: "other_conversation", conversation: launched };
|
||||
}
|
||||
const identities = chain.map((link) => codexConversationToken(tokens(link.process.command).slice(1), "resume"));
|
||||
const identities = chain.map((link) => codexProcessResumeToken(link.process));
|
||||
if (new Set(identities.filter((value) => typeof value === "string")).size > 1) return { kind: "conflicting_conversations" };
|
||||
if (requireResume) {
|
||||
if (!expectedToken) return { kind: "no_recorded_conversation" };
|
||||
if (chain.some((link) => !validCodexArgv(link.process))) return { kind: "not_a_resume" };
|
||||
if (identities[0] === undefined) return { kind: "not_a_resume" };
|
||||
if (identities[0] === null) return { kind: "names_no_conversation" };
|
||||
if (identities[0] !== expectedToken) return { kind: "other_conversation", conversation: identities[0] };
|
||||
@@ -642,6 +684,8 @@ async function observeNativePaneProcess(input: {
|
||||
listProcesses?: NativeProcessLister;
|
||||
expectedToken?: string | null;
|
||||
requireResume?: boolean;
|
||||
/** Codex only: require exact argv on every chain link without requiring a resume (fresh launches remain eligible). */
|
||||
requireArgv?: boolean;
|
||||
/** Canonical executable frozen by the managed launch, never re-resolved at observation time. */
|
||||
selectedExecutable?: string;
|
||||
/** Claude only: the frozen executable is a `#!` script, so a process whose OS executable path is named `claude` is
|
||||
@@ -656,7 +700,7 @@ async function observeNativePaneProcess(input: {
|
||||
if (!pid) return null;
|
||||
const rows = await (input.listProcesses ?? (() => listNativeProcesses({ claudeLaunchPaths: input.scriptLaunch === true })))();
|
||||
return selectNativeProcess(rows, pid, input.expectedToken, input.requireResume, runtime, input.selectedExecutable,
|
||||
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch);
|
||||
runtime === "claude-code" ? input.rotation : undefined, input.scriptLaunch, input.requireArgv);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -678,11 +722,18 @@ export async function observeCodexForkProcess(input: {
|
||||
const rows = await (input.listProcesses ?? listNativeProcesses)();
|
||||
const chain = launcherChain(nativeProcessCandidates(rows, pid, "codex"), rows);
|
||||
if (!chain?.length) return null;
|
||||
const args = chain.map(link => tokens(link.process.command).slice(1));
|
||||
const parents = args.map(argv => codexConversationToken(argv, "fork"));
|
||||
// A fork is positive history proof. Every chain link must have exact argv,
|
||||
// including the selected child and any launcher that could name a conflict.
|
||||
const args: string[][] = [];
|
||||
for (const link of chain) {
|
||||
const argv = codexProcessArgv(link.process);
|
||||
if (!argv) return null;
|
||||
args.push(argv.slice(1));
|
||||
}
|
||||
const parents = args.map(argv => codexConversationToken(argv, "fork", true));
|
||||
if (!parents.includes(input.parentToken)
|
||||
|| parents.some(parent => parent !== undefined && parent !== input.parentToken)
|
||||
|| args.some(argv => codexConversationToken(argv, "resume") !== undefined)) return null;
|
||||
|| args.some(argv => codexConversationToken(argv, "resume", true) !== undefined)) return null;
|
||||
return chain[0]!;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -315,6 +315,14 @@ function hasClaudeComposerPrompt(paneContent: string): boolean {
|
||||
return /(^|\n)\s*❯/.test(paneContent);
|
||||
}
|
||||
|
||||
/** A fresh launch needs the last prompt inside Claude's complete input frame. Earlier
|
||||
* headers or frames can belong to the predecessor above the shell's launch command. */
|
||||
export function hasClaudeDrawnInput(paneContent: string): boolean {
|
||||
const rows = paneContent.split(/\r?\n/);
|
||||
const prompt = rows.map(row => /^\s*❯/.test(row)).lastIndexOf(true);
|
||||
return prompt > 0 && hasClaudeInputFrame(rows.slice(prompt - 1));
|
||||
}
|
||||
|
||||
function looksLikeClaudeBypassConsentPrompt(paneContent: string): boolean {
|
||||
// Plain capture of Claude's current consent menu. Labels in prose or a menu
|
||||
// followed by a new composer are not evidence of an active dialog.
|
||||
|
||||
@@ -588,9 +588,9 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
|
||||
const identityVerdicts = new SeatIdentityStore(db).getForRig(rigId);
|
||||
// FS-1 W1.3 S1/S2 — the per-node restore-outcome + oriented reads built ONCE
|
||||
// for the rig (was a query per node inside the rows.map). rig-scoped restore
|
||||
// map = the prior WHERE rig_id=? filter; oriented map is node-scoped/global.
|
||||
// map = the prior WHERE rig_id=? filter; oriented history follows the rig's nodes.
|
||||
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
|
||||
const orienteds = buildOrientedMap(db);
|
||||
const orienteds = buildOrientedMap(db, rigId);
|
||||
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
|
||||
|
||||
const rows = queryInventoryRows(db, rigId);
|
||||
@@ -769,8 +769,9 @@ function buildInventoryEntry(
|
||||
...(communityHarnessOf(row.session_community_harness, row.runtime) ? { communityHarness: communityHarnessLabel(row.runtime!, undefined, { resumeType: row.resume_type, resumeToken: row.resume_token, resumeProvenance: row.resume_provenance ?? null }) } : {}),
|
||||
sessionStatus,
|
||||
storedSessionStatus: row.session_status,
|
||||
// An explicit down records a failed start's rolled-back session without changing its superseded status.
|
||||
intentionallyStopped: row.intentionally_stopped === 1
|
||||
&& row.session_status === "exited" && row.binding_id === null,
|
||||
&& (row.session_status === "exited" || row.session_status === "superseded") && row.binding_id === null,
|
||||
// Match the graph's current-status projection without rewriting the startup
|
||||
// result. Applicable mismatch (including ambiguity) or pane_missing overrides it.
|
||||
storedStartupStatus: row.startup_status as NodeInventoryEntry["startupStatus"],
|
||||
@@ -1283,8 +1284,8 @@ export function attachTerminalActivityAndWork(
|
||||
}
|
||||
const work = countAssignedWorkForEntry(entry, assignedByDest);
|
||||
const arbitratedNeedsInput = arbitratedNeedsInputSignal(arb);
|
||||
const hook = entry.canonicalSessionName
|
||||
? deps.agentActivity?.getLatestForNode({ sessionName: entry.canonicalSessionName, now,
|
||||
const hook = entry.agentActivity == null && entry.canonicalSessionName
|
||||
? deps.agentActivity?.getLatestForNode({ nodeId: entry.nodeId, sessionName: entry.canonicalSessionName, now,
|
||||
order: entry.runtime === "claude-code" ? "source" : "arrival" }) ?? null : null;
|
||||
return {
|
||||
...entry,
|
||||
@@ -1602,11 +1603,12 @@ export async function attachAgentActivity(
|
||||
};
|
||||
};
|
||||
|
||||
const hookActivity = deps.activityStore?.getLatestForNode({
|
||||
const hookActivity = entry.canonicalSessionName ? deps.activityStore?.getLatestForNode({
|
||||
nodeId: entry.nodeId,
|
||||
sessionName: entry.canonicalSessionName,
|
||||
now: sampledAt,
|
||||
order: entry.runtime === "claude-code" ? "source" : "arrival",
|
||||
});
|
||||
}) : null;
|
||||
// Reuse the existing cached capture. Only a complete supported idle frame, bound to this
|
||||
// target and started in a later second than the last write, distinguishes idle redraw from resumed work.
|
||||
// window_activity has second precision: same-second ordering is unknown. Poll/completion time is not write time.
|
||||
|
||||
@@ -218,7 +218,7 @@ export class NodeLauncher {
|
||||
return { ok: false, code: "selection_changed", message: "The selected node or history changed before launch." };
|
||||
}
|
||||
const tmuxResult = opts?.keepPane
|
||||
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv)
|
||||
? await this.takeOverPane(sessionName, opts.keepPane, sessionCwd, openRigEnv, node.id)
|
||||
: await this.tmuxAdapter.createSession(sessionName, sessionCwd, openRigEnv);
|
||||
if (!tmuxResult.ok) {
|
||||
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
|
||||
@@ -364,17 +364,19 @@ export class NodeLauncher {
|
||||
/** A kept pane's takeover, as a handover's cutover does it: its process ends in place (the pane survives it, with its
|
||||
* scrollback), then a shell starts in it with this launch's environment, for the runtime to launch into. Nothing is
|
||||
* touched when the pane isn't in the session. */
|
||||
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>):
|
||||
private async takeOverPane(sessionName: string, pane: string, cwd: string | undefined, env: Record<string, string>, nodeId: string):
|
||||
Promise<{ ok: true; warnings?: string[] } | { ok: false; code: string; message: string }> {
|
||||
let panes: Array<{ id: string }> = [];
|
||||
try { panes = await this.tmuxAdapter.listPanes(sessionName); } catch { /* an unreadable session holds no pane to keep */ }
|
||||
if (!panes.some((candidate) => candidate.id === pane)) {
|
||||
return { ok: false, code: "pane_not_found", message: `Pane ${pane} isn't in tmux session "${sessionName}", so nothing was stopped.` };
|
||||
}
|
||||
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process");
|
||||
// The guard resolves the selected seat before writing to its immutable pane id.
|
||||
const inputTarget = this.tmuxAdapter.deliveryGuard ? nodeId : pane;
|
||||
const ended = await terminatePaneInPlace(this.tmuxAdapter, pane, this.paneExit, "The seat's process", inputTarget);
|
||||
if (!ended.ok) return { ok: false, code: "pane_process_not_ended", message: ended.message };
|
||||
const shell = (await this.tmuxAdapter.getDefaultShell()) ?? "/bin/sh";
|
||||
const respawned = await this.tmuxAdapter.respawnPane(pane, shell, { ...(cwd ? { cwd } : {}), env });
|
||||
const respawned = await this.tmuxAdapter.respawnPane(inputTarget, shell, { ...(cwd ? { cwd } : {}), env });
|
||||
if (!respawned.ok) {
|
||||
return { ok: false, code: "pane_not_respawned", message: `The seat's process in pane ${pane} ended, but a shell couldn't start there: ${respawned.message}` };
|
||||
}
|
||||
|
||||
@@ -24,15 +24,16 @@ export async function terminatePaneInPlace(
|
||||
paneId: string,
|
||||
timing: PaneCutoverTiming,
|
||||
label = "Retiree",
|
||||
inputTarget = paneId,
|
||||
): Promise<PaneTerminationResult> {
|
||||
const failures: string[] = [];
|
||||
const remain = await tmux.setRemainOnExit(paneId, true);
|
||||
if (!remain.ok) failures.push(`remain-on-exit: ${remain.message}`);
|
||||
const term = await tmux.signalPaneProcess(paneId, "TERM");
|
||||
const term = await tmux.signalPaneProcess(inputTarget, "TERM");
|
||||
if (!term.ok) failures.push(`TERM: ${term.message}`);
|
||||
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "graceful" };
|
||||
// Graceful window elapsed — force-kill (the pinned degraded path).
|
||||
const kill = await tmux.signalPaneProcess(paneId, "KILL");
|
||||
const kill = await tmux.signalPaneProcess(inputTarget, "KILL");
|
||||
if (!kill.ok) failures.push(`KILL: ${kill.message}`);
|
||||
if (await waitPaneDead(tmux, paneId, timing)) return { ok: true, path: "forced" };
|
||||
const detail = failures.length > 0 ? ` Tmux failures: ${failures.join("; ")}.` : "";
|
||||
|
||||
@@ -133,18 +133,23 @@ export class PeriodicSnapshotScheduler {
|
||||
}
|
||||
|
||||
private getRunningNonArchivedRigs(rigId?: string): string[] {
|
||||
// Latest-session-per-node semantics: a node is running only when its
|
||||
// NEWEST session (by created_at DESC, id DESC) has status='running'.
|
||||
// Mirrors ps-projection.ts:116-118. An older running + newer exited
|
||||
// session means the node is NOT running.
|
||||
const rows = this.deps.db.prepare(
|
||||
`SELECT DISTINCT r.id FROM rigs r
|
||||
JOIN nodes n ON n.rig_id = r.id
|
||||
WHERE r.archived_at IS NULL
|
||||
${rigId ? "AND r.id = ?" : ""}
|
||||
AND (SELECT s.status FROM sessions s WHERE s.node_id = n.id
|
||||
ORDER BY s.created_at DESC, s.id DESC LIMIT 1) = 'running'`
|
||||
).all(...(rigId ? [rigId] : [])) as Array<{ id: string }>;
|
||||
return rows.map((r) => r.id);
|
||||
return runningNonArchivedRigIds(this.deps.db, rigId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rigs the scheduler snapshots: not archived, with at least one running node. `rig ps` reads the same set, so
|
||||
* its periodic snapshot flag is true only for a rig the scheduler would snapshot. */
|
||||
export function runningNonArchivedRigIds(db: Database.Database, rigId?: string): string[] {
|
||||
// Latest-session-per-node semantics: a node is running only when its
|
||||
// NEWEST session (by created_at DESC, id DESC) has status='running'.
|
||||
// An older running + newer exited session means the node is NOT running.
|
||||
const rows = db.prepare(
|
||||
`SELECT DISTINCT r.id FROM rigs r
|
||||
JOIN nodes n ON n.rig_id = r.id
|
||||
WHERE r.archived_at IS NULL
|
||||
${rigId ? "AND r.id = ?" : ""}
|
||||
AND (SELECT s.status FROM sessions s WHERE s.node_id = n.id
|
||||
ORDER BY s.created_at DESC, s.id DESC LIMIT 1) = 'running'`
|
||||
).all(...(rigId ? [rigId] : [])) as Array<{ id: string }>;
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
Loaded 100 of 266 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user