Compare commits

...
Author SHA1 Message Date
dev60-review 030cf97210 docs(bundle): the archive hash depends on file modes too
The determinism section said portable mode normalizes uid, gid and mode, and
that identical staged bytes give the same archive hash. Portable mode drops
owner metadata but only partly normalizes modes (node-tar sets owner read and
write and clears group and other write; execute and read bits stay), so two
staged trees with the same contents can hash differently when their files'
execute or read bits differ. Say so, and that the package digest is the
comparison that ignores modes.

Reported by dajiaohuang in #2523.
2026-11-05 05:08:38 +00:00
Mike Schwarz f692215383 fix(daemon): a quoted permission question above Claude's empty input box is not a dialogue (#2520)
A finished Claude reply that quotes a permission question (for example "Do you want to proceed?") above the empty input box no longer makes the seat read as needing input, so a scheduled wake is no longer refused until the quote scrolls away. Above Claude's complete, empty, framed input box with its mode bar, a permission question now counts only when a numbered choice follows it, which every native permission menu draws. Real permission dialogs, including one drawn above the input box, still stop a send. A quote followed by a numbered list still reads as a dialog, as before. Found in the review of #2513.
2026-11-04 20:55:31 -08:00
Mike Schwarz de3b0658a6 docs: show a client reading people and open updates (#2524)
docs/reference/daemon-http-clients.md now has a recipe for a remote client that needs people and their updates. It covers:
- the registered participants and the default person (`GET /api/gateway/participants`);
- a person's open informational updates (`GET /api/queue/open-human-updates`, with its 1-100 limit, person view and `truncated` flag);
- one rig's outbox (`outbox/list?rig=`).

It also states the limits: the default person isn't authentication, an update isn't a delivered receipt, and a person view's window can short a page. A new test drives the documented requests against the real routes.
2026-11-04 20:54:16 -08:00
Luis Araya 826bc1cc5c feat(stream): hand queue worker intake items on emit (#1856) (#2321)
`rig stream emit` now hands each observation to the kernel queue worker. The daemon creates an intake queue row tagged `stream-subscription` for `queue.worker`. While that row is unclaimed, later observations are appended to it as notes; once it's claimed, the next observation opens a new row. An item emitted as `critical` or with `interrupt` makes the row urgent.

The row's id derives from its first stream item, so a restart doesn't duplicate it. Items emitted while the daemon was down are caught up from a committed cursor (migration 149), retried on the next emit if a catch-up was incomplete.

**Defaults to know:**
- The new `stream.intake` setting is on by default, routed to `queue.worker`. Set it to `off` for today's behaviour, with no intake rows.
- On first start, earlier stream history isn't replayed.
- Intake rows wake their owner through the existing queue wake semantics; nothing new interrupts a seat.

The queue worker's role guidance and the `rig stream emit` help describe the routing. Refs #1856. Contributed by @Totopo27.
2026-11-04 20:05:19 -08:00
Wu Shuwen 3f0a5cc744 feat(queue): expose open human updates to clients (#2497)
A remote client can now read a person's open informational updates with `GET /api/queue/open-human-updates`, without depending on Slack delivery. It reuses the existing open-update query and person-view rules, returns open updates only (not asks or delivery receipts), and takes a `limit` from 1 to 100 (other values return 400). With a person view it reads the same 1,001-row window `/api/attention` uses, filters it to that person, and sets `truncated` when more rows exist or the window was full. Refs #2487. Contributed by @dajiaohuang.
2026-11-04 20:02:12 -08:00
Mike Schwarz 7d7634947d docs: 0.7.8 is published (#2519)
CHANGELOG.md's [0.7.8] section now says it was published on 2026-11-05 as @openrig/[email protected], built from the commit tagged v0.7.8 (12df562c), instead of "Not yet published". Its OpenShell wording now matches the published release body: the reference isn't usable in 0.7.8, and the repair ships in the next patch. The section is otherwise byte-equal to what shipped. docs/reference/whats-new.md is unchanged, because main's copy names main's package version.
2026-11-04 20:00:19 -08:00
Kai 7c5c514457 fix(daemon): recognize the current Claude Theme picker (#2514)
OpenRig now recognises Claude Code's Theme picker (the recorded 2.1.312 layout, including its tall preview) as a current selection dialog. Its options are unnumbered, so every existing recogniser missed it, and a wake's Enter could select a theme in a person's open picker. The matcher is pinned to that layout, like the MCP and resume recognisers. A later composer or draft, quoted or partial frames, changed wording and other runtimes don't match. It recognises the dialog only; nothing is answered or dismissed. Contributed by @KM199.
2026-11-04 19:30:39 -08:00
Mike Schwarz 78978dcb21 fix(daemon): accept a proven Claude behind a shell label when a classic resume settles (#2518)
A classic Claude resume whose launch subshell didn't exec Claude (because `claude` is an rc alias or function, or the launch environment correction fell back) keeps the shell as the pane label. On a slow resume, that seat could time out into attention while Claude was fine. Resume readiness now runs the existing process proof in that case without waiting for Claude's prompt: one foreground Claude naming this resume token, stable over two samples, is accepted, as a `claude` label already is, and its launched process is recorded. An idle shell, another token or an unstable process reads as before. Reported in #2508; refs #2508.
2026-11-04 19:27:16 -08:00
Wu Shuwen 2eddb4a2d7 fix: report unconfirmed guarded submissions (#2509)
`rig send --verify` no longer reports `delivered` or `verified: true` when it can't see what the one guarded Enter did. When that Enter's recheck can't capture the pane or finds no input prompt, or the Enter's response is lost, errors or is refused, the result is unconfirmed and the command exits 1. Single and fan-out results drop the delivered claim only for the affected recipient, in human and JSON output, and `effectCheck.remedy` still tells a refusal from an unknown result. The daemon now labels a guarded Enter that failed before the key as `not_reached` with `sent: false`, and one that failed after the key as `attempted`. The one-Enter limit is unchanged, and nothing is resent. Contributed by @dajiaohuang.
2026-11-04 19:01:42 -08:00
Wu Shuwen 91d21bc962 fix(activity): recognize live Codex working frames (#2500)
A Codex seat showing a live working turn now stays `working` in the queue activity oracle when its tmux window timestamp is quiet, so a busy Codex owner isn't diagnosed as parked just because the timestamp is old. The evidence comes from the existing structural pane capture and is positive-only: a working status directly above the empty composer counts for 5 s from the first time that counter value is seen, and otherwise the elapsed counter must advance between two recent captures. A frozen counter, a stale capture or one taken before an occupant change falls through to the existing evidence. Visible needs-input stays independent, and transport's send and idle decisions are unchanged. Refs #331. Contributed by @dajiaohuang.
2026-11-04 19:00:32 -08:00
Mike Schwarz 3448150528 test(daemon): pin a fresh Claude's login refusal through to the node view (#6) (#2405)
Regression coverage for #6: a fresh Claude session that refuses its first request because login is required reports attention_required with an explicit login reason through startup persistence, the node inventory, the nodes route and the ps projection, with no generic readiness timeout. The cases compose the real Claude adapter's delivery and readiness, startup orchestration, SQLite persistence and the nodes HTTP handler (pane text and harness launch mocked), and cover the native Claude 2.1.312 result-row form and the reply form of the refusal. Footer-only use (a gateway may authenticate) and quoted refusal text stay ready, and a Claude that never draws its TUI still ends in the generic readiness timeout. Current production code already behaves this way; nothing in the product changes.

Built and reviewed by the OpenRig Workshop team. Tests +69, no source.
2026-11-04 18:30:52 -08:00
Wu Shuwen 889b804ada fix(daemon): recheck pane after native identity proof (#2512)
When a pane is replaced while the seat attention reconciler is proving its identity, the replacement could be marked verified using the old pane's process. Before accepting Codex identity or a strict Claude resume identity, the reconciler now rechecks the pane PID and that the pane is still the session's only pane, as its Claude proof branch already does; a changed or unavailable pane leaves identity unverified. An unchanged pane verifies as before, and no new capture, loop, restart or refusal is added. Contributed by @dajiaohuang.
2026-11-04 18:19:05 -08:00
Aummadour 863c0f58f2 fix(package): preserve daemon asset modes and relative links (#2511)
The CLI package build now stages `packages/daemon/assets` with Node's `fs.cp` (recursive, `verbatimSymlinks`) instead of `cp -r`. Staged assets keep their source modes, so a build run under a narrower umask than the checkout no longer packs them as 0600 or 0700 (npm keeps owner-only bits when packing). A relative link stays a link in staging and is omitted by npm, rather than being followed and copied in on macOS. Copied bytes and sources are unchanged, and a failed copy still stops the build before the staged root is recorded. Only the assets step changes; the other copy steps still use `cp -r`. Contributed by @Aummadour.
2026-11-04 17:47:27 -08:00
Wu Shuwen 87297f0f8e fix(walk): report unconfirmed delivery and submit outcomes (#2510)
`rig walk` no longer reports a piece as lost, or as typed but not consumed, when it can't see what happened. When the sent piece doesn't appear in the record or the pane within the verification window, or the one guarded Enter's result is lost, errors or is refused, walk now says delivery is unconfirmed and to inspect the recipient before any resend. A generation change after the guarded Enter is reported separately. The walk still stops at that piece with exit 1, after one text send and at most one guarded Enter. Contributed by @dajiaohuang.
2026-11-04 17:33:04 -08:00
Wu Shuwen de47425168 fix: preserve uncertain conditional send receipts (#2505)
An expected-identity send's outbox receipt now reads `indeterminate` when the outcome is uncertain, instead of `delivered` or a receipt left `pending`. That covers a send whose verification came back `rendered-unconfirmed`, and one that failed or threw after input was attempted or written; an attempted or written input phase takes precedence over a `sent: false` flag. A caller deciding whether to retry is no longer misled. Failures before any input stay `failed`, and a failure while finalizing the receipt is reported as a warning. Conditional identity checks, the retained-input path and sending itself are unchanged. Limit: the plain-text CLI failure output doesn't print that warning; API and JSON output carry it. Contributed by @dajiaohuang.
2026-11-04 16:05:51 -08:00
Wu Shuwen 101e5e3ff6 fix: preserve uncertain broadcast delivery receipts (#2507)
An attributed broadcast's outbox receipt for a recipient now reads `indeterminate` when that recipient's text write was attempted or completed but the transport reported an error, instead of `failed`. The text or Enter may already have reached that recipient, so a failed receipt could mislead a retry decision. Delivered receipts and failures before any input are unchanged, as are the route response and the other recipients' receipts. Limit: a successful broadcast whose verification came back unconfirmed still records delivered. Contributed by @dajiaohuang.
2026-11-04 16:04:24 -08:00
Mike Schwarz 4ebb06059a fix(tui): the selected-row highlight shows in 16-colour mode (#2506)
In 16-colour mode, `rig tui`'s selected row is highlighted again. The `selection` token's 16-colour value was already a background code (`40`), and the background path adds 10, so it painted SGR `50`, which isn't a colour and terminals ignore. It's now `30`, so the highlight paints black (`40`), which keeps the row's bright-blue accent text readable and is the colour nearest the truecolor and 256-colour selection. A new cell checks that every token used as a background paints a real background code. Limit: on a dark terminal whose own background is that black, the band blends in, and the selected row is still marked by its arrow, accent colour and bold. Found in the review of #2499.
2026-11-04 16:01:44 -08:00
Mike Schwarz a7bd8fe50f fix: a Slack transport error never carries the bot token (#2503)
A Slack transport error no longer carries the bot token. Node's fetch rejects a token copied with a newline before connecting, and its error message quotes the whole Authorization value, which the readiness route, `rig slack verify`, the inbound and outbound paths and the inbound file download then passed on, the file download into the inbound queue item and the daemon log. `callWebApi` and `downloadPrivateFile` now remove the token and each of its lines, as sent and as Node trims them, from that message in one pass, and keep the rest, so the error still says what went wrong. Limit: a token line shorter than 6 characters, as sent or trimmed, can still appear. Found in the review of #2501, whose new directory routes already return no transport detail.
2026-11-04 15:28:30 -08:00
Mike Schwarz d9b09ec3f2 fix(delivery): route replies to forwarded people through the queue (#2504)
A queue item a person sent from one machine and forwarded to a seat on another now tells that seat how to answer them. The source arrives as `human@kernel@<origin-host>`, which neither message renderer recognized as a person, so the seat was told to `rig send` it, which can't reach a person. Both mirrored renderers now take the two-part person address from a host-qualified source only to recognize it, and offer the working `rig queue create` hint to the full host-qualified destination, which the queue command already splits. The sender, body, destination and origin routing are unchanged, and look-alike seats and malformed addresses behave as before. Limit: the reply still needs a route back to the origin host. Found by @dajiaohuang on #2493.
2026-11-04 15:16:13 -08:00
Mike Schwarz a3e5e92112 feat: list Slack channels and people through the daemon (#2501)
A daemon client can now offer Slack channel and person choices, to fill in a channel map or register a person, without holding the Slack bot token. `GET /api/gateway/slack/channels` returns one page of the workspace's public, unarchived channels as `{ id, name, isMember }`, and `GET /api/gateway/slack/users` one page of people as `{ id, name, realName, displayName, isBot, deleted }`, without email. Both page with Slack's cursor (`nextCursor`, null on the last page) and `limit` (default 100, at most 200), and each request is one live Slack call with no cache or stored copy. Listing needs only a resolvable bot token and config, not inbound delivery. A missing token answers 503 `slack_not_configured`, a missing Slack scope 403 `slack_missing_scope` with the scope named, a rate limit 429 `slack_rate_limited` with Slack's `Retry-After`, a transport failure 502 `slack_unreachable` with no transport detail, and any other Slack error 502 `slack_error`. Access is the same as today's gateway reads. No new scope, private channels, direct messages or migration. Limits: proven against a fake Slack, not a live workspace; an app installed from an older manifest may lack `users:read`. Reported in #2487 (item 4).
2026-11-04 14:52:30 -08:00
Mike Schwarz 7b02076427 fix(tui): body text and the codex mark read on a light terminal (#2499)
`rig tui` body text now reads on a light terminal as well as a dark one, with nothing to configure. The `bright` token, used for a slice's intent, requirements and proof text and for most plain text, and `markInk`, the codex mark's `_`, now use the terminal's own default foreground (SGR 39) in every colour mode wherever the TUI doesn't paint its own background. On a background the TUI paints itself (selected rows, the terminal mark, accent bars) they keep their fixed light ink, so those stay legible, and inverse flash rows now swap the terminal's own colours. Dark terminals look the same to the eye. Limit: in 16-colour mode the selected-row wash isn't painted, an existing issue handled separately. Reported, with the fix, by @adam-nectar.
2026-11-04 14:47:48 -08:00
Mike Schwarz e10b8014f6 fix(daemon): re-apply activity hooks on a contained resume for local: agents too (#2479)
A `local:` Claude seat now gets its current activity hooks back on a contained resume, as a `path:` seat already did, when its saved launch shows where its agent was read. The reader resolves the seat's own `local:` ref against each recorded startup-file owner root, re-anchored for shipped specs as the runtime switch reads them, counts a folder only when the ref lands on a recorded root, and accepts it only when exactly one such folder holds an `agent.yaml` with the seat's resolved spec name. Anything unproven still reads unknown: restore warns, projects nothing and never rewrites the saved context, and `rig restore-check` reads the same way. Resource origins aren't used, so a same-name import can't stand in for the seat's spec, and `builtin:` refs are unchanged. Limits: a seat whose ref can't be tied to a recorded folder still only warns, and one wrong read remains possible when a recorded root that isn't the agent's folder holds a same-name spec and the ref lands on it, for example a seat added later from another rig root with `local:.` and no startup files of its own. Builds on #2338 by @AdityaxDeore. Reported in #1530.
2026-11-04 14:37:46 -08:00
Mike Schwarz 23daf8513a feat: read a rig's outbox in one request (#2496)
A daemon client can now read a rig's messages in one request: `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to that rig's seats, newest first, with the same `limit`. `?senderSession=` still returns one sender's messages, unchanged, and when both are given `senderSession` is used. Migration 148 adds an index on the outbox's dispatch time with both session columns, so the rig read walks newest first, tests the match inside the index and stops at its limit instead of scanning and sorting the whole table. On a copy of a 109,622-row outbox, a busy rig's read went from 378 ms to 0.3 ms and an empty rig's from 30 ms to 19 ms; building the index took 0.37 s. The client reference now says queue SSE events are hints to read `queue/list?rig=` again, since some events name only the destination, and that the rig queue read scans its table, so read it occasionally. Limits: a rig matches by its `@<rig>` suffix with SQLite's ASCII case folding, as `queue/list?rig=` does, and a quiet rig's read still walks the index. Reported by @ziadeh in #2487 (item 1).
2026-11-04 13:28:37 -08:00
Wu Shuwen f7206db6de feat(gateway): expose participant registry to clients (#2495)
A daemon client can now read the registered participants and the effective default person through `GET /api/gateway/participants`, without reading registry files on the host. It returns the same JSON as `rig gateway participants --json`, from one shared projection, so the default, policy and partial-read behaviour match. An optional `organization` query filters the participant list, while the default, organizations and groups still describe the whole registry. An unreadable participant side file still lists people from their fragments and names the error, with organizations and groups null rather than empty; an unreadable person registry returns 503 `human_registry_unavailable`. The response carries no connector bindings or secret pointers, the route uses the same access as the other gateway reads, and the default person isn't authentication. Limits: Slack channel and user reads aren't part of this; with an unreadable side file, an organization filter returns no participants, as the CLI does today. Reported in #2487 (item 4). Contributed by @dajiaohuang.
2026-11-04 12:50:29 -08:00
Mike Schwarz 74501bdf63 docs(slack): name the private-channel route the connector already accepts (#2494)
The Slack setup guide now says how to use a private channel. The generated manifest requests public-channel access only, and a new section gives the route: add groups:history and groups:read, subscribe to message.groups, reinstall the app and invite it to the channel, and optionally run rig slack setup --required-scopes so verify checks the added scopes. The channel-map paragraph says a private channel can be a target once the app has them. Direct messages stay unsupported, and the guide says OpenRig doesn't test private channels. Docs only; no behaviour change. Limits: the --required-scopes example repeats today's baseline scopes, so if the baseline gains a scope that command would need it added; a manifest option and verify mode for a private channel or direct message are not part of this change. Asked about in #313 by @ziadeh.
2026-11-04 12:03:15 -08:00
Mike Schwarz 432a6bcc41 docs: what a daemon client needs that only the source said (#2490)
Someone writing a client for the daemon's HTTP API can find what they need in the reference docs instead of the source. A new page, docs/reference/daemon-http-clients.md, covers finding the daemon: rig status --json is the discovery surface, the status commands check OPENRIG_URL before any state file, start and stop read the state file first, and pid and port appear only when known. It also covers the /healthz fields and when each is present, the two bearer tokens with the routes each checks and their 401 bodies, the three event streams and their replay rules, and route details: the recent-transitions limit rule, urgent on POST /api/transport/send, the scope approve, proof judge and workflow instantiate bodies and refusals, the per-seat Claude provider usage readings (not proof that two seats share an account), and how the slice routes resolve a name two missions share. Existing pages now list every reason a send is retained, say that node inventory reads exited for a verified exited pane, say how to type into a seat without resizing it, and list the files OpenRig writes into a project, with what rig down does to each (protection is per shared file, and legacy block-only files can be removed). Docs only; no behaviour change. Limits: the API changes these reports also ask for are not part of this change; the status lookup summary doesn't name its last fallback, the configured endpoint, which rig status --json already uses. Reported in #2486 and #2487 by @ziadeh.
2026-11-04 12:00:51 -08:00
Mike Schwarz c4b1181dc4 fix(delivery): give human aliases a usable queue reply hint (#2493)
A queue wake from a person alias such as human@kernel or human@host now tells the agent how to reply in a way that works. Only external people got queue guidance before; an alias got a rig send hint, which fails because a person isn't a seat. The CLI and daemon envelopes now give these aliases a rig queue create update hint, with a summary and evidence reference, after the unchanged external and thread branches, so ordinary seats with similar names keep their hints and the acting identity is still derived from the route. gateway-participants.md gains a short paragraph on how people are addressed without Slack and how a client acts for them, removes two stale claims (that another registered person can't answer an ask, and that the terminal impersonates the default person), and its registration example now includes the required --delivery-class. No identity gate is added, and Slack stays optional. Reported in #2480 by @ziadeh.
2026-11-04 11:49:43 -08:00
Mike Schwarz 72a124de82 docs: rig host pair needs a bearer token on the target daemon (#2491)
Pairing two machines now tells you up front what the target needs. A daemon bound to a tailnet address starts without OPENRIG_AUTH_BEARER_TOKEN, but rig host pair hands that token to the other machine, so POST /api/hosts/pair-request answers 409 pair_target_no_bearer when it isn't set, and pair_human_required when a person must approve. The command reference and the rig host pair --help url line now say the target must run its daemon with OPENRIG_AUTH_BEARER_TOKEN set. The bearer 401 and the daemon's startup refusal named an auth.bearerToken config field that doesn't exist; the daemon reads the token only from its environment, and both messages now name OPENRIG_AUTH_BEARER_TOKEN. Text only: no change to which requests are refused. Limits: the website's multiple-machines guide is maintained separately and gets the same correction there; the shared 401 wording is still Mission Control and auth specific, so a 401 from a separately configured terminal bearer token isn't fully explained by it. Reported in #2484 by @ziadeh.
2026-11-04 11:41:38 -08:00
Mike Schwarz 499dfe3ece docs: what a checked stop sent, and what the status words count as (#2489)
Two reference pages now match the code. checked-seat-stop.md said partialEffect true means no kill was sent; the daemon also sets it after the kill, when a selection change is seen while the stop state is saved or the save fails. The page now lists the four stopPhase values and what each says was sent, notes that selection_changed with kill_attempted means the session was killed, and says that a refusal before the stop starts carries neither field and sent nothing. sdlc-conventions.md now says that metadata.status complete is written only by POST /api/missions/:missionId/complete and is outside the readiness rollup, and lists the slice status words, the bucket each counts as in GET /api/slices, and the fallback: an unknown word counts as active, so a slice marked complete counts as active. Docs only; no behaviour change. Reported in #2483 and #2482 by @ziadeh.
2026-11-04 11:22:56 -08:00
Mike Schwarz 9b3f91ecae fix: scope audit names a slice another mission also uses (#2492)
When two missions each have a slice folder of the same name, the slice list (GET /api/slices) keeps only one of them, and the routes that find a slice by name serve that one, so nothing told a person the other slice existed. rig scope audit, and the daemon's audit route behind the dashboard, now add a low-severity slice_name_shared finding to each such slice, naming the other missions and suggesting a rename. It is advice: it doesn't change the audit's result or exit code, which slice the list keeps, or any route. Limits: the check looks for a same-named folder under each other mission's slices directory, which is narrower than every case the indexer can drop; renaming a slice folder unbinds rows that name the old slice; addressing a slice by mission is a separate, larger change. Reported in #2487 by @ziadeh.
2026-11-04 11:01:45 -08:00
Mike Schwarz 8baec13548 fix: host status for an ssh host dials where ssh connects (#2488)
rig host ls and the dashboard's host list (GET /api/hosts) now show an ssh host as reachable when ssh would reach it. OpenRig runs ssh against the host's target and leaves the rest to the person's ssh config, but the status probe dialled the target at port 22, so an ssh-config alias, or a host whose sshd is on another port, read unreachable while ssh reached it. Both probes, in the CLI and the daemon, now ask ssh -G, with the same user and target the transport uses, for the host and port ssh would connect to, and dial those. A host behind a ProxyJump or ProxyCommand reads unknown, since a direct dial can't stand for that route, as do a missing ssh binary, a timeout and a config ssh can't resolve; unreachable now means only a failed dial to the address ssh resolves. http hosts are unchanged. Limits: ssh -G makes no connection, but OpenSSH runs any Match exec command in the person's ssh config while it reads it, so with such a config each list runs that command too; ssh -G and the dial each have their own 1.5 s limit, so one host's status can take up to about 3 s, with hosts still probed in parallel. Reported in #2481 by @ziadeh.
2026-11-04 10:51:49 -08:00
Aummadour f8ba5c4bac fix(daemon): preserve verified bundle file modes under a restrictive umask (#2475)
A bundle file whose owner bits were stripped by the extraction umask, for example a script under a umask that removes the owner's own execute bit, gets its archived owner bits back after content verification. Group and other access stay as the person's umask left them, so nothing is widened. Recognized login files are capped at their archived owner read and write bits, at most 0600. Files are grouped by device and inode after extraction, the last archive header decides each one, and the login cap holds across case aliases, so on a case-insensitive volume a second name can't widen a capped login file. If a chmod fails, extraction continues and returns a named warning with the file, the failed operation and the mode actually observed, never the contents; for a login file it says the owner-only cap couldn't be applied. Those warnings now reach both bundle source resolvers, prelaunch routing, bootstrap and the install result's warning list. Content-integrity, path and link refusals are unchanged. Limits: case-insensitive aliasing is covered by simulated identity, not a native macOS run; when a login file's cap fails, the file can stay wider than 0600, with the warning saying so; Windows is skipped. Contributed by @Aummadour.
2026-11-04 10:04:51 -08:00
Mike Schwarz 3917d119de fix(watchdog): retain undelivered periodic occurrences through bounded recovery (#2478)
A scheduled reminder that couldn't reach its seat, for example after a reboot where the daemon starts before tmux, now stays owed and recovers instead of silently moving on to the next cycle. Each periodic occurrence, including each parked-row digest member, gets its own receipt in the existing outbox and retries through the existing scheduler and wake retry settings (default three retries, five minutes apart). A confirmed delivery is never sent again after interrupted accounting or a restart, and an uncertain one is never replayed. Exhaustion or an unconfirmed result ends only that occurrence, with its cause and next action recorded, and a recurring reminder keeps its normal cadence under a new occurrence; only a permanent transport failure ends the job. A one-shot park timer records its failed wake on its row before it ends, and a pending retry leaves its binding armed with a retry note. The fire stamp and the queue wake receipt and one-shot retirement commit in one transaction, with input outside it. Refusals are read with the transport's own rule (sent false or input not reached), and the external-CLI and interactive-prompt refusals are labelled, which the queue wake classifier, draft-aware delivery, compaction preparation, claim hints and the transport receipt also read. Abandoned occurrence claims are marked indeterminate at startup, before anything sends. Limits: scope is periodic reminders and parked-row digests, while condition watches, the parked-owner policy and explicit sends are unchanged; no schema, API, scheduler or replay of earlier failures is added, and previously recorded failures can't be reconstructed; while a retry waits, the existing one-second loop still evaluates it; a later recurring cycle is a new occurrence, not confirmation of an uncertain one.
2026-11-04 09:49:41 -08:00
Aummadour 7ae36414ed test(daemon): make the recorder timeout fixture deterministic (#2476)
The recorder degradation test in slow-op-recorder.test.ts is deterministic again. A zero timeout could still see a completed acknowledgement from the worker, so the test sometimes rejected a valid healthy result. The fixture now withholds the acknowledgement signal for exactly one named begin record while the real worker still writes that record, and restores normal messaging before the health checks and cleanup. Every earlier assertion is kept: the wrapped value, the begin_barrier_timeout reason, the health response and the single observation. The cell also checks the site and that the begin record reaches disk after a flush. Test only; production code and timeouts are unchanged. Contributed by @Aummadour.
2026-11-04 06:31:55 -08:00
Wu Shuwen a0242b206f docs(reference): correct Pi selector example path (#2477)
The Pi tool-selection recovery guide now gives the selector example's path from the root of the Pi repository: packages/coding-agent/examples/extensions/tools.ts. The old path left out packages/coding-agent/, so a reader starting at the root couldn't find the file. Docs only. A follow-up to the review of #2186. Contributed by @dajiaohuang.
2026-11-04 05:36:12 -08:00
Nash Aschenbath d7e2b5ca63 fix(daemon): retain the kernel variant from bootstrap history (#2471)
The kernel's original template now survives a daemon restart. /api/kernel/status keeps the built-in template the kernel was booted from, so rig daemon start --wait-for-kernel no longer prints variant=(none) on a lost-kernel restore, a deliberate stop, a stale kernel or a --no-kernel start. The selection is read from the rig's latest bootstrap_runs entry with source_kind rig_spec, including a partial launch, so kernels booted before this change are covered when their recorded source still matches the installation. Only the three kernel templates under the current installation's built-in specs directory are recognized. A custom project path with the same suffix, missing bootstrap history, or an old source path that can't be verified, for example after a Node version change moves the global install, reads an unknown variant, never a default. Nothing new is written: no migration, column or rig API field. The live rig spec still describes the current configuration; this value describes its origin. Reported in #21 by @tgrundtvig. Contributed by @Aschenbath.
2026-11-04 05:19:10 -08:00
Mike Schwarz bb007efabc fix(slack): mark original asks handled when their rows settle (#2472)
A Slack ask now shows that it was handled when its row settles anywhere, so a channel no longer fills with asks that look open but aren't. When a posted ask is answered in a pane, closed from the CLI, superseded, canceled, resolved in Mission Control, or a person's park moves to a queue blocker, the connector edits that ask's original primary post: a status line with the outcome, actor, time and closure note, the answer hints removed, and the buttons turned into historical option text. The rest of the post and its reconcile marker are kept, and supplemental posts and separate resolution notices are unchanged. Committed queue events drive the edits, with a catch-up at start from the retained notification receipts and channel stamps. A reopened direct ask reads "Open again"; it offers a thread answer only when it has no committed answer, and otherwise says the earlier answer is retained, since reopening doesn't accept a second answer. "Asked again" doesn't claim the later ask was already posted. One serialized worker uses the recovery budget (four calls per 15 seconds): each primary normally costs one GET read of the original and one JSON chat.update, a success is recorded only when Slack acknowledges the requested message at its current revision, Retry-After holds survive a restart, transient failures back off behind other work, and permanent Slack failures are reported as not done and excluded until the next connector start. Pending edits and the last failure show in rig slack status. Limits: private-channel reads may need groups:history; refused reads and unavailable originals never count as edited; catch-up needs the original receipt and stamp and a retained queue row; the first start after upgrading edits every retained historical ended ask at the edit rate, about an hour for 450 asks; reactions are not part of this change. Reported in #2468 by @ch-davidriott.
2026-11-04 00:20:24 -08:00
Mike Schwarz 8e85902060 fix(daemon): a handover's successor is pointed to its startup files, role first (#2474)
A seat handed over on a fresh install now starts with its role, as a normal launch does. A start types each send_text startup file into the new conversation, the role first; a handover ran no startup delivery, so a fresh or rebuild successor of the built-in development, design and conveyor agents and vault-specialist started without its role file, and nothing said so. The first section of the turn each fresh or rebuild successor already gets, the restore packet or the priming packet, now lists the seat's send_text startup files as absolute paths, role first, before the predecessor's context: the files a fresh start delivers, required and optional. A file whose source is missing is listed as MISSING, and a missing required one joins startedWithoutRequired, so the seat reads as needing attention in rig ps with the file named, and the handover still completes. Fork successors, which carry their parent's conversation, and discovered successors get no list. The runtime switch is unchanged, since its successor's first turn already carries these files' text. Limits: a pointer asks the successor to read the file, rather than pasting its text as a start does; send_text startup actions, such as an after_ready preload, aren't run for a successor. Reported in #2456 by @eisenbergjerry.
2026-11-03 23:30:48 -08:00
NIKHILA c7f5fc9674 fix(daemon): recognize Claude idle footer rows (#2470)
An idle Claude seat now reads idle when Claude Code draws newer rows below its mode footer: a token count, the versioned "current: … · latest: … ✔ Update installed · Restart to update" banner, and /rc. The footer search already skipped known status rows such as the bare "Update installed · Restart to apply" banner and the /clear hint; it now skips these three exact shapes too, so an empty prompt above them is found. So rig compact goes through, managed compaction sends its /compact, and a send that waits for idle sends. An unrecognized trailing row still reads unknown, live work stays active, and a typed draft is never idle. This covers the layout reported on November 3; #1641 stays open for the parts it doesn't cover, such as the /clear hint wrapping onto a second line. Reported by @aanestesia in #1641. Contributed by @Nikhila-Dhevi-R.
2026-11-03 23:26:51 -08:00
Mike Schwarz 5fb816f2b9 fix(daemon): mark a handover's successor that started without a required startup file (#2466)
A coordinator now sees in the seat list when a handover's successor started without a file it requires, and the person who ran the handover sees it in the same turn. An ordinary start fails when a required startup file can't be delivered; a handover's successor started anyway, and the missing file was only a note. The handover now names each required file the guidance file should hold that its successor started without: a projected guidance file, or a guidance startup file marked required, including when an optional startup file shares its block id. The list is on the result as startedWithoutRequired and on the seat.handover_completed event with the successor's session id. needsAttention reads that event while the successor's session is the seat's current one, serving the full paths as evidence; the reason column reads "missing required <file name>". The handover prints "Seat handover complete, degraded: <seat>" and names each file. The mark clears when the seat starts a new session, never by time. The handover still completes, with no new refusal, table or state. Limits: an exact resume clears the mark even if it couldn't put the file back, and its own result names that gap; a discovered successor isn't checked; a missing launch addition doesn't mark the seat; a required send_text startup file, such as an agent's role, isn't delivered to a handover's successor and isn't checked; repairing the file in place doesn't clear the mark before the seat's next session. Reported in #2456 by @eisenbergjerry; #2456 stays open for delivering those startup files.
2026-11-03 22:17:38 -08:00
Mike Schwarz 5f31cb5760 fix(daemon): refresh stale managed guidance blocks, never overwrite a person's edit (#2461)
An edit to a culture or startup file now reaches the next process OpenRig starts for a seat: a launch, an exact resume, a handover or a runtime switch. A managed block a person edited by hand is left in place and named, instead of being silently replaced or left stale. OpenRig records a hash of what it last wrote in each managed block, keyed by the guidance file's real path (so every link to one file shares it) and the block id, in a new managed_block_writes table. Every adapter's guidance merge and the resume and handover put-back decide through the same write. A block OpenRig wrote is refreshed from its changed source. An edited block is kept, with a guidance_block_edited warning. A block written before this change has no record: a start or a runtime switch replaces it if it differs from its source, as before, and names it; an exact resume or a handover keeps it and names it as possibly stale. A record that can't be read is treated as unknown and the differing block is kept; a record that can't be saved is named, and the write still counts. A Codex start on launch developer instructions refreshes the blocks already in AGENTS.md and adds none. When a block is left as it is, the seat is told in one line after a start, in a runtime switch's successor first turn, and in a fresh or rebuild handover's packet; an exact resume types nothing and names the block in its result. Limits: the record is per file and block id, so two rigs whose blocks share an id in one file share it; a writer outside the adapters' guidance merge and the put-back isn't covered, and none writes managed guidance blocks today. Reported in #2456 by @eisenbergjerry; #2456 stays open for its remaining handover work.
2026-11-03 21:33:34 -08:00
Mike Schwarz 886b9d0586 test(daemon): guard health and queue reads during nodes bursts (#2469)
The daemon's burst test now also covers a burst of seat inventory reads. The existing N=24 real-HTTP cell in ps-summary-stall-red.test.ts runs a second case. A child daemon on the host-shaped fixture with today's migrations serves 24 /api/rigs/:rigId/nodes requests across its active rigs while health and an exact queue read are sampled. Every inventory reply must be non-empty and for its own rig, and the worst health and queue reads must stay under the shipped 250 ms responsiveness budget. In the run that added it, the worst reads were about 117 ms and 124 ms, against 6 ms and 9 ms without the burst. Only one paired probe completed during the burst, so this guards against a regression rather than measuring a distribution. The health route is the fixture's event-loop stub, not daemon health. The ps+summary case is unchanged. Test only; production is unchanged.
2026-11-03 20:52:01 -08:00
Nash Aschenbath 1fa46a6118 feat: a Claude member's own plugin channels (--channels) (#2439)
A Claude member in a rig spec can now register just that agent for plugin channel notifications with plugin_channels, a list of plugin:<name>@<marketplace> entries. Every launch of that agent (fresh, resume, fork, restore and handover, in both the pane-shell and managed forms) passes one --channels followed by the entries, after its other options; an agent without the field gets none, and nothing is added for other agents or runtimes. The spec refuses a server: entry, a malformed or repeated entry, a non-list, and the field on a non-Claude member. A non-empty list beside claude_mcp: none produces a schema advisory, since the empty strict MCP configuration excludes plugin servers: it names both remedies, to remove claude_mcp: none to use the channels or remove plugin_channels to keep the MCP opt-out, and both settings remain and the member launches normally. The pane-shell form now writes each launch flag followed by its quoted values, so a multi-value option keeps its values. The entry grammar comes from Claude Code 2.1.316's release binary; no real channel plugin was run. Requested in #2044. Contributed by @Aschenbath, carrying on @todie's fork work.
2026-11-03 17:42:51 -08:00
Nate Faerber be85d82546 fix(daemon): open grid filler panes in the account's login shell (#2465)
Blank filler panes in tiled grid layouts (Herdr and Rex) now open the account's own shell as a login shell, <shell> -l, instead of a hard-coded sh. The shell comes from the account's passwd entry, then $SHELL, then /bin/sh, and only an absolute, executable path is used, so a failed or stale lookup never stops a layout; the passwd entry comes first because a long-running daemon can inherit a $SHELL that's gone stale. A filler now behaves like a new terminal of yours, startup files included, so an rc line that starts or attaches tmux runs in each filler too. An account whose shell refuses a session, such as nologin, closes its filler panes rather than falling back. Remote tiles are unchanged. Contributed by @natefaerber.
2026-11-03 17:13:58 -08:00
Nate Faerber 7cabac4432 fix(daemon): size a fresh seat to its first writable tile (#2464)
A freshly launched seat opened only through writable tiles is now sized to the first tile instead of staying at its 80x24 creation size. Every writable tile attached with tmux's -f ignore-size, and tmux ignores an ignore-size client whenever any client of any session sizes its window, so on a server with another sized session no tile sized the new seat. A writable tile now checks its session's clients just before each attach and keeps -f ignore-size only when one of them already sizes the session; otherwise it attaches without the flag and sizes the window. Read-only tiles keep -r unchanged, and the viewer accepts either writable spelling. Two tiles attaching at once both size, under the session's window-size option, like any two plain clients, and a tile that attached with the flag keeps it until its next attach if the sizing client later leaves. Contributed by @natefaerber.
2026-11-03 16:51:12 -08:00
Mike Schwarz e935df86f1 fix: honor accepted queue closure re-estimates without reclaiming work (#2460)
An accepted re-estimate can now move a claimed row's deadline without unclaiming the work: rig queue update <id> --closure-required-at <ISO>. The existing update transaction records the old deadline, the new one and the accepting actor alongside the note; the header-derived actor wins when present, and without a header the existing body-only attribution applies unchanged. The overdue query reads the revised value at once, and the next stuck sweep closes an already-open overdue finding while the work stays claimed, unless an independent pickup or stall recovery still shares custody. A deadline that passes after an earlier recovery for the row was closed is reported again, whether or not the owner is reporting activity: the renewal follows the row's own expired deadline rather than which finding kind wins, and the kind stays as today, so a row that is both stalled and overdue can report as stalled-after-claim. Open recoveries keep custody, and only a renewed expiry gets the deadline in its recovery ID. Limits: the field accepts a deadline on an in-progress row and doesn't read or backfill ETA prose; other pickup and stall findings stay independent; on 0.7.6 and 0.7.7, unclaim followed by claim remains the workaround and resets the claim. Reported in #2453 by @eisenbergjerry.
2026-11-03 16:45:15 -08:00
Mike Schwarz 6fb1e2c1c5 test(daemon): concurrent same-name import cells wait for the hold, not a 1 s poll (#2463)
The concurrent same-name import cells in yaml-import-replaces-stopped-generation.test.ts are deterministic again. Each cell starts a first import and waited, with vi.waitFor's default one-second poll, for it to reach a held probe or prelaunch hook. The import reaches that hold only after preflight's profile-resolution worker answers, which under vitest loads tsx first, so on a loaded runner the poll could give up with the import still on its way. Each held stub now releases an entered gate, and the cell awaits it raced against the first import settling, so it waits as long as the import takes, within the package's test timeout, and an import that settles before its hold fails at once. Every assertion about the refusal, the single replacement and the rollback is kept. Test only; production is unchanged.
2026-11-03 15:59:29 -08:00
Mike Schwarz 91ad6e719f docs: name the directories the spec library scan skips (#2462)
The instance layout reference now says which directories the spec library scan skips. Below the built-in specs, $OPENRIG_HOME/specs and the legacy library, it skips any directory named .git, .worktrees, node_modules, dist, build, .turbo or .next (SKIP_DIRS in spec-library-service.ts), so a spec such as specs/build/rig.yaml isn't listed and nothing says why. To list it, rename its directory and run rig specs sync; rig specs add keeps a directory's own name, so it doesn't help for a directory named build, and rig up with a path to the rig.yaml still launches it without the library. Text only; the scan itself is unchanged. Reported in #2333 by @EZotoff.
2026-11-03 15:45:28 -08:00
Mike Schwarz fe29a92e44 fix(daemon): put a seat's guidance back before any runtime's handover successor launches (#2458)
A handover now puts a seat's missing managed guidance blocks back for every runtime before its successor launches, as it already did for Codex and as exact resume does. It passes the rig's Claude file, so a Claude seat on a rig that uses CLAUDE.local.md gets its blocks there, not in CLAUDE.md, and the file each seat gets still follows the runtime saved at its last launch. That covers a rebuild into a working directory without the seat's blocks, for example after rig seat set-cwd. Blocks already present are left alone, and a block that can't be put back is named in the result's notes while the handover completes. Not covered: comparing blocks already in the file with their current source, on any route, is not part of this change; a required startup file that can't be delivered fails an ordinary start but is only a note on a handover; Pi, omp and a Claude fork take the same call, and the new cells cover Claude fresh and rebuild. Reported in #2456 by @eisenbergjerry.
2026-11-03 15:24:04 -08:00
NIKHIL ab858f691b fix(tui): read a seat's own window for RECENTLY FINISHED (#2459)
An agent's page in rig tui keeps its RECENTLY FINISHED section however busy the rest of the fleet is. The page used to read the instance's 20 most recently finished items and filter them down to the seat, so 20 finishes elsewhere could hide everything this seat had just done. It now reads GET /api/queue/list with state done and handed-off, sorted by update, limited to 20 and filtered to the seat's own session, which the route applies in one query. The Pulse JUST FINISHED lane keeps its instance-wide read. While a pending drill has every rig's inventory loaded, a seat whose logical ID another rig also uses can show an empty section for that one refresh; it never shows another seat's rows. A failed read shows no section and names the read, as before. Contributed by @Nikhi00718.
2026-11-03 15:16:27 -08:00
Mike Schwarz 18b5d79c9d fix(rigspec): add only the rig layers there are to the spec a seat launches with (#2457)
The spec handed to a seat's startup layering now carries only the rig layers that exist: withRigStartupLayers adds cultureFile and startup only when the stored or declared layer has a value, so a missing layer is no key rather than one holding undefined. The layers still replace the spec's own pair, as before, and OpenRig's own config resolution already ignored the undefined values, so seats launch with the same guidance; callers that inspect the spec's keys now see exactly the layers there are. The rigStartupLayers comment now states the rule as it is: the culture file and startup block are decided as a pair. Reported in #2454 and #2455 by @eisenbergjerry.
2026-11-03 14:34:45 -08:00
Mike Schwarz c9b1ae480f fix: bound tmux client execution and kill stalled calls (#2452)
A stalled tmux client no longer holds a seat's later sends or the daemon's single-flight captures indefinitely. The default argv executor and the POSIX legacy executor now end a tmux client that hasn't answered within 30 seconds, using execFile's own timeout with SIGKILL, so the call fails only once the client is gone and the seat's next operation can run. Only tmux invocations replace the shell, so kill keeps the shell's builtin, including on hosts without an external kill executable. A deadline failure logs the tmux operation and its target without input payloads, and its error tells the person that the write may still have happened, to check with rig capture before resending. There's no new state, refusal or error code. The shared execCommand is unchanged, including RuntimeVerifier's separate tmux -V and cmux probes. Windows argv calls are bounded; legacy Windows strings and injected executors are unchanged. The POSIX legacy path's output limit grows from exec's 1 MiB to the argv path's 64 MiB. Not covered: this is a per-exec deadline, so cleanup can require another bounded call, and killing a client doesn't establish whether a server-side write happened.
2026-11-03 11:57:36 -08:00
NIKHILA 076c710d42 feat(specs): make built-in inventory visibility configurable (#2379)
Operators can set specs.builtins_visible to false to leave built-in rigs, agents and workflows out of the shared spec inventory that the CLI and TUI list; the default stays visible. rig specs ls --include-builtins lists them on demand, and explicit lookups and launch paths still reach them, so this is an inventory preference, not an access boundary. With built-ins hidden, a visible user spec wins a name lookup; with both visible, a same-name match stays ambiguous, as today, and rig specs ls --include-builtins --json shows each entry's explicit ID. A user's copy of a workflow keeps its own source-specific library entry, projected only while the cached row for that name and version holds the file's current content, so an older version can't take its link. Requested in #2333 by @EZotoff. Contributed by @Nikhila-Dhevi-R.
2026-11-03 10:40:04 -08:00
Mike Schwarz e7e6dc4172 feat(mission-control): an opt-in decision revision on the settling verbs (#2448)
A client, such as an outside GUI, can now settle exactly the decision its person reviewed. GET /api/queue/:qitemId returns decisionRevision, an opaque token computed on read over the question (body, intent, questions and options) and the decision's state (open, blocked or terminal, who it's addressed to or parked on, the recorded answers, a delegated decision, the current park and the replies returned). A summary change, a wake or a claim doesn't move it; a new park does. POST /api/mission-control/action takes an optional expectedRevision for answer, resolve, approve and deny. When it's current, the act runs as today and the result adds the saved revision. When it's stale, the response is 409 decision_revision_changed with nothing written, carrying the current revision, state, answers and most recent action. On other verbs the revision isn't checked, and the result's revisionNote says so. The compare runs at the start of the act, before any branch that writes, with no await before the first write; a runner's permission ask whose call wrote nothing before its await compares again just before its close. A retry under the same operationId in auditNotes, with the same verb, sender and payload, is reconciled from its record (reconciled: true, the original actionId). A forward to the origin host carries the revision. It's opt-in: a caller that sends no revision acts exactly as before, with the same result fields, and the CLI, TUI and Slack don't send one. Not covered: the Mission Control views and attention reads don't carry the token yet; an exact retry of an operation that changed nothing acts again and writes a second audit row; a forwarded act still runs as this daemon's identity at the origin. Requested in #2446 by @pvl-agent.
2026-11-03 09:38:14 -08:00
Devulapalli Naga Sri Vaishnavi 2128bb85d1 fix(daemon): bound the Pi Node version probe (#2451)
Bootstrap's runtime verification no longer waits forever when Pi is installed but node --version hangs. The Node check now runs through the same bounded probe executor as the other runtime probes, so a timeout is recorded as an error with its message, as a hanging binary probe already is, and a shutdown stop propagates without persisting a misleading result. Other Node lookup failures, such as node missing from PATH, keep the existing fallback, and the engine floor is still checked again at launch. Contributed by @Vaishnavi220506.
2026-11-03 09:31:57 -08:00
Mike Schwarz 3352bee0d6 fix(delivery): a guarded wake recovers its own paste when the input can't be read before Enter (#2447)
On a draft-aware seat, a wake no longer leaves its own paste in the input and then holds every later delivery behind it. When the guard can't read the input just after its own paste, it reads once more after 300 ms; an input that still can't be read is refused as before, and unknown never counts as empty. When it does refuse, it records the paste as OpenRig's. The next delivery that finds exactly that paste (the same occupant and pane, no input from a person through OpenRig since, and the paste's own text or the collapsed label recorded right after pasting it) submits it first, then pastes its own text, and says so in its result. The final checks run again just before that Enter. Nothing is ever removed from the input, and a delivery that waits for an idle seat, such as managed compaction, stays held. Not covered: the 300 ms bound isn't measured on a native Claude; a label first seen after the delivery ended stays held; a person's replacement paste of the same length with a reused label isn't told apart; a read-only tmux client refuses the leftover's Enter, so the next wake stays held. Reported in #2429 by @billbenetti.
2026-11-03 09:13:32 -08:00
Mohit Sharma 94b01e5c88 fix(openrig-upgrade): use node:sqlite for database backup when sqlite3 CLI is absent (#2394)
Step 4 of the openrig-upgrade skill now works on a host without the sqlite3 CLI when the running Node provides node:sqlite. backup-sqlite.mjs still uses the sqlite3 CLI's .backup first (OPENRIG_SQLITE_BIN, or sqlite3 on PATH); when the CLI isn't there, it uses Node's built-in node:sqlite online backup() for a page-for-page copy, and VACUUM INTO only when backup() isn't available, then checks the copy with PRAGMA integrity_check. With neither the CLI nor node:sqlite, it stops with a message naming both remedies. It needs no extra dependency. A focused test runs the helper with an empty PATH, confirms sqlite3 can't be found, and checks the copied database. Reported in #2391 by @ziadeh. Contributed by @mohitdixit02.
2026-11-03 09:05:20 -08:00
Mike Schwarz 79e5b7952d test(compaction): keep armed-map waits on the fixture clock (#2450)
The armed-map cells in compaction-preparation.test.ts are deterministic again. The fixture's injected clock and transport sleep advance fake time, but waitForIdle's deadline read the real Date.now, so with #2445's longer managed /compact wait an attempt could expire on fake time before the second idle poll, intermittently on a loaded runner. The four cells now spy Date.now onto the fixture clock, as other cells in the file already do, and assert the busy wait stays within the readable budget. Test only; production is unchanged.
2026-11-03 09:01:44 -08:00
Nash Aschenbath ea4c178126 fix(cli): skip the experiment config owner check where getuid is unavailable (#2449)
Writing the experiment config no longer fails on a platform where Node has no process.getuid. The owner check compared the file's uid with process.getuid?.(), which is undefined there, so every write was refused as "experiment config must be an owned regular file". The comparison now runs only where getuid exists, so POSIX behaviour is unchanged, and the regular-file and single-link checks still apply everywhere. Contributed by @Aschenbath.
2026-11-03 07:01:36 -08:00
Mike Schwarz 98f0f4939c fix(compaction): read small panes at a readable size and retain end reasons (#2445)
Managed compaction now works on a small Claude pane, such as a tile in a follow view. The /compact send's readiness probe and its final input check read the pane at a readable size, using the existing readable capture, which pins the window, waits for the redraw and restores the size exactly; the uncertain post-compact read does too. The managed send's idle wait includes the readable budget, still bounded by the attempt's deadline, so the larger read can finish. Ordinary sends are unchanged, and a pane that still can't be classified stays unknown, never idle. An ended attempt now records its reason in COMPACTION-STATE.json. Reported by the Ops team on an 11x20 pane.
2026-11-03 06:15:44 -08:00
Dery Ferdika 9819da9d5e test(scenarios): cover queue handoff, block, resolve and unclaim transitions (#2389)
A new scenario, packages/test-system/scenarios/queue-transitions-reach-exact-states.yaml, drives the queue transitions the test-layers Help wanted row lists as still owed: handoff, block, resolve and unclaim. It runs through the existing env.queue fixture and the shipped rig queue commands, and asserts each row's observable state through expect queue, which now reads closed rows too (rig queue list --json --full --all): the handoff's source reads handed-off to its new owner, and its successor is pending there with handedOffFrom set. Invalid fixtures are refused when the scenario loads, including a whitespace-only handoff destination. Test harness and docs only; no product code. The scenario itself hasn't been run end to end yet. Contributed by @DeryFerd.
2026-11-03 04:35:12 -08:00
Mike Schwarz 3fe80b97f6 fix(delivery): tell swapped occupants about held messages, and let every handoff pass a full held quota (#2442)
An agent that takes over a seat is now told about the seat's held messages, as a seat that starts already is. After a fresh, fork, rebuild or discovered handover, or a runtime switch, the result's warnings name how many messages are held and `rig seat held-messages <seat>`, and `rig handover` prints them. The notice is also typed to the new occupant, except into a discovered successor (a session OpenRig didn't launch) or after a restore packet that wasn't confirmed, where it could submit someone's unsent text. A runtime switch's activation carries it, once per activation attempt. Nothing is replayed. A full held quota no longer rolls back mission control's route and handoff verbs or a workflow step's handoff: the wake is recorded as not retained and the work still moves, as `rig queue handoff` already does. Reported in #2430 and #2431 by @billbenetti.
2026-11-03 03:23:38 -08:00
Mike Schwarz ac7a6b0713 fix(handover): put a Codex seat's guidance back before its successor launches (#2444)
A Codex seat that hands over to a new occupant keeps OpenRig's guidance. Since #2441, a fresh Codex launch carries the guidance as launch developer instructions instead of writing AGENTS.md, but a fresh or rebuild handover successor launches without them, so it started with no guidance (a fork very likely too; the put-back covers it). Before the successor launches, the handover now puts the seat's missing guidance blocks back in the repository AGENTS.md from its saved startup context, using the same steps restore uses: only missing blocks are written, the person's text around them is unchanged, and a seat that already has them, or has no saved context, is left alone. A discovered handover launches nothing and is skipped. The startup guide lists a handover successor among the cases that still use the repository AGENTS.md. Part of #875, which stays open.
2026-11-03 03:15:29 -08:00
Mike Schwarz cb3fb1890d docs(startup): where a fresh Codex or Pi seat's guidance goes, and how a shell-set CODEX_HOME is checked (#2443)
docs/reference/agent-startup-guide.md gains a "Codex guidance at launch" section. On a fresh launch of Codex 0.164.0 or later, with no developer_instructions in a config layer OpenRig reads, a seat's managed guidance is passed as launch developer instructions, and neither the tracked AGENTS.md nor the Codex home is written. The section lists the layers read and the home used, and when the repository AGENTS.md is still used: a configured or unreadable layer, a resume or fork, a restore (even one that starts fresh), and an older or unknown Codex. If your shell alone sets CODEX_HOME, declare that same absolute path in the member's or rig's env:, so OpenRig checks it and a configured value keeps the repository file. Packaged, MDM and enterprise-managed settings aren't read and may be replaced, and legacy MDM config can override the guidance. The delivery table and the Pi and OMP lines now match where their guidance goes. Part of #875, which stays open.
2026-11-03 02:14:55 -08:00
Nash Aschenbath a0622b63fd fix(seat): set-typing-guard applies from an ordinary terminal, recorded as unattributed (#2351)
`rig seat set-typing-guard` now works from an ordinary terminal. Without a seat identity, a valid request applies, and the preference and its audit entry record the actor as `unattributed (no seat identity)`. With one, attribution is unchanged, and neither the request body's actor nor the target seat's name stands in for it. Target, mode and reason validation, activation, pending changes and held messages are unchanged, and there's no migration. The command's help and docs/reference/seat-delivery.md describe both cases. Reported in #2350 by @Aschenbath.
2026-11-03 01:40:43 -08:00
Mike Schwarz 0f6e6ea8f3 fix(codex): a fresh Codex seat's managed guidance arrives as launch developer instructions (#2441)
A fresh Codex seat's managed guidance now arrives as a launch developer instruction instead of being written into the person's tracked AGENTS.md. On a fresh launch with Codex 0.164.0 or newer and no developer_instructions configured in any layer, OpenRig passes the guidance with -c developer_instructions, and the guidance tells Codex to follow AGENTS.md where they conflict. With no Codex home declared, it reads ~/.codex. Resume, fork, older or unknown Codex versions, a configured value, and a CODEX_HOME set only in a shell profile keep today's repository file. Part of #875, which stays open.
2026-11-03 01:08:27 -08:00
Mike Schwarz 1593295db6 fix(delivery): accept Claude's wrap points, which fall short of the input box (#2436)
A long one-line delivery to a Claude seat is now submitted when Claude wraps it short of the input box's width. The check that the input holds only the delivery's own text accepts Claude's wrap points, up to 6 columns short, and each row must still match exactly. Codex is unchanged. Reported in #2429 by @billbenetti.
2026-11-03 00:51:00 -08:00
Mike Schwarz e3c461a7e3 fix(startup): tell a seat that starts with held messages about them (#2437)
A seat that starts with held messages is now told about them once: it gets one notice saying how many were kept and not delivered, and to read them with `rig seat held-messages <seat>`. The same line appears in the launch's warnings. Nothing is replayed. Handover and runtime-switch successors don't reach this point yet, and a follow-up gives them the same notice. Reported in #2431 by @billbenetti.
2026-11-03 00:50:10 -08:00
Mike Schwarz c9e73d363c fix(delivery): keep one held reminder per watchdog job, and let a handoff through a full held quota (#2438)
A watchdog job now holds at most one reminder per seat: holding a newer reminder retires that job's earlier held one, inside the retention transaction and before the capacity check, so it frees the slot even at a full quota. A queue handoff (`rig queue handoff`, and handoff-and-complete) is no longer blocked by the recipient's full held quota: it stages its wake in a savepoint, so a full quota records that the wake wasn't retained on the successor and the handoff commits, while any other staging failure still rolls it back. That deliberately changes "one act or none" for this one failure. Mission control's route and handoff verbs and workflow step projections still roll back on a full quota; a follow-up gives them the same savepoint. Reported in #2430 by @billbenetti.
2026-11-03 00:34:16 -08:00
Mike Schwarz 51c2bd9d4d fix(cli): scope slice ls and mission ls tell shipped, superseded and closed apart (#2434)
`rig scope` now gives each slice one standing and counts it once. A slice in `closed/` is closed; one whose status is shipped* or done is shipped; one whose status is closed* or superseded, or whose stage is superseded or retired, is closed; everything else is active, blocked included. `slice ls --state` filters on that standing (the default stays `active`, and the help says what each value means), and `mission ls` and `mission show` count by it, with a new `shipped=` count, so a mission whose slices all shipped no longer reads as all active. A malformed slice file reads as empty frontmatter instead of throwing. The advisory dependency graph reads the active set, so it no longer offers a superseded slice as ready to build. The daemon's copy of the scope types matches the CLI's.

Reported in #2427; thanks to @gifflen.
2026-11-03 00:29:40 -08:00
Mike Schwarz 6a35cd39f3 fix(daemon): load the vendored openrig-core plugin's skills once on Claude seats (#2433)
A Claude seat no longer loads openrig-core's skills twice. Each seat's own `openrig` skill plugin received a copy of every selected plugin's skills, and the seat also loads the vendored `openrig-core` plugin directly for its hooks, which includes its skills. The copy is now skipped exactly when that direct load applies (a Claude seat, the vendored manifest present, and a selection that is `<plugins>/openrig-core`). Both routes reach Claude through `CLAUDE_CODE_PLUGIN_DIRS`, so no skill is lost. Existing seats change at their next fresh launch. Codex is unchanged.

Thanks to @billbenetti for the report.
2026-11-03 00:13:38 -08:00
Mike Schwarz 85881245b4 fix(typing guard): read a Claude suggestion with plain spaces between its words as empty input (#2435)
The draft-aware typing guard's input reader treated a Claude prompt suggestion (ghost text) as a person's draft unless every cell, spaces included, was dim, so a multi-word suggestion with plain spaces held every send and wake. It now uses the rule `rig capture`'s classifier already applies: a cursor-to-end suffix is a suggestion when every non-space character is dim, and any undimmed word keeps it a draft. Text before the cursor stays authored input. The test uses a retained Claude 2.1.296 frame with synthetic dim styling, not a capture of the reporter's 2.1.316 session; grey text without SGR dim is outside this change.

Reported in #2428 by @billbenetti.
2026-11-02 23:55:36 -08:00
Mike Schwarz e5cd19e25a fix(seat): record launched handover successors as managed (#2440)
A handover whose source OpenRig launches (fresh, fork or rebuild) records its successor session as launched, in the same transaction that registers it, as a runtime switch already does. `rig seat stop`, `restart` and `clean` then treat the seat like any seat OpenRig launched, instead of refusing it as "adopted, not launched by OpenRig". A discovered-source handover, and genuine `claim`, `adopt` and `attach` sessions, stay adopted and are still refused. Tenure, the reserved occupant generation, predecessor supersession and events are unchanged. Seats handed over before this keep their adopted origin until their next launched handover or managed launch.
2026-11-02 23:40:35 -08:00
Mike Schwarz 9d2d0d47d8 fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md (#2432)
A Pi seat's managed guidance now goes to its own agent folder (`PI_CODING_AGENT_DIR`'s `AGENTS.md`), so a person's tracked repository `AGENTS.md` stays byte-identical across launch, `rig down`, restore and exact resume. A fresh launch asks the adapter once and records the answer in a new nullable `node_startup_context.guidance_file` (migration 146); restore, the exact-resume put-back, teardown, inventory and snapshots all read that one target through `seatGuidanceFile`. Rows and snapshots recorded before this keep the repository file. An exact restore of an older snapshot records that snapshot's choice, and a symlink or hard link introduced after launch is reported as a gap rather than written through. Codex, OMP and Claude are unchanged; this does not stop Codex's tracked-file writes, and #875 stays open.
2026-11-02 23:19:22 -08:00
genius8267 7b858418f3 fix(cli): mark cut queue previews inside the body (#2422)
A bounded `rig queue show` body now ends with `…[cut at N of M chars; rig queue show '<id>' --full]`, so a reader that keeps only `.body` can see that more text exists and how to get it. Counts are code points, like the preview; the row ID is shell-quoted. `bodyTruncated`, `bodyBytes`, `readView`, the stderr notice and `--full` are unchanged, and short or empty bodies are untouched.

Thanks to @genius8267.
2026-11-02 21:15:44 -08:00
277 changed files with 11828 additions and 704 deletions

No files matched your search

+19 -23
View File
@@ -10,12 +10,14 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
## [0.7.8]
**Not yet published.**
Published 2026-11-05 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.8` (`12df562c`).
OpenRig 0.7.8 lets a person answer a runner seat's permission requests, with a
conformance check and an example runner to build against. It adds a maintained
way to run a Claude worker in an NVIDIA OpenShell sandbox, and includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI.
conformance check and an example runner to build against. It includes 0.7.7's
fixes plus repairs across seats, tmux, Slack and the TUI. It also includes a
reference for running a Claude worker in an NVIDIA OpenShell sandbox, but that
worker isn't usable in this release: see [the OpenShell reference](#the-openshell-reference-isnt-usable-in-078).
### Before you upgrade
@@ -61,20 +63,15 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **A seat activity grant** lets a runner outside the host's hook setup report
activity for one seat and launch, without the install-wide hook token. ([#2295](https://github.com/mvschwarz/openrig/pull/2295))
### Claude workers in OpenShell sandboxes
### The OpenShell reference isn't usable in 0.7.8
- **`rig environment setup` installs a maintained reference** that binds a seat
to one NVIDIA OpenShell sandbox (`rig environment create` or `attach`, then
`rig up`).
- **Context and results:** the seat's guidance, skills, plugins and startup
files go into the sandbox, and verified JSON results come back through
queue work.
- **Stop and delete:** stopping the agent, stopping compute and deleting the
sandbox are separate actions.
- **Restore:** a recreated sandbox resumes the same conversation.
The steps are in `daemon/assets/openshell/README.md`.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300))
0.7.8 includes `rig environment` (`setup`, `create`, `attach`) and a reference
for binding a seat to an NVIDIA OpenShell sandbox (`daemon/assets/openshell/README.md`).
**In our release testing the worker never became usable:** after the sandbox was
created and the native process started, `rig environment connect` failed when its
check timed out after 5 seconds, so no team work ran. Don't rely on this path in
0.7.8.
([#2296](https://github.com/mvschwarz/openrig/pull/2296), [#2300](https://github.com/mvschwarz/openrig/pull/2300), [#2378](https://github.com/mvschwarz/openrig/pull/2378))
### Seats, delivery and the TUI
@@ -150,14 +147,13 @@ fixes plus repairs across seats, tmux, Slack and the TUI.
- **The dsh example keeps one hold per seat:** "if dsh asks twice in one turn,
settling one ask releases the hold while the other may still wait on a
person."
- **The OpenShell reference requires:**
- NVIDIA OpenShell 0.1.6;
- a Linux image;
- an API-backed provider using `ANTHROPIC_API_KEY`. Subscription or OAuth
credentials aren't supported.
- **The worker isn't usable** (above). The reference also requires NVIDIA
OpenShell 0.1.6, a Linux image, and an API-backed provider using
`ANTHROPIC_API_KEY`; subscription or OAuth credentials aren't supported.
"Provider registration and image construction can incur costs." The worker's
activity reads unknown, from pane sampling, never idle.
- **The OpenShell README example uses `operator@kernel`;** the actual kernel
operator is `operator-agent@kernel`.
- **"Never grant the guest access to the daemon's main API port (normally
7433)."** The guest-only listener is what confines a guest's reach. Its grant
only authorizes requests for its own seat and launch.
@@ -461,6 +461,13 @@ in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
+10 -3
View File
@@ -356,6 +356,13 @@ 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` |
`rig host pair` needs the target daemon to run with `OPENRIG_AUTH_BEARER_TOKEN` set,
even when it is bound to a tailnet address and so starts without one: pairing hands
over that token. Without it, the target's `POST /api/hosts/pair-request` answers 409
`pair_target_no_bearer`; it answers 409 `pair_human_required` when no single
registered person can approve (none, or several with no default; name one with
`--human`) (`packages/daemon/src/routes/hosts.ts:167-186`).
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
@@ -379,7 +386,7 @@ do not establish connectivity to a live peer.
|---|---|---|
| `rig ps` | — | `--json`<br>`--no-cleanup`<br>`--resources`<br>`--nodes`<br>`--full`<br>`--verbose`<br>`--limit <n>`<br>`--fields <list>`<br>`--summary`<br>`--filter <key=value>`<br>`--active`<br>`--running`<br>`-A, --all-rigs`<br>`--rig <name>`<br>`--session <name>`<br>`--include-archived`<br>`--host <id>`<br>`--all-hosts`<br>`--hosts <ids>` |
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`.
- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it.
### roster
@@ -463,7 +470,7 @@ do not establish connectivity to a live peer.
| `rig queue create` | — | `--source <session>`<br>`--destination <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--body-context <ref>`<br>`--mission <id>`<br>`--slice <id>`<br>`--gate <role>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--expires-at <iso>`<br>`--id <qitemId>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--human-intent <intent>`<br>`--alert`<br>`--human-detail-file <path>`<br>`--reply-to <qitemId>`<br>`--human-questions-file <path>`<br>`--evidence-ref <path>`<br>`--host <id>`<br>`--no-nudge`<br>`--verify`<br>`--full`<br>`--json` |
| `rig queue claim <qitemId>` | — | `--destination <session>`<br>`--full`<br>`--json` |
| `rig queue unclaim <qitemId>` | — | `--destination <session>`<br>`--reason <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue update <qitemId>` | — | `--actor <session>`<br>`--state <state>`<br>`--reopen`<br>`--closure-reason <reason>`<br>`--closure-target <target>`<br>`--closure-required-at <iso>`<br>`--resolution <text>`<br>`--blocked-on <blocker>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--full`<br>`--json` |
| `rig queue block <qitemId>` | — | `--on <blocker>` **required**<br>`--actor <session>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--note <text>`<br>`--continuation <text>`<br>`--wake-watchdog <jobId>`<br>`--wake-after <duration>`<br>`--full`<br>`--json` |
| `rig queue resolve <qitemId>` | — | `--decision <text>` **required**<br>`--actor <session>`<br>`--bearer <token>`<br>`--no-notify`<br>`--json` |
| `rig queue handoff <qitemId>` | — | `--from <session>`<br>`--to <session>` **required**<br>`--body <text>`<br>`--body-file <path>`<br>`--note <text>`<br>`--priority <priority>`<br>`--tier <tier>`<br>`--tags <tags>`<br>`--gate <role>`<br>`--target-repo <name>`<br>`--summary <text>`<br>`--evidence-ref <path>`<br>`--alert`<br>`--host <id>`<br>`--no-nudge`<br>`--full`<br>`--json` |
@@ -631,7 +638,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
+1 -1
View File
@@ -540,7 +540,7 @@ Before you start, two honest constraints:
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
+89 -3
View File
@@ -214,7 +214,7 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into `AGENTS.md` (Codex, Pi) or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`) | Role guidance, culture, project context |
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -367,7 +367,15 @@ Each startup file and action specifies when it applies:
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts.
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
@@ -528,6 +536,58 @@ This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -558,7 +618,10 @@ remain shared.
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory; guidance is merged there, added to Pi's own system prompt
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
@@ -672,6 +735,29 @@ When authoring startup content, note which instructions are runtime-specific. Fo
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
+23 -2
View File
@@ -23,7 +23,10 @@ Only the activity axis is served today. The session and resumability values are
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`).
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
@@ -59,6 +62,21 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
@@ -116,7 +134,10 @@ time-bounded evidence. Rungs, top down, with each rung's retirement condition:
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). Undeclared and partial-coverage rungs are identity-only, which is also where
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
+12 -1
View File
@@ -8,4 +8,15 @@ Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-c
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. If the selection changes after client detach, the response reports `partialEffect: true` and no kill is sent. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
+203
View File
@@ -0,0 +1,203 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
## Event streams
All three are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log. `?rigId=` limits it to one rig. Each event carries `id: <seq>`.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
+29 -17
View File
@@ -5,15 +5,31 @@ OpenRig's gateway can register **several people** and **shared channels**, group
OpenRig doesn't enforce it. This page covers the commands, what the default is for, and what upgrading and rolling back
do.
For a remote client, `GET /api/gateway/participants` returns the same participant, organization, group, default and
warning fields as `rig gateway participants --json`. Add `?organization=<id>` to filter the participant list; the default
and organization and group maps still describe the whole registry. The result names the participant file and reports
an unreadable side file without treating it as an empty one. If the human registry itself cannot be read, the route
returns `503` with `error: "human_registry_unavailable"`. The result does not include connector secret pointers.
## Participants
A participant is either:
- **a person**, registered with `rig gateway human add` as before, with their own Slack binding; or
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
sender.
People and channels share one address space: a channel can't take a person's id.
A registered person is addressed as `<id>@external`, including without Slack: for example,
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
substituting the sender's address and your summary, evidence path and reply. A reply destination doesn't identify
who acted or determine who may answer an ask. Clients acting as a registered person name that person's
`actorSession`; a seat identity header takes precedence. The terminal's primary-user authority records the unnamed
`human@host` actor, not the person whose queue is being viewed.
```bash
# An organization and a group inside it (one command each, create or update)
rig gateway org set acme --name "Acme" --policy "Answer Acme's customers within a working day. Never promise a date."
@@ -59,30 +75,26 @@ never changes `workspace.operator_seat_name`, which keeps its own meaning.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**: the default person, or, with several people and none chosen, the
person at the terminal unnamed. Whatever the TUI shows, its acts are theirs.
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
whatever person's queue it shows or who is configured as the default.
- **Opening:** with a chosen default, the TUI opens on that person's Feed. With none chosen, including a one-person
install that never chose one, it opens on the topology, as before.
- **Whose queue:** `whose <person>` shows one person's asks, `whose all` everyone's, and `whose default` the default
person's. The Feed's first line says whose queue it shows and who acts.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the primary user as the actor,
and the person asked in the audit notes and the transition. The asking seat reads, for example, "Answered by
mike@external, the primary user at the terminal, on sarah@external's ask". With several people and none chosen, the
actor is `human@host`.
- **Viewing Sarah isn't acting as Sarah.** Only Sarah, from her own channel, answers as Sarah. Without the primary
user's authority, one registered person can't answer, approve, deny or resolve what was asked of another; that's
refused (`answer_not_recorded`) and nothing changes.
- With no one registered, the TUI and its acts are as before, except on a shared channel's ask or a row parked on a
channel: the TUI shows the act as `human@host`, the person at the terminal, and it's recorded that way, never as the
channel.
- **Acting on someone else's ask** (answer, approve or resolve from the TUI) records both: the unnamed terminal
actor `human@host` and the person asked in the audit notes and transition. The asking seat reads, for example,
"Unattributed (human@host) answered sarah@external's ask".
- **Viewing Sarah isn't acting as Sarah.** A registered person may answer, approve, deny or resolve another person's
ask as themselves; both the actor and person asked are recorded. No connector binding is required, and viewing
someone else's queue doesn't make its viewer that person.
- **A shared channel never acts.** Its messages are correspondence, so nothing it says answers, approves, denies or
resolves an ask, and a customer's earlier message in an ask's thread is never taken as its answer. A request without
the primary user's authority that acts as a channel, with any Mission Control verb (including drop, route, hold, hand
off and annotate), is refused (`answer_not_recorded`) and nothing changes.
- **Where the authority applies:** only on a request that carries no seat identity header, which is how the TUI calls
the daemon. Seats and the CLI send that header, so their acts stay theirs. This is the existing trusted-network
distinction, not an authentication check: a request that leaves the header out is taken as the terminal's, as any
such request already is.
- **Where the authority applies:** primary-user authority is accepted only on a request that carries no seat identity
header, which is how the TUI calls the daemon. A headerless client can instead name a person's `actorSession` without
that authority. Seats and the CLI send the identity header, so their acts stay theirs. This is the existing
trusted-network distinction, not an authentication check.
## Shared-channel conversations
+11
View File
@@ -291,6 +291,17 @@ fine; if both accounts are authenticated, the kernel uses both even when your
later project uses just one. A request to use only one provider for everything
is a separate choice.
`/api/kernel/status` retains the chosen template (`rig.yaml`,
`rig-claude-only.yaml` or `rig-codex-only.yaml`) across daemon restarts and
recovery, including starts with `--no-kernel`. It reads the rig's latest rig-spec
bootstrap record, including a partial launch, so an older kernel's recorded
selection is also available. This is the original selection; the rig's live spec
describes its current configuration. The saved source must match a template under
the current installation’s built-in specs directory. A custom project path with
the same directory suffix, an old source path that can no longer be verified
(for example, after a Node version change moves the global install), or a kernel
without bootstrap history reports an unknown variant.
**Started is not ready.** Read the `Kernel:` boot state in `rig status` and the
individual seat state in `rig ps --nodes --rig kernel`. The view may open while
agents finish starting; describe that state, without claiming they are ready.
+11
View File
@@ -110,6 +110,17 @@ limitation: use the live spec-library commands to determine where a spec is
served from, and do not infer convergence merely because the canonical
directory exists.
The spec library scans the built-in specs, `$OPENRIG_HOME/specs` and, when
present, that legacy library. At any depth below them it skips directories
named `.git`, `.worktrees`, `node_modules`, `dist`, `build`, `.turbo` and
`.next`, which usually hold build output or dependencies (`SKIP_DIRS` in
`packages/daemon/src/domain/spec-library-service.ts`). A spec inside one of
them, such as `specs/build/rig.yaml`, isn't listed, and nothing reports that it
was skipped. To list it, rename its directory and run `rig specs sync`.
`rig specs add <directory>` copies a directory under its own name, so adding a
directory named `build` doesn't help. `rig up <path-to-rig.yaml>` still launches
a spec by its path, without the library.
Config updates publish a complete replacement file atomically where directory
permissions and the filesystem allow it. On POSIX systems, replacement preserves
the existing owner, group, and read/write/execute permission bits. If creating or
+22
View File
@@ -107,6 +107,28 @@ rig queue create --destination <entityId>@external --human-intent update \
- **`posted` means the connector posted it, not that they read it.** Don't say they saw it.
- **If delivery fails or is indeterminate,** inspect that task before anything else. Never resend blindly.
## If the timer cannot reach its recipient
A `periodic-reminder` occurrence that definitely fails before input remains owed. The watchdog
retries through its existing loop, using `queue.wake_retry_interval_seconds` and
`queue.wake_retry_cap` (by default, three retries five minutes apart). A failed attempt does not
advance the reminder to its next regular cycle. A confirmed send advances the normal interval;
recovery does not replay every elapsed cycle.
`rig watchdog show <jobId> --json` includes the failed attempt, its retained outbox IDs, retry
time and next action. If input may have reached the recipient, that occurrence ends unconfirmed
and is not automatically sent again. Inspect the recipient and exact outbox record before any
manual replay. A recurring reminder keeps its next normal cycle under a new occurrence identity. A confirmed receipt survives a daemon restart, so
finishing its accounting does not type the same occurrence twice.
Exhausted retries end only the failed occurrence, with its cause and next action; a recurring
reminder keeps its next normal cycle. A permanent transport failure leaves a terminal job:
correct the destination and register a replacement reminder. A one-shot park timer records its
failed or unconfirmed wake on the parked row before it ends. A retryable failure is recorded
there while the timer stays armed. Deliberately held
input keeps its existing retained-message custody. Condition watches and parked-owner policy
behavior are unchanged. This does not replay failed occurrences recorded by older versions.
## "What are you watching for me?"
List **every active state**, not just set-aside ones. A watch being checked can be in progress, and a new one can be
+2 -1
View File
@@ -25,7 +25,8 @@ that restores nothing is not itself evidence of accidental loss.
## Choose the interface the seat actually has
Pi 1.4.0 includes an opt-in selector at `examples/extensions/tools.ts`. Its
Pi 1.4.0 includes an opt-in selector at
`packages/coding-agent/examples/extensions/tools.ts` in the Pi repository. Its
`/tools` command remembers selection per branch and needs Pi's own TUI. Use it
where that extension and native TUI are available. **Do not send `/tools` to an
agent OpenRig drives over RPC**: that interface cannot operate the selector.
+2 -2
View File
@@ -91,10 +91,10 @@ my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Portable mode omits owner metadata (uid, gid, user and group names) and only partly normalizes file modes: owner read and write are always set and group and other write are cleared, while execute and read bits are kept
- Maximum gzip compression (level 9)
Identical staged bytes (including manifest timestamps and provenance) produce the same archive hash. Use the package digest to compare packaged file content independently of manifest metadata.
Identical staged bytes and file modes (including manifest timestamps and provenance) produce the same archive hash, so staged trees with the same contents whose files differ in execute or read bits hash differently. Use the package digest to compare packaged file content independently of file modes and manifest metadata.
---
+59
View File
@@ -755,6 +755,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
| `agent_file` | string | no | — | Claude members only. A Claude agent definition (a `.md` file with `name` in its frontmatter) for this seat. It's copied into the seat's own storage and launched as `--agent openrig:<name>`. A path beside the spec: no `..`, not absolute. |
| `append_system_prompt_file` | string | no | — | Claude members only. A prompt file appended to Claude's system prompt for this seat. It's copied into the seat's own storage and passed as `--append-system-prompt-file`. A path beside the spec. |
| `plugin_dirs` | list | no | — | Claude members only. Claude plugin folders for this seat, each copied into the seat's own storage and loaded through `CLAUDE_CODE_PLUGIN_DIRS`. Paths beside the spec. Each is copied under its folder name, so two folders with the same name are refused; an empty list is none. A missing file or folder fails that seat's launch by name. `rig bundle create` carries all three; `rig export` names a seat whose additions it doesn't carry. |
| `plugin_channels` | list | no | — | Claude members only. Plugin channels this agent registers for, as `plugin:<name>@<marketplace>` entries, passed as one `--channels` on every launch; see [Claude plugin channels](#claude-plugin-channels-runtime-claude-code). |
| `role` | string | no | — | The seat's role, letters, digits, `_`, `.` and `-`. Not valid on terminal members. |
| `restore_policy` | string | no | the AgentSpec's | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. The default comes from the AgentSpec's `defaults.lifecycle` (else `resume_if_possible`) as narrowed by its profile; a member value may only narrow it further, in that order. |
| `compaction_strategy` | string | no | — | `default-compaction`, `managed-compaction`, `handover` or `apprentice-handover`; `harness_native` and `pod_continuity` are accepted as deprecated aliases, and `custom_prompt` is refused. `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing). |
@@ -810,6 +811,52 @@ OpenRig keeps the requested opt-out flags, so startup and resume remain subject
to that native refusal; it does not fall back to loading servers. Use a host
without that managed configuration, or omit the opt-out to use the managed set.
### Claude plugin channels (`runtime: claude-code`)
Set `plugin_channels` on a Claude member to register that agent for channel
notifications (messages a plugin's MCP server pushes into the session):
```yaml
members:
- id: support
agent_ref: local:agents/support
profile: default
runtime: claude-code
cwd: .
plugin_channels:
- plugin:example-channel@example-marketplace
```
OpenRig passes `--channels plugin:example-channel@example-marketplace` to this
agent's Claude on every launch, resume, fork, restore and handover. Several
entries follow one `--channels`, space-separated, and OpenRig's own session
options come after them. Claude Code 2.1.316 hides `--channels` from
`claude --help`, so it isn't listed there. An agent without the field gets no
`--channels`, and nothing is added for other agents or other runtimes; there is
no rig-wide or environment default.
Each entry is `plugin:<name>@<marketplace>`, both names made of letters, digits,
`.`, `_` and `-`, starting with a letter or digit. `server:` entries and Claude's
`--dangerously-load-development-channels` aren't supported. Combining a non-empty
channel list with `claude_mcp: none` produces a validation warning: that setting
starts Claude without MCP servers (plugin servers included), so no channel could
register. OpenRig keeps both settings and launches the member normally. Remove
`claude_mcp: none` to use the channels, or remove `plugin_channels` to keep the MCP
opt-out. OpenRig doesn't install plugins or marketplaces, or change the settings
that admit a channel: whether each channel registers is Claude's decision (the plugin installed, and
admitted by your organisation's channel settings or Claude's approved list).
Selecting a channel for one agent doesn't isolate what the plugin shares with
other agents. A token or other credential the plugin keeps in its own
configuration is the same for every agent that loads that plugin.
The selection is saved with the seat's other launch additions when the seat
starts from the spec, and starting it from a spec without the field removes it.
`rig seat launch --plan` and `rig skill loadout --seat` list it, a fork keeps it,
and `rig bundle create` and `rig export` carry it in the member; a bundle's
before-install view names each channel as a need. A running Claude process is
unaffected.
### Claude Advisor (`runtime: claude-code`)
Set `advisor` independently of the primary `model`:
@@ -1274,3 +1321,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+22 -3
View File
@@ -144,6 +144,23 @@ changes either. `rig scope` reads an `established` or `canonical` slice whose
`verified:` is missing, has no source, names a tool-scaffolded placeholder, or is
older than 90 days as `provisional`.
A slice's frontmatter may also carry `status:`. `GET /api/slices` reports the
bucket each word falls in as the slice's `status`, and its
`?filter=all|active|done|blocked` filters on that bucket:
| `status:` word | Bucket |
| --- | --- |
| `active`, `in-flight`, `ratified` | `active` |
| `draft`, `draft-pending-orch-ratification`, `placeholder` | `draft` |
| `done`, `shipped`, `promoted`, `closed` | `done` |
| `blocked`, `parked-with-evidence` | `blocked` |
Words are matched ignoring case. Any other word is matched by what it contains:
`done`, `ship` or `close` makes it `done`; `block` or `park`, `blocked`; `draft`
or `pending`, `draft`. A word that matches none of these counts as `active`, so a
slice marked `complete` counts as `active`. A slice with no `status:` counts as
`draft`.
When the owning scope selects `proofPolicy.judges`, use `rig proof judge` to
record an attributed accept, reject or withdraw decision on a contract item,
and `rig proof show` to read derived readiness up through slice, mission and
@@ -178,9 +195,11 @@ missions whose `mission.yaml` carries `metadata.status: active` or
a mission does not change the project's readiness. A `planned` mission stays
outside the rollup, even after its slices are accepted, until someone changes
its status to `active` (or `release-candidate` for a release that is ready to
judge). Nothing changes the status automatically. Missions created before this
default carry no status and are not counted either; add the line by hand to
include them. It also writes `metadata.name`, the mission's folder name, which
judge). Nothing changes the status automatically. A mission can also carry
`metadata.status: complete`, which only `POST /api/missions/:missionId/complete`
writes (no `rig` verb does); a `complete` mission is outside the rollup too.
Missions created before this default carry no status and are not counted either;
add the line by hand to include them. It also writes `metadata.name`, the mission's folder name, which
the project lifecycle compiler requires; a mission created without it refuses to
compile with `lifecycle_field_missing` until you add that line.
+31 -6
View File
@@ -10,7 +10,7 @@ type: `openrig-urgent`, `urgent-only` and `sends-only`. Sibling seats keep their
| `off` (default) | Existing delivery and prompt checks | Existing send result |
| `draft-aware` | Paste into a recognized empty composer; check ownership again before Enter | Hold input and retain this attempt for inspection when bookkeeping is available |
| `openrig-urgent` | OpenRig's own messages (watchdogs, scheduled wakes, notices) and anything marked urgent type, never over a draft | Other agents' messages are held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `urgent-only` | Only messages marked urgent type (`rig send --urgent`, `urgent: true` on `POST /api/transport/send`, or a queue item at priority urgent or critical), never over a draft | Everything else is held, the sender is told, and held messages stay readable |
| `sends-only` | Deliberate sends from people and agents type, as with `off`; a terminal member's typing isn't protected, because its shell input can't be read | OpenRig's own notices are held. A terminal member defaults to this level |
| `hold` | No automatic terminal input, even at an empty prompt | Retain for inspection outside the pane |
@@ -111,8 +111,18 @@ their existing behaviour.
### Terminal delivery
Run from a seat shell with `OPENRIG_SESSION_NAME` set; the daemon derives the
audit actor from transport identity.
`rig seat set-typing-guard` works from an ordinary terminal as well as from a
seat's shell. The daemon takes the recorded actor from transport identity only:
- From a managed seat's shell (`OPENRIG_SESSION_NAME` set), the preference and
its audit entry name that seat.
- From a terminal with no seat identity, the change applies the same way, and
the preference and its audit entry record the actor as
`unattributed (no seat identity)`.
Neither case takes an actor from the request body or from the target seat's name.
`rig seat retire-held-message` still records the seat that retired a message, so
run it from a seat's shell.
~~~sh
rig seat set-typing-guard dev-impl@my-rig --mode draft-aware \
@@ -131,8 +141,21 @@ to finish. The API may return HTTP 202 with `pending: true`; wait for
and effective modes and retains the text `Typing guard: on (hold)` for hold.
A held send immediately returns `outcome: "retained"`, `sent: false`, its exact
reason (such as `draft_input_busy`) and the saved attempt's `outboxIds`. No automatic
retry is scheduled. Each explicit draft-aware request is a new attempt, including
reason and the saved attempt's `outboxIds`. No automatic
retry is scheduled. The reasons are:
- `typing_guard_enabled`: the seat's mode holds this send (`hold`, or a level that
holds this kind of message).
- `runtime_switch_in_progress`: the seat is switching runtime.
- In `draft-aware` mode, a refusal before any input was written:
`draft_input_busy` (the input holds unsubmitted text), `draft_input_unknown` (the
input can't be read), `draft_input_changed` (the input or its target changed during
the delivery), `draft_wake_superseded`, `target_needs_input`,
`wait_for_idle_timeout`, `target_activity_unknown`, `target_runtime_unverified`,
`target_runtime_not_running`, `target_runtime_conflict`, `invalid_wait_for_idle`,
`invalid_dangerously_interact`, `dangerously_interact_requires_reason`,
`prompt_override_audit_unavailable`, `transport_unavailable`, `session_missing` or
`tmux_unavailable`. Each explicit draft-aware request is a new attempt, including
one with a reused `deliveryId`; the result warns older callers that this field no
longer deduplicates input. The CLI does not generate a deduplication key.
@@ -230,7 +253,9 @@ refuses Enter and is not replayed. Unsupported provider layouts remain held.
Use off when that tradeoff is unsuitable.
HTTP configuration uses `POST /api/seat/set-typing-guard/:seatRef` with
`{mode, reason}` or `{enabled, reason}`. Status and held-message routes are
`{mode, reason}` or `{enabled, reason}`. The `X-OpenRig-Session` header, when
present, is the recorded actor; without it the change is recorded as
`unattributed (no seat identity)`. Status and held-message routes are
unchanged; no RigSpec field is required.
## Verification scope
+54 -4
View File
@@ -99,8 +99,24 @@ So after installing, compare the granted scopes Slack shows for the app with all
`rig slack manifest --json` lists.
The app subscribes to messages in public channels it is a member of (`message.channels`), to
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). It does not request
direct-message or private-channel access.
mentions of the app (`app_mention`), and to emoji reactions (`reaction_added`). The manifest does
not request direct-message or private-channel access.
### Using a private channel
The connector doesn't check whether a channel is public or private, so a private channel can be
the default channel or a mapped one once the app can read it. OpenRig doesn't test this setup. In
the app's settings:
1. Under **OAuth & Permissions**, add the bot scopes `groups:history` (read messages in private
channels the app is in) and `groups:read` (read a private channel's details; `rig slack verify`
needs it to confirm the app is a member).
2. Under **Event Subscriptions**, subscribe to the bot event `message.groups`.
3. Reinstall the app to the workspace, then invite it to the private channel.
4. To have `rig slack verify` require the new scopes too, run
`rig slack setup --required-scopes chat:write,channels:history,channels:read,groups:history,groups:read`.
Direct messages are not supported.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
@@ -166,6 +182,9 @@ rig slack channel-map list
rig slack channel-map remove pr@my-rig
```
A client can offer the channels to choose from with `GET /api/gateway/slack/channels`, which lists the public
channels the bot token can see, with their ids ([Writing a daemon client](daemon-http-clients.md#route-details)).
- **Which channel an item posts to.** The item's seat is matched exactly first (`pr@my-rig`),
then its rig (`my-rig`). Anything unmapped posts to the default channel, so a config
without a map behaves exactly as before. Aggregate delivery digests have no single seat and
@@ -221,8 +240,9 @@ configuration matches the file.
The map is stored as `channelMap` in `slack-connector.json`, as a list of `{ "match", "channel" }`
entries, each with an optional `"inbound"` seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and `rig slack status` names it. Private channels and direct messages
are not supported targets.
connector reads the file, and `rig slack status` names it. A private channel can be a target once
the app has the private-channel scopes ([Using a private channel](#using-a-private-channel)).
Direct messages are not supported targets.
## Named bots (optional)
@@ -366,6 +386,36 @@ Quiet hours hold a person's routine asks while they're on, and post them once th
## Reconnect recovery and status
When a posted ask is answered, closed, canceled or replaced elsewhere, the connector edits its
original primary message with the status, time, actor and closure note. Buttons become option
text and the answer hint disappears; the remaining content and reconciliation marker stay as
posted. Supplemental messages are unchanged. This adds no post or reaction and needs no agent
turn. Separate resolution notices retain their existing behavior.
A deliberately reopened direct ask says **Open again**; its options remain as text. If it has
no committed answer, it tells the person to reply in its thread. Otherwise it says **Earlier
answer retained**: reopening does not accept a second answer. Earlier replaced parks remain
historical; **Asked again** does not claim that the later ask has been posted. Edits reconcile
the current state again after awaited Slack calls, so an obsolete close is not recorded as the
current result after a reopen.
Edits follow committed queue changes, with a startup catch-up for posted asks on retained queue
rows. Each primary costs one exact-message read and one `chat.update`. The serial edit worker
uses the recovery budget helpers (four calls, 100 entries, 15 seconds) and waits for the next
budget window when exhausted. Slack's per-method `Retry-After` is retained across restarts.
Only an acknowledged edit gets a success receipt. `rig slack status` shows queued reconciliation
and the last edit failure. Transient failures retry with increasing waits, capped at an hour,
behind other queued work. Permanent Slack failures (such as missing scope, a deleted original,
an archived channel or an uneditable message) remain unfinished and are retried only on the next
connector start. They do not keep consuming edit capacity. These edit calls have their own
budget, separate from missed-message scanning below.
The baseline already includes public-channel history access and `chat:write`. Private-channel
reads may require `groups:history`, which the baseline does not grant; a refused read never
counts as an edit. An unavailable/deleted original cannot be replaced by a new post. Startup
catch-up cannot edit rows already removed by queue retention or posts without their original
notification receipt and channel stamp.
The daemon scans available top-level messages and replies broadcast with **Also send to
channel** in the channels this machine owns: its default channel and its channel map's, except a
channel it relays to another machine. It also scans replies in the threads it tracks in those
+15 -5
View File
@@ -300,13 +300,23 @@ 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 writable tile attaches with `tmux attach -f ignore-size` when its session
already has a client that sizes it, so the tile doesn't fight that client over
the agent window's dimensions. When the session has no sizing client, as with a
freshly launched seat, the tile attaches without the flag and sizes the window
itself: tmux applies ignore-size across the whole server, so a session viewed
only by ignore-size clients would otherwise keep its 80x24 creation size while
any other session has a sizing client. Read-only clients keep `-r` (which also
implies ignore-size), and the printed manual attach commands keep
`-f ignore-size`. This is **not resize isolation**: opening a tile can change
the agent's terminal size. Observers of a Herdr terminal do not themselves resize it;
a controlling browser can. No cross-version resize guarantee is implied.
To type into a seat without changing its size, send the text through OpenRig
(`rig send`, or `POST /api/transport/send`): it is written to the pane with tmux
buffers and keys, and no client attaches. There is no supported way to attach a
writable client that is sure never to resize the agent's window.
## Following a slice or mission's current work
+1
View File
@@ -69,6 +69,7 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
+6 -7
View File
@@ -732,17 +732,16 @@ Examples:
.option("--json", "Complete records as JSON, including organizations and groups")
.action(async (opts: { organization?: string; json?: boolean }) => {
const registry = await import("@openrig/daemon/gateway-human-registry");
if (opts.json) {
const result = registry.participantsJsonView(undefined, opts.organization);
if (!result.ok) { console.error(`refused: ${result.error}`); process.exitCode = 1; return; }
console.log(JSON.stringify(result));
return;
}
const view = registry.participantsView();
if (!view.ok) { console.error(`refused: ${view.error}`); process.exitCode = 1; return; }
const shown = opts.organization ? view.participants.filter((p) => p.organization?.id === opts.organization) : view.participants;
const defaultPerson = view.default.person ? { id: view.default.person.entityId, source: view.default.source } : { id: null, reason: view.default.source === "none" ? view.default.reason : null };
if (opts.json) {
const side = registry.readParticipantsFile();
console.log(JSON.stringify({ ok: true, participants: shown, default: defaultPerson,
organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile }));
return;
}
if (!shown.length) console.log(opts.organization ? `no participants in organization "${opts.organization}"` : "no participants yet: rig gateway human add … registers a person, rig gateway channel add … a shared channel");
for (const p of shown) {
console.log(`${p.id} ${p.kind} "${p.displayName}" ${p.address}${p.channel ? ` channel ${p.channel.channelId}` : ""}`);
+31 -9
View File
@@ -24,8 +24,9 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { hostname as osHostname, userInfo } from "node:os";
import { connect } from "node:net";
import { execFile } from "node:child_process";
import { getOpenRigHome } from "../openrig-compat.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { addHostEntry, updateHostEntry, removeHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, bearerAuthHeaders, type HostEntry, type HttpHostEntry, type SshHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
import { recordHostObservation, loadHostBindings, describeBindingConflict, defaultHostBindingsPath, type HostObservationOutcome } from "../host-bindings.js";
import { DaemonClient } from "../client.js";
@@ -167,15 +168,35 @@ function defaultDoctorDeps(): DoctorDeps {
};
}
/** Where ssh would connect for this host, from `ssh -G`, which reads ssh config without connecting.
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
* direct dial stands for that transport. */
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
return new Promise((resolve) => {
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
if (error) return resolve(null);
const conf = new Map(stdout.split("\n").map((line) => {
const i = line.indexOf(" ");
return [line.slice(0, i).toLowerCase(), line.slice(i + 1).trim()] as const;
}));
const proxied = ["proxyjump", "proxycommand"].some((k) => (conf.get(k) ?? "none") !== "none");
const hostname = conf.get("hostname");
const port = Number(conf.get("port"));
resolve(hostname && Number.isInteger(port) && !proxied ? { hostname, port } : null);
});
});
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
* against the host's transport endpoint (ssh: target:22; http: the
* URL's host:port). Coarse by design (the kubectl STATUS / docker
* context ls precedent): reachable | unreachable | unknown — never a
* hang (hard timeout), never a guess (dial failure = unreachable,
* probe error/unparseable endpoint = unknown). */
* against the host's transport endpoint (ssh: the host and port ssh itself
* resolves, see resolveSshEndpoint; http: the URL's host:port). Coarse by
* design (the kubectl STATUS / docker context ls precedent): reachable |
* unreachable | unknown — never a hang (hard timeout), never a guess (dial
* failure = unreachable, probe error/unparseable endpoint = unknown). */
export async function probeHostStatus(
host: HostEntry,
tcpProbe: DoctorDeps["tcpProbe"],
@@ -185,8 +206,9 @@ export async function probeHostStatus(
let target: string;
let port: number;
if (host.transport === "ssh") {
target = host.target;
port = 22;
const endpoint = await resolveSshEndpoint(host, timeoutMs);
if (!endpoint) return "unknown";
({ hostname: target, port } = endpoint);
} else {
const u = new URL(host.url);
target = u.hostname.replace(/^\[|\]$/g, "");
@@ -761,7 +783,7 @@ export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
cmd
.command("pair")
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port); that daemon must run with OPENRIG_AUTH_BEARER_TOKEN set")
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
.option("--human <address>", "Registered @external approval recipient on the target (required when several exist)")
+1 -1
View File
@@ -42,7 +42,7 @@ export function setExperiment(file: string, enabled: boolean, options: Partial<E
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
try {
const s = fs.fstatSync(fd);
if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.()) throw Error("experiment config must be an owned regular file");
if (!s.isFile() || s.nlink !== 1 || (process.getuid !== undefined && s.uid !== process.getuid())) throw Error("experiment config must be an owned regular file");
fs.ftruncateSync(fd, 0); fs.writeFileSync(fd, JSON.stringify(config, null, 2) + "\n");
} finally { fs.closeSync(fd); }
return config;
+13 -2
View File
@@ -99,6 +99,8 @@ interface NodeEntry {
blockedWorkCount?: number;
/** OPR.0.3.4.11 — held reason from node.held event. */
heldReason?: string | null;
/** #2456: the required startup files this occupant's handover started it without. */
startedWithoutRequired?: string[];
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
@@ -238,6 +240,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"needsInputEvidence",
"contextUsage",
"heldReason",
"startedWithoutRequired",
"storedSessionStatus",
"identityVerdict",
]);
@@ -705,6 +708,11 @@ export function formatDeclaredModel(model: string | null | undefined): string {
return m.length > 0 ? m : "not-declared";
}
/** #2456: the attention reason for an occupant started without a required startup file. */
function startedWithoutLine(files: string[]): string {
return `missing required ${files.map((file) => file.split("/").pop()).join(", ")}`;
}
export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string, unknown>> {
return nodes.map((n) => {
const keepDiagnostics = n.needsAttention !== false;
@@ -768,6 +776,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
}
// held/attention reason (short) when present.
if (n.heldReason) compact.heldReason = n.heldReason;
if (n.startedWithoutRequired?.length) compact.startedWithoutRequired = n.startedWithoutRequired;
if (keepDiagnostics && n.latestError) {
compact.latestError = n.latestError;
}
@@ -926,7 +935,7 @@ intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachC
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
agentActivity, needsAttention, arbitratedNeedsInput, needsInputEvidence,
contextUsage, heldReason, storedSessionStatus, identityVerdict.
contextUsage, heldReason, startedWithoutRequired, storedSessionStatus, identityVerdict.
--host runs on a remote host declared in ~/.openrig/hosts.yaml (no current-rig
default applied; the remote host's rigs are shown).
@@ -1358,7 +1367,8 @@ async function handleNodes(
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON", "ATTN"));
for (const n of humanList as NodeEntry[]) {
const reason = missingSessionGuidance(n) ?? (n.needsAttention !== false || hasCurrentUsageLimit(n)
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
? (n.latestError ? truncate(n.latestError, 40) : n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 40)
: n.agentActivity?.reason ?? "—")
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—");
console.log(padCompactNodeRow(
n.rigName,
@@ -1395,6 +1405,7 @@ async function handleNodes(
formatContextUsage(n.contextUsage),
n.restoreOutcome,
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}`
: n.startedWithoutRequired?.length ? truncate(startedWithoutLine(n.startedWithoutRequired), 30)
: livenessUnconfirmed(n) ? "liveness unconfirmed" : "—"),
));
}
+13 -1
View File
@@ -703,6 +703,12 @@ export function previewBody(
};
}
// Mark the cut inside the body so readers that discard metadata or stderr can request the complete brief.
// Counts use code points, like the preview.
export function previewCutMarker(qitemId: string, shownCodePoints: number, totalCodePoints: number): string {
return `…[cut at ${shownCodePoints} of ${totalCodePoints} chars; rig queue show ${shellQuote(qitemId)} --full]`;
}
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
return (
typeof v === "object" &&
@@ -1234,6 +1240,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--reopen", "Explicitly acknowledge a deliberate terminal-to-active repair; requires --state and --note")
.option("--closure-reason <reason>", "Required for state=done. --state canceled takes no reason, except 'superseded' (with --closure-target = the successor), which records a supersession, distinct from an abandoned cancel")
.option("--closure-target <target>", "Required for handed_off_to, escalation, and superseded; blocked_on park records use state=blocked with --blocked-on")
.option("--closure-required-at <iso>", "Accept a revised closure deadline on an in-progress item; keeps the claim and records the old/new time and accepting actor")
.option("--resolution <text>", "The outcome, stored on the row and shown by rig queue show. Needs --state done, canceled, denied, failed or handed-off, and non-blank text. Stored when the update closes an open row, after anything the row already has (a fallback line, an earlier outcome); a row that is already closed keeps what it has")
.option("--blocked-on <blocker>", "For state=blocked: the blocker — a qitem id (must exist and be live), a human seat (FR-6 park; requires summary + evidence_ref), or a typed non-qitem gate 'fold:<what>' / 'auth:<what>' / 'external:<what>'")
.option("--wake-watchdog <jobId>", "For state=blocked: attach an existing live watchdog id targeting the row owner")
@@ -1249,6 +1256,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen?: boolean;
closureReason?: string;
closureTarget?: string;
closureRequiredAt?: string;
resolution?: string;
blockedOn?: string;
wakeWatchdog?: string;
@@ -1285,6 +1293,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
reopen: opts.reopen,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
...(opts.closureRequiredAt !== undefined ? { closureRequiredAt: opts.closureRequiredAt } : {}),
resolution: opts.resolution,
blockedOn: opts.blockedOn,
wakeWatchdogId: opts.wakeWatchdog,
@@ -1675,7 +1684,10 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
...(closingTransition === undefined ? [] : [omittedReadField("closingTransition", closingTransition)]),
];
const view = readView(item, fullCommand, omitted);
const transformed = { ...record, body: preview, bodyBytes, bodyTruncated, readView: view };
const shownBody = bodyTruncated
? `${preview}${previewCutMarker(qitemId, Array.from(preview).length, Array.from(item.body).length)}`
: preview;
const transformed = { ...record, body: shownBody, bodyBytes, bodyTruncated, readView: view };
// Slice 15 P2: with output.compact on, the empty fields are left out and counted.
const compact = compactOutputEnabled() ? compactShow(transformed) : null;
printResult(json, compact ? compact.item : transformed, res.status);
+36 -2
View File
@@ -213,7 +213,7 @@ function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--state <state>", "Filter: active (in flight, blocked included) | shipped (delivered in place: shipped or done) | closed (in closed/, or superseded, retired or closed in place) | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
@@ -727,12 +727,13 @@ function buildMissionLsCommand(): Command {
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
shippedSliceCount: m.shippedSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} shipped=${r.shippedSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
@@ -764,6 +765,7 @@ function buildMissionShowCommand(): Command {
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
shippedSliceCount: mission.shippedSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
trust,
@@ -778,6 +780,7 @@ function buildMissionShowCommand(): Command {
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(formatTrustLine(trust));
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` shipped slices: ${mission.shippedSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
@@ -1100,6 +1103,7 @@ function buildAuditCommand(): Command {
frontmatter: {},
id: null,
activeSliceCount: 0,
shippedSliceCount: 0,
closedSliceCount: 0,
};
const graph = buildMissionDependencyGraph(auditMission);
@@ -1232,6 +1236,8 @@ function buildAuditCommand(): Command {
for (const sr of sliceResults) {
const shadow = shadowedNodeFileFinding(path.join(slicesDir, sr.name), "slice");
if (shadow) sr.result.findings.push(shadow);
const shared = sharedSliceNameFinding(missionsRoot, missionName, sr.name);
if (shared) sr.result.findings.push(shared);
}
const allFindings = [
@@ -2152,6 +2158,34 @@ function shadowedNodeFileFinding(dir: string, level: "mission" | "slice"): {
};
}
/**
* Advisory-only: another mission has a slice folder of the same name. The slice list and the routes that
* find a slice by name keep one slice per name, so the others are hidden there with no sign they exist.
* Twin of the daemon's finding of the same name.
*/
function sharedSliceNameFinding(missionsRoot: string, missionName: string, sliceName: string): {
kind: "slice_name_shared"; severity: "low"; path: string; message: string; remediation: string;
} | null {
let others: string[];
try {
others = fs.readdirSync(missionsRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== missionName
&& fs.statSync(path.join(missionsRoot, e.name, "slices", sliceName), { throwIfNoEntry: false })?.isDirectory() === true)
.map((e) => e.name)
.sort();
} catch {
return null;
}
if (others.length === 0) return null;
return {
kind: "slice_name_shared",
severity: "low",
path: path.join(missionsRoot, missionName, "slices", sliceName),
message: `Mission ${others.join(", ")} also ${others.length === 1 ? "has" : "have"} a slice named "${sliceName}". The slice list (GET /api/slices) and the routes that find a slice by name keep one slice per name, so all but one of these slices are hidden there.`,
remediation: "Rename one of the slice folders so each slice name is unique across missions. Advisory only — nothing is blocked.",
};
}
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
+15 -1
View File
@@ -159,12 +159,16 @@ interface SeatHandoverMutationResult {
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
/** What the successor was told on taking the seat, such as its held messages. */
warnings?: string[];
/** OPR.0.5.5.5 — per-source execution record (wire mirror of the daemon shape). */
sourceOutcome?:
| { mode: "fork"; forkedFrom: string; forkId?: "captured" | "pending"; limit?: string }
| { mode: "rebuild"; primedArtifacts: Array<{ address: string; label: string }>; gaps: string[]; emptyChainReason?: string; permissions?: { state: string; detail: string } };
/** The restore packet was pasted and Enter pressed, but its submission couldn't be confirmed. */
restorePacket?: { status: "unconfirmed"; reason: string };
/** #2456: the required startup files the successor started without, where a start would have failed. */
startedWithoutRequired?: string[];
sideEffects: {
departingSessionKilled: false;
/** Absent from an older daemon, whose receipt reported both side effects as a fixed false. */
@@ -323,7 +327,11 @@ function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Seat handover complete: ${result.seat.ref}`);
// #2456: a successor that started without a required startup file isn't a plain success; a start would have failed.
const withoutRequired = result.startedWithoutRequired ?? [];
console.log(`Seat handover complete${withoutRequired.length > 0 ? ", degraded" : ""}: ${result.seat.ref}`);
for (const file of withoutRequired) console.log(`The successor started without required file ${file}.`);
if (withoutRequired.length > 0) console.log("rig ps --nodes shows the seat as needing attention until it's next launched.");
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
@@ -333,6 +341,7 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`);
// An older daemon reports both side effects as a fixed false: say nothing rather than misreport them.
if (result.sideEffects.departingProcess) {
console.log(result.sideEffects.provenanceRecordWritten
@@ -460,6 +469,11 @@ when the current input contains a draft or cannot be read. It returns the exact
reason and held message ID immediately, without automatic retry.
Off (--enabled false) permits new automatic sends.
This works from an ordinary terminal as well as from a seat's shell. From a
managed seat's shell (OPENRIG_SESSION_NAME set), the change is recorded under
that seat. From a terminal with no seat identity, it applies the same way and is
recorded as "unattributed (no seat identity)".
Between off (always interrupt) and hold (full quiet):
openrig-urgent OpenRig's own messages (watchdogs, scheduled wakes, notices) and
anything marked urgent type; other agents' messages are held.
+42 -15
View File
@@ -1,3 +1,4 @@
import { isHumanSeatSessionRef } from "../session-name.js";
import { Command } from "commander";
import { resolveEffectiveHost } from "../host-selection.js";
import { DaemonClient, DaemonConnectionError, terminalAuthHeaders } from "../client.js";
@@ -90,6 +91,9 @@ export function wrapSendBody(
// renders EXACTLY AS RECEIVED — bare member@rig locally (copy-paste reply hint), and a
// cross-host caller constructs the origin triple at the forwarding boundary before wrapping.
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
// Only reply classification uses the bare address; From and the destination retain the origin host.
const senderParts = senderLabel.split("@");
const personAddress = senderParts.length === 3 && senderParts[2] ? senderParts.slice(0, 2).join("@") : senderLabel;
const header = [`From: ${senderLabel}`, renderToLine(recipient, meta?.scope)];
if (meta?.stampISO) {
// GHOST-STAGE (g): the sender's occupant generation rides the Sent: line as a short suffix
@@ -102,7 +106,9 @@ export function wrapSendBody(
}
const reply = senderLabel.endsWith("@external")
? `↩ Reply if needed: rig queue create --destination ${senderLabel}${meta?.replyToQitem ? ` --human-intent update --reply-to ${meta.replyToQitem}` : ""} --summary "<one line>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
: isHumanSeatSessionRef(personAddress)
? `↩ Reply if needed: rig queue create --destination ${senderLabel} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`
: `↩ Reply: rig send ${senderLabel} "..."`;
return [...header, "---", body, "---", reply].join("\n");
}
@@ -565,7 +571,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
const envelope = !effect
? res.data
: effectUnresolved(effect)
? { ...res.data, verified: false, outcome: "staged-not-consumed", effectCheck: effect }
? { ...res.data, verified: false, outcome: effectSubmissionUnconfirmed(effect) ? "submission-unconfirmed" : "staged-not-consumed", effectCheck: effect }
: { ...res.data, effectCheck: effect };
console.log(JSON.stringify(envelope));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
@@ -632,7 +638,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
* recheck, then stops. Pure classification: no printing here.
*/
type EffectCheck =
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused"; detail?: string }
| { checked: true; state: "staged"; remedy: "submitted-cleared" | "submitted-still-staged" | "submit-refused" | "submit-unconfirmed"; detail?: string }
| { checked: true; state: "no-staged-residual" }
| { checked: false; why: string };
@@ -683,16 +689,26 @@ async function classifyDeliveryEffect(
const probe = await detectStagedAtPrompt(client, session, identity);
if (probe.state === "unchecked") return { checked: false, why: probe.why };
if (probe.state === "not-staged") return { checked: true, state: "no-staged-residual" };
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session,
submitOnly: true,
expectedStagedText: identity.expectedStagedText,
expectedStagedLineCount: identity.expectedLines,
}, transportRequestOptions(waitForIdleMs));
} catch (err) {
return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: (err as Error).message };
}
if (enter.status >= 400) {
return { checked: true, state: "staged", remedy: "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
const phase = enter.data?.["inputPhase"];
const reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
return { checked: true, state: "staged", remedy: unconfirmed ? "submit-unconfirmed" : "submit-refused", detail: (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}` };
}
const recheck = await detectStagedAtPrompt(client, session, identity);
if (recheck.state === "unchecked") return { checked: true, state: "staged", remedy: "submit-unconfirmed", detail: recheck.why };
return recheck.state === "staged"
? { checked: true, state: "staged", remedy: "submitted-still-staged" }
: { checked: true, state: "staged", remedy: "submitted-cleared" };
@@ -705,13 +721,17 @@ function effectHumanLines(effect: EffectCheck, session: string): string[] {
return [`Note: the pane-effect check could not run (${effect.why}); the verdict above is transport-level only.`];
}
if (effect.state === "no-staged-residual") return [];
if (effect.remedy === "submit-unconfirmed") return [
`Delivery: submission unconfirmed — the text was staged before one guarded Enter attempt, but the result could not be confirmed (${effect.detail}). Inspect with: rig capture ${session}`,
];
if (effect.remedy === "submit-refused") return [
`Delivery: unconfirmed — the guarded Enter was refused (${effect.detail}); the earlier staged observation may have changed. Inspect with: rig capture ${session}`,
];
const out = ["Delivery: staged, not consumed (checked: post-send pane capture; observed: the sent text is still at the prompt — typed, never submitted)"];
if (effect.remedy === "submitted-cleared") {
out.push("Remedy: one guarded Enter submitted — the staged text left the prompt.");
} else if (effect.remedy === "submitted-still-staged") {
out.push(`Remedy: one guarded Enter submitted, but the text is STILL at the prompt — not consumed; stopping here (one submit is the contract). Inspect with: rig capture ${session}`);
} else {
out.push(`Remedy: the single guarded Enter (submit path) was refused (${effect.detail}); stopping here — one submit is the contract. Inspect with: rig capture ${session}`);
}
return out;
}
@@ -721,6 +741,10 @@ function effectUnresolved(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && effect.remedy !== "submitted-cleared";
}
function effectSubmissionUnconfirmed(effect: EffectCheck | undefined): boolean {
return !!effect && effect.checked && effect.state === "staged" && (effect.remedy === "submit-unconfirmed" || effect.remedy === "submit-refused");
}
/**
* S3 (OPR.0.5.4.6) — the staged-at-prompt detector, walk's staged-evidence
* primitive generalized to plain sends. POSITIVE evidence only: text rendered
@@ -754,7 +778,7 @@ async function detectStagedAtPrompt(
for (let i = lines.length - 1; i >= 0; i--) {
if (PROMPT_LINE_PATTERN.test(lines[i]!)) { lastPrompt = i; break; }
}
if (lastPrompt === -1) return { state: "not-staged" };
if (lastPrompt === -1) return { state: "unchecked", why: "no input prompt was visible in the capture" };
const inputRegionRaw = lines.slice(lastPrompt).join("\n").replace(PROMPT_LINE_PATTERN, "");
// IDENTITY FIRST (round-2 F2, desk-binding): the literal-residual match uses
// the daemon submit-precheck's own normalization (strip ALL whitespace,
@@ -1083,6 +1107,7 @@ async function runFanOutSend(params: {
}
const effectBySeat = new Map((effects ?? []).map((e) => [e.sessionName, e.effect]));
const unresolvedCount = (effects ?? []).filter((e) => effectUnresolved(e.effect)).length;
const unconfirmedCount = (effects ?? []).filter((e) => effectSubmissionUnconfirmed(e.effect)).length;
if (opts.json) {
// Round-2 F1: ONE verdict per recipient — a staged-unresolved recipient's
@@ -1090,7 +1115,7 @@ async function runFanOutSend(params: {
const rawResults = (res.data["results"] as Array<{ sessionName: string; ok: boolean; outcome?: string }> | undefined) ?? [];
const encodedResults = rawResults.map((r) =>
effectUnresolved(effectBySeat.get(r.sessionName))
? { ...r, ok: false, verified: false, outcome: "staged-not-consumed", error: "staged, not consumed (pane effect); the one guarded submit did not clear it" }
? { ...r, ok: false, verified: false, outcome: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "submission-unconfirmed" : "staged-not-consumed", error: effectSubmissionUnconfirmed(effectBySeat.get(r.sessionName)) ? "delivery unconfirmed; inspect the recipient after the guarded submit attempt" : "staged, not consumed (pane effect); the one guarded submit did not clear it" }
: r
);
// Round-3 (r2 row 00fb3a68): the machine-readable AGGREGATE derives from
@@ -1133,6 +1158,8 @@ async function runFanOutSend(params: {
if (ec && ec.checked && ec.state === "staged") {
if (ec.remedy === "submitted-cleared") {
console.log(`${r.sessionName}: sent — staged at the prompt, cleared by one guarded Enter (consumed)`);
} else if (effectSubmissionUnconfirmed(ec)) {
console.log(`${r.sessionName}: ${effectHumanLines(ec, r.sessionName)[0]}`);
} else {
console.log(`${r.sessionName}: staged, not consumed — ${effectHumanLines(ec, r.sessionName).slice(-1)[0]}`);
}
@@ -1145,7 +1172,7 @@ async function runFanOutSend(params: {
}
// The delivered count never includes a staged-unresolved recipient.
const deliveredCount = Math.max(0, ((data["sent"] as number) ?? 0) - unresolvedCount);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount > 0 ? `, ${unresolvedCount} staged-not-consumed` : ""}`);
console.log(`${deliveredCount}/${data["total"]} delivered${unresolvedCount - unconfirmedCount > 0 ? `, ${unresolvedCount - unconfirmedCount} staged-not-consumed` : ""}${unconfirmedCount > 0 ? `, ${unconfirmedCount} submission-unconfirmed` : ""}`);
// S2 (OPR.0.5.4.3): surface additive advisories (e.g. the unknown-sender
// sign-it notice) — an env-less operator must see it, not "sent" lines alone.
const fanoutAdvisory = data["warning"] as string | undefined;
+5
View File
@@ -251,6 +251,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
recovery?: { state?: string; reason?: string; lastScanAt?: string; acceptedThisProcess?: number; deadLetteredThisProcess?: number;
@@ -261,6 +262,10 @@ export function slackCommand(deps: SlackDeps = {}): Command {
backoff?: Record<string, number>; incomplete?: Record<string, number> };
limits?: string[] } } | undefined;
if (connector) {
if (connector.settlement) {
log(` Ask edits: ${connector.settlement.pending} queued for reconciliation`);
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
+21 -13
View File
@@ -157,21 +157,25 @@ export async function resolveLibrarySpec(
nameOrId: string,
opts?: { kind?: string },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
const filterKind = (entries: LibraryEntry[]) =>
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
? entries.filter((entry) => entry.kind === opts.kind)
: entries;
const visibleEntries = filterKind(res.data ?? []);
// An exact ID always wins, including when a visible name matches a hidden ID.
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
if (visibleId) return visibleId;
const separator = opts?.kind !== undefined ? "&" : "?";
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
const entries = filterKind(all.data ?? []);
const byId = entries.find((e) => e.id === nameOrId);
if (byId) return byId;
// Try name match
const byName = entries.filter((e) => e.name === nameOrId);
// Visible names take precedence over hidden built-ins with the same name.
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
@@ -230,12 +234,16 @@ Examples:
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const params = new URLSearchParams();
if (opts.kind !== undefined) params.set("kind", opts.kind);
if (opts.includeBuiltins) params.set("include_builtins", "true");
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
+1 -1
View File
@@ -104,7 +104,7 @@ export function streamCommand(depsOverride?: StreamDeps): Command {
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. dev-build@starter)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-destination <session>", "Hint at intended destination seat (the queue worker routes by this hint)")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
if (runtime === "terminal") return "builtin:terminal";
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
const entry = res.data?.find(
(candidate) =>
candidate.kind === "agent"
+26 -10
View File
@@ -273,7 +273,7 @@ initial record is explicitly reported as unverified delivery.`)
let verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); the walk cannot continue into a different generation.");
failPiece(i, piece.label, "the seat's generation rolled mid-walk (a re-prime); delivery is unconfirmed. Inspect the recipient before any resend; the walk cannot continue into a different generation.");
return;
}
if (verdict === "timeout") {
@@ -289,23 +289,39 @@ initial record is explicitly reported as unverified delivery.`)
/\[Pasted text #\d+ \+\d+ lines\]/.test(pane)
);
if (stagedEvidence) {
const enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
let enter: { status: number; data: Record<string, unknown> };
try {
enter = await client.post<Record<string, unknown>>("/api/transport/send", {
session: seat,
submitOnly: true,
expectedStagedText: piece.content, // FULL bytes — the transport checks the rendered literal residual for contiguous containment
expectedStagedLineCount: piece.content.split("\n").length,
}, { headers: terminalAuthHeaders() });
} catch (error) {
failPiece(i, piece.label, `the guarded submit result is unconfirmed (${(error as Error).message}); inspect the recipient before any resend.`);
return;
}
if (enter.status >= 400) {
failPiece(i, piece.label, `typed but not consumed; the single submit retry was refused (${(enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`}).`);
const phase = enter.data?.["inputPhase"], reason = enter.data?.["reason"];
const unconfirmed = phase === "attempted" || phase === "written"
|| (phase !== "not_reached" && enter.data?.["sent"] !== false && (reason === "submit_failed" || enter.status >= 500));
const detail = (enter.data?.["error"] as string | undefined) ?? `HTTP ${enter.status}`;
failPiece(i, piece.label, unconfirmed
? `the guarded submit result is unconfirmed (${detail}); inspect the recipient before any resend.`
: `the single guarded submit was refused (${detail}); the earlier staged observation may have changed. Inspect the recipient before any resend.`);
return;
}
verdict = await pollConsumed();
if (verdict === "generation-rolled") {
failPiece(i, piece.label, "the seat's generation rolled after the guarded submit; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
if (verdict !== "consumed") {
failPiece(i, piece.label, `typed and staged, but not consumed even after the single submit retry — the piece never entered the seat's conversation record. Not re-sending (one retry is the contract).`);
failPiece(i, piece.label, "no matching conversation record was observed within the verification window after the single submit retry; delivery is unconfirmed. Inspect the recipient before any resend.");
return;
}
} else {
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"} but the piece is neither consumed in the generation record nor staged in the pane — delivery lost; consumption verification fails this walk closed.`);
failPiece(i, piece.label, `send reported ${sendOutcome === "ok" ? "success" : "a transport error"}, but no matching record or staged input was observed within the verification window. Delivery and consumption are unconfirmed; inspect the recipient before any resend.`);
return;
}
}
+26
View File
@@ -13,6 +13,7 @@ import {
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
@@ -229,6 +230,10 @@ export interface RiggedConfig {
wakeSwapGraceSeconds: number;
wakeHumanRung: string;
};
// Issue #1856 — stream intake setting
stream: {
intake: string;
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
@@ -283,6 +288,7 @@ const DEFAULTS = {
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
specs: { builtinsVisible: true },
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
onboarding: { defaultPack: { enabled: true } },
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
@@ -388,6 +394,10 @@ const DEFAULTS = {
consumers: "",
templateDir: "",
},
// Issue #1856 — stream intake default queue.worker; read live by the stream intake coordinator.
stream: {
intake: "queue.worker",
},
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
retention: {
enabled: true,
@@ -454,6 +464,7 @@ export const VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
@@ -537,6 +548,7 @@ export const VALID_KEYS = [
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
"retention.stream_days",
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
// S04 — pickup-receipt stall threshold; lockstep with the daemon settings-store twin.
"queue.pickup_stall_threshold_minutes",
@@ -581,6 +593,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
@@ -652,6 +665,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
"stream.intake": { primary: "OPENRIG_STREAM_INTAKE" },
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
"queue.pickup_stall_threshold_minutes": { primary: "OPENRIG_QUEUE_PICKUP_STALL_THRESHOLD_MINUTES" },
"queue.stuck_sweep_interval_seconds": { primary: "OPENRIG_QUEUE_STUCK_SWEEP_INTERVAL_SECONDS" },
@@ -688,6 +702,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
@@ -750,6 +765,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
"retention.stream_days": ["retention", "streamDays"],
"retention.stream_max_items": ["retention", "streamMaxItems"],
"stream.intake": ["stream", "intake"],
"retention.batch_size": ["retention", "batchSize"],
"queue.pickup_stall_threshold_minutes": ["queue", "pickupStallThresholdMinutes"],
"queue.stuck_sweep_interval_seconds": ["queue", "stuckSweepIntervalSeconds"],
@@ -1113,6 +1129,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1259,6 +1281,7 @@ export class ConfigStore {
root: v("context.root") as string,
systemWorld: v("context.system_world") as string,
},
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
skills: {
root: v("skills.root") as string,
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
@@ -1385,6 +1408,9 @@ export class ConfigStore {
wakeSwapGraceSeconds: v("queue.wake_swap_grace_seconds") as number,
wakeHumanRung: v("queue.wake_human_rung") as string,
},
stream: {
intake: v("stream.intake") as string,
},
};
}
+4 -1
View File
@@ -31,7 +31,10 @@ export type FindingKind =
// SPEC.md compatibility — a node carrying BOTH authored files. Advisory by
// construction (low severity never flips the exit code): SPEC.md wins, and the
// shadowed README.md is a state to notice, never a failure to gate on.
| "shadowed_node_file";
| "shadowed_node_file"
// Another mission has a slice folder of the same name, which the slice list and the name-only routes
// hide. Advisory (low severity): renaming is the person's call.
| "slice_name_shared";
export interface AuditFinding {
kind: FindingKind;
+36 -52
View File
@@ -362,31 +362,23 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
const hasReadme = readmePath !== null;
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
out.push(withSliceCounts({ name: entry.name, absPath, readmePath, frontmatter, id }));
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
/** The mission with each of its slices counted once, by its standing (the same one `slice ls` filters on). */
function withSliceCounts(base: Omit<MissionInfo, "activeSliceCount" | "shippedSliceCount" | "closedSliceCount">): MissionInfo {
const mission: MissionInfo = { ...base, activeSliceCount: 0, shippedSliceCount: 0, closedSliceCount: 0 };
for (const { standing } of slicesWithStanding(mission)) {
if (standing === "active") mission.activeSliceCount++;
else if (standing === "shipped") mission.shippedSliceCount++;
else mission.closedSliceCount++;
}
return mission;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
@@ -425,15 +417,7 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = resolveNodeFile(absPath);
const frontmatter = readmePath ? readFrontmatter(readmePath) : {};
return {
name: path.basename(absPath),
absPath,
readmePath,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
return withSliceCounts({ name: path.basename(absPath), absPath, readmePath, frontmatter, id: pickIdFromFrontmatter(frontmatter) });
}
// ---------------------------------------------------------------------
@@ -444,35 +428,35 @@ export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
return slicesWithStanding(mission)
.filter(({ standing }) => state === "all" || standing === state)
.map(({ slice }) => slice);
}
/** Where a slice stands. In closed/, it's closed. In place: shipped (or done) is delivered; a closed or superseded
* status, or a superseded or retired stage, is an exit, so closed; anything else, blocked work included, is active. */
export function sliceStanding(slice: SliceInfo, inClosedFolder: boolean): Exclude<SliceState, "all"> {
if (inClosedFolder) return "closed";
const status = (slice.status ?? "").toLowerCase();
const stage = typeof slice.frontmatter.stage === "string" ? slice.frontmatter.stage.toLowerCase() : "";
if (status === "done" || status.startsWith("shipped")) return "shipped";
if (status.startsWith("closed") || status === "superseded" || stage === "superseded" || stage === "retired") return "closed";
return "active";
}
/** Every slice in the mission (slices/ and closed/), in order, each with its standing. */
function slicesWithStanding(mission: MissionInfo): Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> {
const out: Array<{ slice: SliceInfo; standing: Exclude<SliceState, "all"> }> = [];
for (const [folder, inClosedFolder] of [["slices", false], ["closed", true]] as const) {
const root = path.join(mission.absPath, folder);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
if (!entry.isDirectory() || !SLICE_DIRNAME_RE.test(entry.name)) continue;
const slice = buildSliceInfo(mission, root, entry.name);
out.push({ slice, standing: sliceStanding(slice, inClosedFolder) });
}
}
out.sort((a, b) => (a.nn ?? Number.MAX_SAFE_INTEGER) - (b.nn ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name));
if (state === "active") {
// active = not in closed/, and not already delivered in-place.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return st !== "done" && !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
out.sort((a, b) => (a.slice.nn ?? Number.MAX_SAFE_INTEGER) - (b.slice.nn ?? Number.MAX_SAFE_INTEGER) || a.slice.name.localeCompare(b.slice.name));
return out;
}
+3
View File
@@ -30,7 +30,10 @@ export interface MissionInfo {
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
/** Slices in flight (blocked included); delivered in place; and closed (closed/, or an exit in place). Each slice is
* counted once, by the standing `slice ls --state` filters on. */
activeSliceCount: number;
shippedSliceCount: number;
closedSliceCount: number;
}
@@ -7,6 +7,8 @@ export interface SeatLaunchAdditionsView {
agentPromptReplacesDefault?: boolean;
appendSystemPromptFile?: string;
pluginDirs: string[];
/** #2044: absent from a daemon before it, and when the seat has none. */
pluginChannels?: string[];
missing: string[];
refused: string[];
}
@@ -17,6 +19,7 @@ export function seatLaunchAdditionLines(additions: SeatLaunchAdditionsView | nul
...(additions.agent ? [`agent ${additions.agent} (--agent${additions.agentPromptReplacesDefault ? "; its prompt replaces Claude's default system prompt" : ""})`] : []),
...(additions.appendSystemPromptFile ? [`prompt ${additions.appendSystemPromptFile} (--append-system-prompt-file)`] : []),
...additions.pluginDirs.map((dir) => `plugin dir ${dir} (CLAUDE_CODE_PLUGIN_DIRS)`),
...(additions.pluginChannels ?? []).map((entry) => `channel ${entry} (--channels)`),
...additions.missing.map((file) => `missing ${file}: not passed to the launch`),
...additions.refused.map((reason) => `refused ${reason}: not passed to the launch`),
];
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
// The responder reads relative paths from a different root, like an already-running daemon.
server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/specs/library") {
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
catalogReads++;
res.end(JSON.stringify(catalog));
return;
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
expect(requests[0]?.content).toBe("name: daemon\n");
});
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "unlisted", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe("unlisted");
});
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
await run("bootstrap", "catalog-rig", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests).toEqual([]);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/specs/library") {
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
} else if (url.includes("/api/bootstrap")) {
@@ -138,6 +138,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
@@ -211,6 +212,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"retention.watchdog_keep_per_job",
"retention.stream_days", // slice 22 outcome 9: stream retention
"retention.stream_max_items",
"stream.intake",
"retention.batch_size",
"queue.pickup_stall_threshold_minutes",
// S02 — standing-stuck-sweep cadence + unclaimed-obligation age.
@@ -234,6 +236,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(() => store.set("daemon.db_conflict", "always")).toThrow(/warn or stop/);
});
it("defaults stream.intake to queue.worker and validates destinations", () => {
const store = new ConfigStore(configPath);
expect(store.get("stream.intake")).toBe("queue.worker");
store.set("stream.intake", "off");
expect(store.get("stream.intake")).toBe("off");
store.set("stream.intake", "lead@kernel");
expect(store.get("stream.intake")).toBe("lead@kernel");
expect(() => store.set("stream.intake", "invalid-bare-seat")).toThrow(/Invalid value for stream.intake/);
});
it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => {
const store = new ConfigStore(configPath);
expect(store.resolveWithSource("context.root")).toMatchObject({
+8
View File
@@ -63,6 +63,14 @@ describe("ConfigStore", () => {
});
// Test 1
it("persists the built-in inventory preference as a validated boolean", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
store.set("specs.builtins_visible", "false");
expect(store.resolve().specs.builtinsVisible).toBe(false);
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
});
it("resolve() returns defaults when no config file and no env", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
const config = store.resolve();
+6 -1
View File
@@ -484,12 +484,17 @@ describe("PUBLIC bin ownership (guard finding 1 — the wrapper is the real fron
// "PRODUCTION PATH … REAL probeFrontDoor" test earlier, with no real TUI spawned.)
it.skipIf(!existsSync(binWrapper))("piped PUBLIC bin keeps the commander usage baseline (no TUI, fast exit)", () => {
const started = Date.now();
const result = spawnSync(process.execPath, [binWrapper], {
input: "x\n",
timeout: 5000,
encoding: "utf-8",
});
expect(result.status).toBe(0);
expect(result.status, JSON.stringify({
elapsedMs: Date.now() - started, status: result.status, signal: result.signal,
error: result.error && { name: result.error.name, message: result.error.message, code: (result.error as NodeJS.ErrnoException).code },
stdout: result.stdout?.slice(-4096), stderr: result.stderr?.slice(-4096),
})).toBe(0);
expect(`${result.stderr}${result.stdout}`).toMatch(/Usage: rig/);
});
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -6,6 +6,7 @@ import { createProgram } from "../src/index.js";
// The registry module is home-state owned by the daemon; the verb (and this test) reach
// it via the narrow @openrig/daemon/gateway-human-registry subpath (the C3/crash-cart rail).
import { humansDir, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
import { gatewayRoutes } from "../../daemon/src/routes/gateway.js";
// M1 A3 pt2 / A4b relocate — `rig gateway human add` verb integration. The verb lazy-imports
// the relocated daemon surface; these tests drive the real command end-to-end.
@@ -19,10 +20,24 @@ describe("rig gateway human add verb (post-relocate)", () => {
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
if (prevHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
it("participants --json and the daemon route expose the same filtered view", async () => {
const add = createProgram(); add.exitOverride();
await add.parseAsync(["node", "rig", "gateway", "human", "add", "mike", "--display-name", "Mike", "--delivery-class", "B"]);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const command = createProgram(); command.exitOverride();
await command.parseAsync(["node", "rig", "gateway", "participants", "--organization", "missing", "--json"]);
const cli = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
const response = await gatewayRoutes({ home }).request("/participants?organization=missing");
expect(response.status).toBe(200);
expect(await response.json()).toEqual(cli);
expect(cli).toMatchObject({ ok: true, participants: [], default: { id: "mike", source: "only-person" } });
});
it("is wired via createProgram (gateway human add)", () => {
const gw = createProgram().commands.find((c) => c.name() === "gateway");
expect(gw).toBeDefined();
@@ -0,0 +1,51 @@
// `rig host ls` status for an ssh host dials where ssh itself would connect (`ssh -G`), so an
// ssh-config alias or a non-22 port isn't reported unreachable. A stand-in `ssh` on PATH plays
// `ssh -G`; no real ssh runs.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { probeHostStatus } from "../src/commands/host.js";
let dir: string;
let savedPath: string | undefined;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "host-status-ssh-"));
writeFileSync(join(dir, "ssh"), `#!/bin/sh
case "$*" in
*-G*devbox*) printf 'user ops\\nhostname 10.1.2.3\\nport 2222\\n' ;;
*-G*jumpy*) printf 'hostname jumpy\\nport 22\\nproxyjump bastion\\n' ;;
*) exit 255 ;;
esac
`);
chmodSync(join(dir, "ssh"), 0o755);
savedPath = process.env["PATH"];
process.env["PATH"] = `${dir}:${savedPath}`;
});
afterEach(() => {
process.env["PATH"] = savedPath;
rmSync(dir, { recursive: true, force: true });
});
describe("rig host ls status for an ssh host", () => {
it("dials the host and port ssh resolves for an ssh-config alias", async () => {
const dials: string[] = [];
const status = await probeHostStatus({ id: "devbox", transport: "ssh", target: "devbox" }, async (target, port) => {
dials.push(`${target}:${port}`);
return "open";
});
expect(status).toBe("reachable");
expect(dials).toEqual(["10.1.2.3:2222"]);
});
it.each([["a ProxyJump host", "jumpy"], ["a target ssh can't resolve", "broken"]])(
"reads unknown for %s, since a direct dial can't stand for the transport", async (_label, target) => {
const dials: string[] = [];
const status = await probeHostStatus({ id: target, transport: "ssh", target }, async (t, port) => {
dials.push(`${t}:${port}`);
return "closed";
});
expect(status).toBe("unknown");
expect(dials).toEqual([]);
});
});
+32 -3
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import os from "node:os";
import { PassThrough } from "node:stream";
import type { QueueDeps } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { resolveQueueBody, previewBody, previewCutMarker, waitForDeliveryOutcome } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
/**
@@ -1204,6 +1204,17 @@ describe("rig queue CLI", () => {
});
});
it("update forwards an explicitly accepted closure deadline without a state change", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "update", "qitem-estimate", "--closure-required-at", "2026-11-04T10:00:00.000Z", "--note", "Coordinator accepted the revised estimate", "--json"]);
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toMatchObject({
closureRequiredAt: "2026-11-04T10:00:00.000Z", transitionNote: "Coordinator accepted the revised estimate",
});
expect(calls.find(c => c.path === "/api/queue/qitem-estimate/update")?.body).toHaveProperty("state", undefined);
});
it("update forwards explicit reopen acknowledgment", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
@@ -1849,7 +1860,8 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
expect(Array.from(printed.body).length).toBe(512);
// Keep the first 512 code points, then mark the cut inside the body.
expect(printed.body).toBe(`${"A".repeat(512)}…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]`);
// #1806: stdout is the record alone and parses as JSON; the marker line, with the honest total byte size, is on stderr
expect(JSON.parse(logs.join("\n"))).toMatchObject({ qitemId: "qitem-1", bodyTruncated: true });
expect(errors.join("\n")).toContain("bounded preview — complete body is 1000 bytes; full record");
@@ -1892,7 +1904,24 @@ describe("rig queue CLI", () => {
const printed = JSON.parse(logs[0]);
expect(printed.bodyTruncated).toBe(true);
expect(printed.bodyBytes).toBe(1000);
expect(Array.from(printed.body).length).toBe(PREVIEW_MAX_CODEPOINTS);
const marker = "…[cut at 512 of 1000 chars; rig queue show 'qitem-1' --full]";
expect(printed.body.endsWith(marker)).toBe(true);
expect(Array.from(printed.body.slice(0, -marker.length)).length).toBe(PREVIEW_MAX_CODEPOINTS);
});
it("show default: a multibyte body's marker counts code points, not bytes", async () => {
const full = "😀".repeat(600); // 600 code points, 2400 bytes
const { deps } = showRoute({ qitemId: "qitem-1", body: full });
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "queue", "show", "qitem-1", "--json"]);
const printed = JSON.parse(logs[0]);
expect(printed.bodyBytes).toBe(2400);
expect(printed.body).toBe(`${"😀".repeat(512)}…[cut at 512 of 600 chars; rig queue show 'qitem-1' --full]`);
});
it("previewCutMarker quotes the row id for the shell", () => {
expect(previewCutMarker("qitem-it's", 512, 3983)).toBe(`…[cut at 512 of 3983 chars; rig queue show 'qitem-it'\\''s' --full]`);
});
it("show --full --json: byte-identical COMPLETE item body (compatibility contract — no preview fields)", async () => {
@@ -0,0 +1,43 @@
// The CLI twin of the daemon's slice_name_shared advisory: two missions with a slice folder of the same
// name leave one of them out of the slice list and the routes that find a slice by name. Advisory only.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
let workspace: string;
let missionsRoot: string;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cli-slice-name-audit-"));
missionsRoot = path.join(workspace, "missions");
for (const mission of ["alpha", "beta"]) {
fs.mkdirSync(path.join(missionsRoot, mission, "slices", "01-login"), { recursive: true });
fs.writeFileSync(path.join(missionsRoot, mission, "SPEC.md"), "# mission\n");
fs.writeFileSync(path.join(missionsRoot, mission, "slices", "01-login", "SPEC.md"), "# login\n");
}
});
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
async function audit(mission: string): Promise<{ slices: Array<{ name: string; findings: Array<{ kind: string; severity: string; message: string }> }> }> {
const buf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((c: unknown) => { buf.push(String(c)); return true; }) as typeof process.stdout.write;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", "audit", "--mission", mission, "--json", "--workspace", workspace]);
} finally {
process.stdout.write = origWrite;
}
return JSON.parse(buf.join(""));
}
describe("rig scope audit — a slice name another mission also uses", () => {
it("names the other mission, as advice that blocks nothing", async () => {
const shared = (await audit("alpha")).slices.find((s) => s.name === "01-login")!.findings.find((f) => f.kind === "slice_name_shared");
expect(shared).toMatchObject({ severity: "low", message: expect.stringContaining("beta") });
});
});
+11
View File
@@ -678,6 +678,17 @@ describe("rig scope mission ls (HG-8)", () => {
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
it("counts a mission's shipped slices apart from its active ones (#2427)", async () => {
for (const nn of ["02-shipped-a", "03-shipped-b"]) {
writeFile(path.join(env.missionsRoot, "release-0.3.2", "slices", nn, "README.md"), `---\nid: OPR.0.3.2.${nn[1]}\nstatus: shipped\n---\n# ${nn}\n`);
}
const text = await run(["mission", "ls"], env.missionsRoot);
expect(text.stdout).toContain("release-0.3.2 OPR.0.3.2 active=1 shipped=2 closed=0");
const json = JSON.parse((await run(["mission", "ls", "--json"], env.missionsRoot)).stdout);
expect(json.missions.find((m: { name: string }) => m.name === "release-0.3.2"))
.toMatchObject({ activeSliceCount: 1, shippedSliceCount: 2, closedSliceCount: 0 });
});
});
describe("rig scope mission show", () => {
+38
View File
@@ -326,6 +326,44 @@ describe("listMissions + listSlices + nextSliceNN", () => {
});
});
describe("a slice's standing: in flight, delivered in place, or closed (#2427)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
writeFile(path.join(missionsRoot, "m1", "README.md"), "---\nid: OPR.9.1\n---\n# m1\n");
const slice = (dir: string, frontmatter: string) => writeFile(path.join(missionsRoot, "m1", dir, "README.md"), `---\n${frontmatter}\n---\nbody\n`);
slice("slices/01-open", "id: OPR.9.1.1\nstatus: active");
slice("slices/02-blocked", "id: OPR.9.1.2\nstatus: blocked");
slice("slices/03-shipped", "id: OPR.9.1.3\nstatus: shipped");
slice("slices/04-done", "id: OPR.9.1.4\nstatus: done");
slice("slices/05-replaced", "id: OPR.9.1.5\nstatus: active\nstage: superseded\nsuperseded-by: OPR.9.1.9");
slice("slices/06-superseded", "id: OPR.9.1.6\nstatus: superseded");
slice("closed/07-stale", "id: OPR.9.1.7\nstatus: closed-stale");
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("slice ls: active is work in flight (blocked included); shipped is delivered in place; closed includes exits in place", () => {
const mission = findMission(missionsRoot, "m1");
const names = (state: "active" | "shipped" | "closed" | "all") => listSlices(mission, state).map((s) => s.name);
expect(names("active")).toEqual(["01-open", "02-blocked"]);
expect(names("shipped")).toEqual(["03-shipped", "04-done"]);
expect(names("closed")).toEqual(["05-replaced", "06-superseded", "07-stale"]);
expect(names("all")).toHaveLength(7);
});
it("mission counts give each slice one standing, the same as slice ls", () => {
const counts = { activeSliceCount: 2, shippedSliceCount: 2, closedSliceCount: 3 };
expect(listMissions(missionsRoot)[0]).toMatchObject(counts);
expect(findMission(missionsRoot, "m1")).toMatchObject(counts);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
@@ -481,7 +481,7 @@ describe("fresh launch preview", () => {
it("OPR.0.7.0.9: the printed plan shows the seat's launch additions, as the seat view does", async () => {
const launchAdditions = { agent: "openrig:reviewer", agentPromptReplacesDefault: true, appendSystemPromptFile: "/s/additions/system-prompt.md",
pluginDirs: ["/s/additions/plugins/review"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
pluginDirs: ["/s/additions/plugins/review"], pluginChannels: ["plugin:example-channel@example"], missing: ["/s/gone"], refused: ["/s/linked is not inside this seat's storage"] };
const data = { ok: true, planOnly: true, complete: true, launchable: true, sessionName: "dev@fixture", currentOccupant: "absent",
startup: { projectionCount: 0, fileCount: 0, actionCount: 0 }, stopRequired: false, wouldStop: false, blockers: [], warnings: [], siblings: [],
launchAdditions };
@@ -491,6 +491,7 @@ describe("fresh launch preview", () => {
expect(text).toContain("agent openrig:reviewer (--agent; its prompt replaces Claude's default system prompt)");
expect(text).toContain("prompt /s/additions/system-prompt.md (--append-system-prompt-file)");
expect(text).toContain("plugin dir /s/additions/plugins/review (CLAUDE_CODE_PLUGIN_DIRS)");
expect(text).toContain("channel plugin:example-channel@example (--channels)");
expect(text).toContain("missing /s/gone: not passed to the launch");
expect(text).toContain("refused /s/linked is not inside this seat's storage: not passed to the launch");
});
+13
View File
@@ -793,6 +793,19 @@ describe("rig seat status", () => {
expect(output).not.toContain("restore packet) was delivered");
});
it("handover human output says the successor started without a required file, not a plain complete (#2456)", async () => {
const missing = "/project/spec/culture.md";
const deps = makeDeps({ status: 200, data: { ...HANDOVER_RESULT_FRESH, startedWithoutRequired: [missing] } }, []);
const { logs, exitCode } = await captureLogs(async () => {
await makeCommand(deps).parseAsync(["node", "rig", "seat", "handover", "dev-impl@seat-rig", "--source", "fresh", "--reason", "context-wall"]);
});
const output = logs.join("\n");
expect(exitCode).toBeUndefined(); // the successor is running, so the handover itself completed
expect(logs[0]).toBe("Seat handover complete, degraded: dev-impl@seat-rig");
expect(output).toContain(`The successor started without required file ${missing}.`);
expect(output).not.toContain("Seat handover complete: dev-impl@seat-rig");
});
it("handover surfaces a loud step-named failure (successor_create_failed)", async () => {
const deps = makeDeps({
status: 500,
+41
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { wrapSendBody } from "../src/commands/send.js";
import { resolveQueueHostDestination } from "../src/commands/queue.js";
describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)", () => {
it("renders From / To / body / reply hint with both session names", () => {
@@ -10,6 +11,46 @@ describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)"
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it.each(["human@kernel", "human@host", "human-alice@kernel", "human-alice@host"])(
"gives %s a queue update reply with the existing human-route fields",
(sender) => {
const body = "Please check this.\nKeep the actor unchanged.";
const out = wrapSendBody(sender, "driver@rig", body, { replyToQitem: "qitem-original" });
expect(out).toContain(`From: ${sender}\nTo: driver@rig\n`);
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe(`↩ Reply if needed: rig queue create --destination ${sender} --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`);
expect(out).not.toContain(`rig send ${sender}`);
expect(out).not.toContain("--reply-to"); // Slack-thread guidance belongs to registered external addresses.
},
);
it.each(["human@kernel@host-origin", "human-reviewer@host@host-origin", "alex@external@host-origin"])(
"a forwarded person %s gets a queue hint retaining the origin host",
(sender) => {
const body = "Original body\nkept verbatim.";
expect(resolveQueueHostDestination(sender)).toEqual({ ok: true, destination: sender.slice(0, sender.lastIndexOf("@")), hostId: "host-origin" });
const out = wrapSendBody(sender, "driver@receiver", body);
expect(out).toContain("From: " + sender + "\nTo: driver@receiver\n");
expect(out.split("\n---\n")[1]).toBe(body);
expect(out.split("\n").at(-1)).toBe("↩ Reply if needed: rig queue create --destination " + sender + ' --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify');
},
);
it("keeps host-qualified look-alike seats and malformed human triples on the ordinary hint", () => {
for (const sender of ["humanity@kernel@host-origin", "human@real-rig@host-origin", "dev-human@host@host-origin",
"human@kernel@", "human@kernel@host-origin@extra"]) {
expect(wrapSendBody(sender, "driver@receiver", "hi").split("\n").at(-1))
.toBe("↩ Reply: rig send " + sender + ' "..."');
}
});
it("keeps similarly named real seats on the ordinary reply route", () => {
for (const sender of ["human@my-rig", "humanity@kernel", "dev-human@host", "dev-human@rig@origin-host"]) {
expect(wrapSendBody(sender, "driver@rig", "hi").split("\n").at(-1))
.toBe(`↩ Reply: rig send ${sender} "..."`);
}
});
it("routes an external sender's reply through the durable human queue", () => {
const out = wrapSendBody("decision-maker@external", "driver@rig", "Decision received.");
expect(out).toContain("From: decision-maker@external");
+179
View File
@@ -285,6 +285,185 @@ describe("Send CLI", () => {
expect(output).toContain("Note: sent as exact text, without the From/To envelope, because infra-ui@term-rig is a terminal seat.");
});
it.each([
{ json: false, missingPrompt: false }, { json: true, missingPrompt: false },
{ json: false, missingPrompt: true }, { json: true, missingPrompt: true },
])("keeps the send unresolved when the post-submit capture is unusable ($json, $missingPrompt)", async ({ json, missingPrompt }) => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: missingPrompt ? { status: 200, data: { content: "redrawing" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { state: "staged", remedy: "submit-unconfirmed" } });
} else {
expect(logs.join("\n")).toContain("submission unconfirmed");
expect(logs.join("\n")).not.toContain("staged text left the prompt");
}
expect(exitCode).toBe(1);
});
it.each([false, true])("reports a lost guarded Enter response as unconfirmed (json: %s)", async json => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") { captures++; return { status: 200, data: { content: "❯ make test" } }; }
if (body["submitOnly"]) { submits++; throw new Error("Enter response lost"); }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(captures).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([false, true])("reports a failed guarded Enter after attempted input as unconfirmed (json: %s)", async json => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) { submits++; return { status: 500, data: { ok: false, reason: "submit_failed", inputPhase: "attempted", error: "submission unconfirmed" } }; }
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", ...(json ? ["--json"] : []),
]);
});
expect(submits).toBe(1);
expect(exitCode).toBe(1);
if (json) {
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result).toMatchObject({ verified: false, outcome: "submission-unconfirmed", effectCheck: { remedy: "submit-unconfirmed" } });
} else expect(logs.join("\n")).toContain("submission unconfirmed");
});
it.each([
[{ reason: "submit_failed", error: "older daemon" }, "submit-unconfirmed"],
[{ reason: "staged_mismatch", sent: false, inputPhase: "not_reached", error: "changed" }, "submit-refused"],
] as const)("classifies guarded Enter failure %j as %s", async (failure, remedy) => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 500, data: { ok: false, ...failure } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(result.effectCheck.remedy).toBe(remedy);
expect(result.outcome).toBe("submission-unconfirmed");
});
it("does not claim the earlier staged text is still unconsumed after a refused Enter", async () => {
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/capture") return { status: 200, data: { content: "❯ make test" } };
if (body["submitOnly"]) return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" } };
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify",
]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("guarded Enter was refused");
expect(logs.join("\n")).not.toContain("typed, never submitted");
});
it("reports a fan-out refusal as unconfirmed alongside a successful recipient", async () => {
let submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 2, sent: 2, failed: 0, results: [
{ sessionName: "seat-a@rig", ok: true, outcome: "delivered" },
{ sessionName: "seat-b@rig", ok: true, outcome: "delivered" },
],
} };
if (path === "/api/transport/capture") return { status: 200, data: {
content: body["session"] === "seat-a@rig" ? "❯ make test" : "❯ ",
} };
if (body["submitOnly"]) {
submits++;
return { status: 409, data: { ok: false, reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "pane changed" } };
}
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "seat-a@rig,seat-b@rig", "make test", "--verify",
]);
});
const output = logs.join("\n");
expect(output).toContain("seat-a@rig: Delivery: unconfirmed");
expect(output).toContain("guarded Enter was refused (pane changed)");
expect(output).not.toContain("staged, not consumed");
expect(output).toContain("seat-b@rig: sent");
expect(output).toContain("1/2 delivered, 1 submission-unconfirmed");
expect(submits).toBe(1);
expect(exitCode).toBe(1);
});
it("reports an unconfirmed post-submit recheck per recipient in fan-out JSON", async () => {
let captures = 0, submits = 0;
const client = {
post: async (path: string, body: Record<string, unknown>) => {
if (path === "/api/transport/broadcast") return { status: 200, data: {
total: 1, sent: 1, failed: 0, results: [{ sessionName: "infra-ui@term-rig", ok: true, verified: true, outcome: "delivered" }],
} };
if (path === "/api/transport/capture") {
captures++;
return captures === 1 ? { status: 200, data: { content: "❯ make test" } }
: { status: 503, data: { error: "capture unavailable" } };
}
if (body["submitOnly"]) { submits++; return { status: 200, data: { ok: true } }; }
throw new Error("unexpected request");
},
} as unknown as DaemonClient;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
"node", "rig", "send", "--to", "infra-ui@term-rig", "make test", "--verify", "--json",
]);
});
const result = JSON.parse(logs.find(line => line.trim().startsWith("{")) ?? "{}");
expect(submits).toBe(1);
expect(result).toMatchObject({ sent: 0, failed: 1, results: [{ ok: false, verified: false, outcome: "submission-unconfirmed" }] });
expect(exitCode).toBe(1);
});
it("--raw sends no exactText", async () => {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "/help", "--raw"]);
+17
View File
@@ -7,6 +7,23 @@ const clientFor = (entries: object[]) => ({ get: vi.fn(async () => ({ status: 20
afterEach(() => vi.restoreAllMocks());
describe("first-project compatibility alias", () => {
it("keeps same-name user and built-in specs ambiguous when both are visible", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
await expect(resolveLibrarySpec(clientFor([starter, user]), "starter", { kind: "rig" })).rejects.toThrow("ambiguous");
});
it("finds an explicitly requested hidden built-in without shadowing a visible user spec", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
const client = { get } as unknown as DaemonClient;
expect(await resolveLibrarySpec(client, "starter", { kind: "rig" })).toEqual(user);
expect(await resolveLibrarySpec(client, "starter-id", { kind: "rig" })).toEqual(starter);
expect(get).toHaveBeenCalledWith("/api/specs/library?kind=rig&include_builtins=true");
});
it("prefers a hidden exact ID over a visible spec with that name", async () => {
const user = { ...starter, id: "user-id", name: "starter-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
expect(await resolveLibrarySpec({ get } as unknown as DaemonClient, "starter-id")).toEqual(starter);
});
it("resolves the built-in starter without creating a second library entry, with a stderr notice", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
+20 -1
View File
@@ -60,6 +60,7 @@ const RIG_REVIEW = {
describe("Specs CLI", () => {
let server: http.Server;
let port: number;
let lastLibraryUrl = "";
const libraryRequests: string[] = [];
beforeAll(async () => {
@@ -68,6 +69,7 @@ describe("Specs CLI", () => {
libraryRequests.push(rawUrl);
const url = decodeURIComponent(rawUrl);
if (url === "/api/specs/library" || url.startsWith("/api/specs/library?")) {
lastLibraryUrl = url;
res.writeHead(200, { "Content-Type": "application/json" });
const kind = new URL(`http://localhost${url}`).searchParams.get("kind");
const filtered = kind ? LIBRARY_ENTRIES.filter((e) => e.kind === kind) : LIBRARY_ENTRIES;
@@ -137,6 +139,13 @@ describe("Specs CLI", () => {
expect(parsed.length).toBe(2);
});
it("requests hidden built-ins only when explicitly asked", async () => {
await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--include-builtins", "--kind", "rig"]); });
const params = new URL(`http://localhost${lastLibraryUrl}`).searchParams;
expect(params.get("kind")).toBe("rig");
expect(params.get("include_builtins")).toBe("true");
});
it("specs ls --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
@@ -165,6 +174,16 @@ describe("Specs CLI", () => {
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=rig%23draft");
});
it("filters an unknown kind even when built-ins are requested", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "rig&kind=agent", "--include-builtins", "--json"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind");
expect(JSON.parse(stdout.join("\n"))).toEqual([]);
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("kind")).toBe("rig&kind=agent");
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("include_builtins")).toBe("true");
});
it.each(["rig", "agent", "workflow"])("specs ls --kind %s runs without warning", async (kind) => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
@@ -231,7 +250,7 @@ describe("Specs CLI", () => {
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
if (url.startsWith("/api/specs/library?kind=rig")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { resolveDefaultAgentRef } from "../src/commands/topology-default-agent.js";
describe("default agent lookup", () => {
it("finds the built-in orchestrator when inventory hides built-ins", async () => {
const client = {
get: async (url: string) => ({
data: url.includes("include_builtins=true")
? [{ kind: "agent", name: "orchestrator", sourceType: "builtin", sourcePath: "/builtin/orchestrator/agent.yaml" }]
: [],
}),
} as unknown as DaemonClient;
expect(await resolveDefaultAgentRef(client, "codex")).toBe("path:/builtin/orchestrator");
});
});
+1 -1
View File
@@ -1408,7 +1408,7 @@ describe("Up CLI", () => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
if (url.startsWith("/api/specs/library?kind=rig")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
+64
View File
@@ -320,6 +320,22 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(succeeds);
});
it("reports a lost guarded submit response as unconfirmed without retrying", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return "throw";
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain("unconfirmed");
});
it.each(["during-send", "between-pieces"])("generation changes %s cannot produce a verified walk", async when => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = b => {
@@ -333,6 +349,7 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(result.exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(result.logs.some(l => l.includes('"consumptionVerified":true'))).toBe(false);
if (when === "during-send") expect(result.errLogs.join("\n")).toMatch(/delivery is unconfirmed/i);
});
it("PIN W1 — a typed-but-never-consumed piece FAILS LOUD naming the piece; the next piece is never sent [GREEN — consumption verification]", async () => {
@@ -348,6 +365,31 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(errLogs.join("\n")).toMatch(/consum/i); // names the failure class
});
it("keeps delivery unknown when the send times out and neither record nor pane proves its effect", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [], sendBehavior: () => "throw" };
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send")).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/delivery lost/i);
expect(errLogs.join("\n")).toMatch(/before.*resend/i);
});
it("reports a generation change after the guarded Enter without claiming the piece was lost", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = body => {
if (body["text"] !== undefined) w.pane = `❯ ${String(body["text"]).slice(0, 40)}`;
else w.record.generationId = "g2";
return { status: 200, data: { ok: true } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toMatch(/generation rolled.*unconfirmed/i);
expect(errLogs.join("\n")).not.toMatch(/never entered|delivery lost/i);
});
it("PIN W2 — staged text detected -> exactly ONE submit retry (bare Enter, never a piece re-send), then consumed -> walk proceeds [GREEN — the retry path]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
@@ -372,6 +414,28 @@ describe("rig walk — per-piece consumption verification (RED-first, mechanics-
expect(enterRetries).toHaveLength(2); // exactly one Enter per staged piece
});
it.each([
[{ reason: "submit_failed", inputPhase: "attempted", error: "reply lost" }, "unconfirmed"],
[{ reason: "submit_failed", error: "old daemon" }, "unconfirmed"],
[{ reason: "staged_mismatch", inputPhase: "not_reached", sent: false, error: "changed" }, "refused"],
] as const)("classifies a guarded submit error %j as %s", async (failure, wording) => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (body) => {
if (body["text"] !== undefined) {
w.pane = `❯ ${String(body["text"])} `;
return { status: 200, data: { ok: true } };
}
return { status: 500, data: { ok: false, ...failure } };
};
const { errLogs, exitCode } = await captureLogs(() => makeCmd(consumptionDeps(w)).parseAsync(walkArgs));
expect(exitCode).toBe(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["submitOnly"] === true)).toHaveLength(1);
expect(w.sends.filter(s => s.path === "/api/transport/send" && s.body["text"] !== undefined)).toHaveLength(1);
expect(errLogs.join("\n")).toContain(wording);
expect(errLogs.join("\n")).not.toMatch(/typed but not consumed/i);
if (wording === "unconfirmed") expect(errLogs.join("\n")).not.toMatch(/was refused/i);
});
it("PIN W3 — a client timeout with server-side completion reconciles BY EFFECT: no re-send, walk proceeds [GREEN — reconcile-by-effect]", async () => {
const w: ScriptedWorld = { record: { generationId: "g1", content: "" }, pane: "", sends: [], gets: [] };
w.sendBehavior = (b) => {
@@ -537,7 +537,9 @@ not have to tear one down to change it, and you rarely have to start from nothin
- **A Claude member can bring its own setup** in the rig spec: `agent_file`,
`append_system_prompt_file`, `plugin_dirs` and `advisor` (an Advisor model). They're copied into
that seat's own storage, never the project, so one seat in a shared repository can have its own
persona, prompt or plugins. A bundle's before-install view lists them.
persona, prompt or plugins. A bundle's before-install view lists them. `plugin_channels`
(`plugin:<name>@<marketplace>` entries) registers that seat alone for those plugins' channel
notifications, through Claude's `--channels`.
- **A new Claude member can name a prepared subscription token** with
`claude_token: {label: name, file: /abs/token}` beside its `env.CLAUDE_CONFIG_DIR`; the seat stores
the file reference and reads it at launch. A missing/unreadable token uses the existing fallback
@@ -98,9 +98,10 @@ node "$SKILL_DIR/scripts/backup-sqlite.mjs" \
--destination "/safe/path/openrig-before-upgrade.sqlite"
```
The helper refuses to overwrite a destination, uses SQLite's backup operation,
and verifies the backup with `PRAGMA integrity_check`. It does not stop the
daemon or restore a database.
The helper refuses to overwrite a destination, uses the `sqlite3` CLI `.backup`
command when available (falling back to an in-process backup via `node:sqlite`
when the CLI is absent), and verifies the backup with `PRAGMA integrity_check`.
It does not stop the daemon or restore a database.
### Plan or apply safe managed-plugin refreshes
@@ -4,6 +4,28 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
// Suppress experimental warning for node:sqlite to keep stderr clean for JSON errors
const originalEmitWarning = process.emitWarning;
process.emitWarning = (warning, ...args) => {
if (typeof warning === "string" && warning.includes("SQLite is an experimental feature")) {
return;
}
if (warning && typeof warning === "object" && warning.name === "ExperimentalWarning" && warning.message?.includes("SQLite")) {
return;
}
return originalEmitWarning.call(process, warning, ...args);
};
let sqliteModule;
let DatabaseSync;
try {
sqliteModule = await import("node:sqlite");
DatabaseSync = sqliteModule.DatabaseSync;
} catch {
sqliteModule = undefined;
DatabaseSync = undefined;
}
function argument(name) {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
@@ -33,22 +55,76 @@ if (destinationPath.includes("'") || destinationPath.includes("\n")) {
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const sqlite = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const backup = spawnSync(sqlite, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqlite} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqlite, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
const integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
const sqliteBin = process.env.OPENRIG_SQLITE_BIN || "sqlite3";
const probe = spawnSync(sqliteBin, ["-version"], { encoding: "utf8" });
const hasCli = probe.status === 0;
let integrity;
if (hasCli) {
const backup = spawnSync(sqliteBin, [sourcePath, `.backup '${destinationPath}'`], { encoding: "utf8" });
if (backup.status !== 0) {
fs.rmSync(destinationPath, { force: true });
fail(
backup.stderr?.trim() || backup.stdout?.trim() || backup.error?.message || "sqlite3 backup failed",
`run ${sqliteBin} against the source directly, resolve locking/path/tooling errors, then choose a fresh destination`,
);
}
const check = spawnSync(sqliteBin, [destinationPath, "PRAGMA integrity_check;"], { encoding: "utf8" });
integrity = check.stdout?.trim();
if (check.status !== 0 || integrity !== "ok") {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else if (DatabaseSync) {
try {
const srcDb = new DatabaseSync(sourcePath, { readOnly: true });
try {
if (typeof sqliteModule?.backup === "function") {
await sqliteModule.backup(srcDb, destinationPath);
} else {
srcDb.exec(`VACUUM INTO '${destinationPath}';`);
}
} finally {
srcDb.close();
}
} catch (err) {
fs.rmSync(destinationPath, { force: true });
fail(
err?.message || "sqlite backup failed via node:sqlite",
"resolve locking/path/tooling errors, then choose a fresh destination",
);
}
try {
const backupDb = new DatabaseSync(destinationPath, { readOnly: true });
try {
const rows = backupDb.prepare("PRAGMA integrity_check;").all();
integrity = rows[0]?.integrity_check;
} finally {
backupDb.close();
}
} catch (err) {
fail(
err?.message || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
if (integrity !== "ok") {
fail(
`backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
);
}
} else {
fail(
check.stderr?.trim() || `backup integrity result was ${JSON.stringify(integrity)}`,
"preserve the failed backup for diagnosis only; do not use it for rollback",
"neither sqlite3 CLI nor node:sqlite is available to create a backup",
"install sqlite3 CLI or run on Node with node:sqlite support",
);
}
@@ -7,7 +7,7 @@ fleet can pick them up.
## What you do
- Inspect new stream items with `rig stream list --json`. Hint-destination
- Inspect new stream items with `rig stream list --json`. When stream items carry `--hint-destination`, route by that hinted destination seat. Hint-destination
filtering is exact, so `?` does not mean unassigned. Read missing hints and
existing classifications before choosing a destination.
- For each: decide the destination seat (which rig + which member),
@@ -19,7 +19,8 @@ import { assessNativeResumeProbe, hasClaudeDrawnInput } from "../domain/native-r
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";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
@@ -29,7 +30,7 @@ import { observeClaudePermission } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { contextUsageDirectory, providerUsageDirectory } from "../domain/telemetry-state-paths.js";
import { seatLaunchAdditionArgs } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs } from "../domain/seat-launch-additions.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
export interface ClaudeAdapterFsOps {
@@ -99,6 +100,8 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — called after a projected file is written to a target, so the manifest
* records what we last wrote (→ operator-vs-stale discrimination). No-op by default. */
private recordProjection: (targetPath: string, content: string) => void;
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: {
tmux: TmuxAdapter;
@@ -125,6 +128,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
/** P20 — record-at-apply hook (startup wires it to the projection manifest store).
* Absent → no-op (the manifest stays empty → discrimination safe-degrades to P17). */
recordProjection?: (targetPath: string, content: string) => void;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.tmux = deps.tmux;
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
@@ -142,6 +148,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
this.activityRelayPath = deps.activityRelayPath ?? null;
this.claudeHooksManifestPath = deps.claudeHooksManifestPath ?? null;
this.recordProjection = deps.recordProjection ?? (() => {});
this.managedBlockRecords = deps.managedBlockRecords;
}
async listInstalled(binding: NodeBinding): Promise<InstalledResource[]> {
@@ -378,7 +385,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
// OPR.0.7.0.9: the seat's own additions, after the model, effort and Advisor and before the identity options.
const additionArgs = seatLaunchAdditionArgs(binding.launchAdditions);
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
const additionArg = seatLaunchAdditionShellArgs(binding.launchAdditions, shellQuote); // each flag bare, each value quoted
// Fork branch: `claude --resume <parent> --fork-session --session-id <fork> --name <seat>`
// (Claude Code 2.0.73 or later). The fork's own id is chosen here, like a fresh launch's,
@@ -1018,11 +1025,11 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
+13 -4
View File
@@ -14,7 +14,7 @@ import { verifyClaudePaneProcess, verifyPaneIdleShell, type ClaudeLaunchedProces
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
import { seatLaunchAdditionArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
import { seatLaunchAdditionArgs, seatLaunchAdditionShellArgs, type SeatLaunchAdditions } from "../domain/seat-launch-additions.js";
export type ResumeResult =
// `launchedProcess` (Claude): the process this resume's own identity check observed (#1077).
@@ -97,7 +97,7 @@ export class ClaudeResumeAdapter {
const mcpArgs = claudeMcpArgs(claudeMcp);
const mcpArg = mcpArgs.map(arg => ` ${shellQuote(arg)}`).join("");
const additionArgs = seatLaunchAdditionArgs(launchAdditions);
const additionArg = additionArgs.map((arg, index) => ` ${index % 2 === 0 ? arg : shellQuote(arg)}`).join(""); // flag, value pairs
const additionArg = seatLaunchAdditionShellArgs(launchAdditions, shellQuote); // each flag bare, each value quoted
let managed: Awaited<ReturnType<ClaudeManagedLaunch["prepare"]>> | undefined;
if (selectedPermissionMode !== undefined) {
try {
@@ -235,8 +235,8 @@ export class ClaudeResumeAdapter {
}
// The exact --resume identity is stronger evidence than a pane command or
// a version/footer heuristic. Use it only with Claude's interactive prompt
// visible, and after the untrusted screen classifiers have had their say.
// a version/footer heuristic. Use it with Claude's interactive prompt visible
// (or behind a shell label, below), after the untrusted screen classifiers have had their say.
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
@@ -273,6 +273,15 @@ export class ClaudeResumeAdapter {
message: "Claude resume failed: pane returned to shell instead of entering Claude",
};
}
// #2508: a launch subshell that didn't exec Claude (an rc alias or function, or a fallen-back environment
// correction) keeps the shell as the label. A proven Claude naming this resume beneath it is the foreground
// runtime, as a `claude` label is, before its prompt shows.
const identity = await verifyClaudePaneProcess({ target: tmuxSessionName, tmux: this.tmux,
...(this.options.listProcesses ? { listProcesses: this.options.listProcesses } : {}), expectedToken: resumeToken });
if (identity) {
return { ok: true, ...(identity.process.startedAt
? { launchedProcess: { pid: identity.process.pid, startedAt: identity.process.startedAt } } : {}) };
}
return {
ok: false,
code: "attention_required",
@@ -19,6 +19,7 @@ import type {
InstalledResource, ProjectionResult, StartupDeliveryResult, ReadinessResult,
HarnessLaunchResult,
} from "../domain/runtime-adapter.js";
import type { GuidanceChoice } from "../domain/runtime-adapter.js";
// Type-only — keeps the profile-preflight module's dynamic import lazy for
// production (no runtime import cost from this line).
import type { CodexProfileProbeResult } from "../domain/codex-profile-preflight.js";
@@ -36,7 +37,8 @@ import { assessNativeResumeProbe, buildCodexResumeCore, hasCodexUpdateHeader, ty
import { unknownDaemonSupportMessage, type CodexDaemonSupportDetector } from "../domain/codex-daemon-support.js";
import { CODEX_NETWORK_DEFAULT_OVERRIDE, codexNetworkDefaultArg, type CodexNetworkDefaultReader } from "../domain/codex-network-default.js";
import { resolveCodexGitAddDirs, type CodexGitAddDirResolver } from "../domain/codex-git-add-dirs.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { isPerSeatRoleGuidance, managedBlockText, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { excludeNewGeneratedFiles } from "../domain/generated-file-hygiene.js";
import { codexQueueStateRoot, codexTeamWorkspaceArg, type PrepareCodexSeatFolder, type PrepareCodexTeamWorkspace } from "../domain/codex-team-workspace.js";
import { shellQuote } from "./shell-quote.js";
@@ -68,7 +70,7 @@ const codexHookTrust = createRequire(import.meta.url)("../../assets/plugins/open
};
import { listNativeProcesses, observeCodexPaneProcess, observeCodexForkProcess, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow } from "../domain/native-process-lineage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal } from "../domain/seat-skill-storage.js";
import { recordSeatSkillWrite, seatPluginSkillsLossNote, seatProjectionComplete, seatSkillWriteRefusal, SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "../domain/seat-skill-storage.js";
import { codexPluginContentHash, planOwnedCodexContentApproval, recordOwnedCodexApproval } from "../domain/codex-owned-plugin-trust.js";
import { findTomlTable, opensTomlTable, parseTomlRoot, tomlArrayTableHeaderPath, tomlDefines, tomlTableHeaderPath } from "../domain/toml-tables.js";
@@ -196,6 +198,10 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// so each of those runs its whole setup under this one lock, one after another.
private configLock: Promise<unknown> = Promise.resolve();
private codexSystemConfigDir: string;
/** #875: the managed guidance blocks a fresh launch's startup queued for its developer instructions, per tmux session. */
private launchInstructions = new Map<string, Map<string, string>>();
/** #2456: OpenRig's record of what it last wrote in each managed guidance block (startup wires the daemon's). */
private managedBlockRecords: ManagedBlockRecords | undefined;
/** Build the Codex launch adapter with injectable filesystem, process, and CLI seams for isolated checks. */
constructor(deps: {
@@ -239,7 +245,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
runCodexCli?: CodexCliRunner;
/** Where Codex's system and legacy managed config files live (default /etc/codex). */
codexSystemConfigDir?: string;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}) {
this.managedBlockRecords = deps.managedBlockRecords;
this.resolveCorePluginDir = deps.resolveCorePluginDir;
this.resolveCorePluginSelected = deps.resolveCorePluginSelected;
this.runCodexCli = deps.runCodexCli ?? defaultCodexCli;
@@ -791,6 +801,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
if (binding.guidanceLaunchInstructions && binding.tmuxSession) this.launchInstructions.set(binding.tmuxSession, new Map());
const projected: string[] = [];
const warnings: string[] = [];
const skipped: string[] = [];
@@ -803,7 +814,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
try {
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding));
const didProject = this.projectEntry(entry, binding.cwd, warnings, binding.seatSkillsRoot, binding.seatSkillsRefused, this.homeFor(binding), binding);
if (didProject) {
projected.push(entry.effectiveId);
} else {
@@ -833,8 +844,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
switch (hint) {
case "guidance_merge": {
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
const merged = this.placeGuidance(binding.cwd, binding, file.path, content, warnings);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
}
@@ -949,7 +959,7 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
// 0.5.2-07 A2-3: the pod-aware RESUME path threads the SPEC model too (reverted before — the
// grounding map assumed codex parity with the claude adapter, but only fresh emitted -m).
? this.resumeCore(prepared, opts.resumeToken, binding)
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}`;
: `codex${daemonArg}${postureArg}${networkArg} -C ${shellQuote(binding.cwd)}${gitDirArg}${seatFolderArg}${queueStateDirArg}${modelArg}${effortArg}${binding.guidanceLaunchInstructions ? this.developerInstructionsArg(binding.tmuxSession) : ""}`;
const staged = await this.seatCommand(binding, cmd);
if (opts.beforeNativeDispatch && !opts.beforeNativeDispatch()) return { ok: false, error: "Selected history changed during Codex launch preparation; no native command was sent.", recovery: "attention_required" };
@@ -1370,15 +1380,14 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
private projectEntry(entry: ProjectionEntry, cwd: string, warnings: string[], seatSkillsRoot?: string, seatSkillsRefused?: string,
home?: string): boolean {
home?: string, binding?: NodeBinding): boolean {
if (entry.category === "runtime_resource" && this.applyRuntimeResource(entry, home)) {
return true;
}
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const content = this.fs.readFile(entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
return this.placeGuidance(cwd, binding, entry.effectiveId, content, warnings, entry.absolutePath);
}
// HG-1.3 plugin runtime applicability filter (per DESIGN.md §5.1):
@@ -1529,6 +1538,78 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
* propagate the skip signal so ProjectionResult and StartupDeliveryResult
* report honest counts.
*/
/** #875: a block goes in with the launch's developer instructions when this fresh launch chose them (guidanceFileFor),
* else into the repository AGENTS.md as before. A seat's role is skipped either way, and the seat-skills line keeps its
* AGENTS.md merge: the person's yes was for that file. */
private placeGuidance(cwd: string, binding: NodeBinding | undefined, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
const targetPath = nodePath.join(cwd, "AGENTS.md");
const queued = binding?.guidanceLaunchInstructions && binding.tmuxSession ? this.launchInstructions.get(binding.tmuxSession) : undefined;
if (!queued || blockId === SEAT_SKILLS_BOOTSTRAP_BLOCK_ID || isPerSeatRoleGuidance(blockId, sourcePath)) {
return this.mergeGuidance(targetPath, blockId, content, warnings, sourcePath);
}
if (blockId === "openrig-start.md") queued.delete("using-openrig.md");
queued.set(blockId, content);
// #2456: a block an earlier handover or resume left in AGENTS.md is refreshed by the same rule (the launch text asks
// Codex to follow AGENTS.md where the two differ); nothing new is written there.
writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, { records: this.managedBlockRecords, onlyIfPresent: true, sourcePath, warnings });
return true;
}
/** The `-c developer_instructions` argument for a fresh launch, from the blocks its startup queued (#875). The value is a
* TOML basic string (JSON's escapes, with DEL escaped too). Codex can give developer instructions more weight than AGENTS.md,
* so the text asks it to keep the person's AGENTS.md guidance first. */
private developerInstructionsArg(tmuxSession: string): string {
const blocks = this.launchInstructions.get(tmuxSession);
this.launchInstructions.delete(tmuxSession);
if (!blocks || blocks.size === 0) return "";
const text = [LAUNCH_INSTRUCTIONS_PREAMBLE, ...[...blocks].map(([id, content]) => managedBlockText(id, content))].join("\n\n");
return ` -c ${shellQuote(`developer_instructions=${JSON.stringify(text).replace(/\u007f/g, "\\u007F")}`)}`;
}
/** #875: whether a fresh launch can carry this seat's managed guidance as developer instructions instead of writing the
* repository AGENTS.md: Codex 0.164.0 or later (where `developer_instructions` is a declared key), and no visible config
* layer sets one, since the launch value would replace it. Anything else, or anything unreadable, keeps AGENTS.md. */
guidanceFileFor(binding: NodeBinding, opts?: { resuming?: boolean }): GuidanceChoice | Promise<GuidanceChoice> {
// A resume or fork (untested with developer instructions) and a startup continuation (it launches nothing) keep the
// repository path, answered at once.
if (opts?.resuming || !binding.tmuxSession) return { file: null };
return this.freshGuidanceChoice(binding);
}
private async freshGuidanceChoice(binding: NodeBinding): Promise<GuidanceChoice> {
let version: string | undefined;
try { version = (await this.resolveManagedConfigSettings?.())?.codexVersion; } catch { version = undefined; }
if (!version || !codexAcceptsDeveloperInstructions(version)) return { file: null };
const configured = this.configuredDeveloperInstructions(binding);
if (configured) return { file: null, note: `codex: ${configured}, so this seat's managed guidance stays in its repository AGENTS.md` };
return { file: null, launchInstructions: true };
}
/** The first visible Codex config layer that sets `developer_instructions` (at any depth, profiles included) or can't be
* read, as `profileRemainsReadOnly` reads the same layers. Null when none does. */
private configuredDeveloperInstructions(binding: NodeBinding): string | null {
const home = this.homeFor(binding) ?? nodePath.join(this.fs.homedir ?? os.homedir(), ".codex");
const layers = [
nodePath.join(this.codexSystemConfigDir, "config.toml"),
nodePath.join(home, "config.toml"),
...(binding.codexConfigProfile ? [nodePath.join(home, `${binding.codexConfigProfile}.config.toml`)] : []),
nodePath.join(this.codexSystemConfigDir, "managed_config.toml"),
];
for (let dir = nodePath.normalize(binding.cwd);;) {
layers.push(nodePath.join(dir, ".codex", "config.toml"));
const parent = nodePath.dirname(dir);
if (parent === dir) break;
dir = parent;
}
for (const path of new Set(layers)) {
if (!this.fs.exists(path)) continue;
let value: unknown;
try { value = parseToml(this.fs.readFile(path)); } catch { return `${path} can't be read as TOML`; }
if (holdsKey(value, "developer_instructions")) return `${path} sets developer_instructions`;
}
return null;
}
private mergeGuidance(targetPath: string, blockId: string, content: string, warnings: string[], sourcePath?: string): boolean {
// Mirrors Claude Code adapter: the `rig-role` managed block collides across
// pod-mates because the regenerator pairs (target-file × spec) without
@@ -1543,11 +1624,11 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
private detectDeliveryHint(path: string, content: string, orientation?: string): "guidance_merge" | "skill_install" | "send_text" {
@@ -2123,6 +2204,21 @@ export function upsertCodexHookTrust(content: string, key: string, hash: string,
return field === "trusted_content_hash" ? lines.join("\n") : `${lines.join("\n").replace(/\n*$/, "\n")}`;
}
const LAUNCH_INSTRUCTIONS_PREAMBLE = "OpenRig seat guidance for this session follows. Where it conflicts with AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md.";
/** Codex 0.164.0 or later, where `developer_instructions` is a declared config key (#875). */
export function codexAcceptsDeveloperInstructions(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
return parseInt(match[1]!, 10) > 0 || parseInt(match[2]!, 10) >= 164;
}
function holdsKey(value: unknown, key: string): boolean {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some((item) => holdsKey(item, key));
return Object.entries(value).some(([name, item]) => name === key || holdsKey(item, key));
}
export function isCodex013xOrLater(version: string): boolean {
const match = /^(\d+)\.(\d+)/.exec(version);
if (!match) return false;
@@ -13,4 +13,9 @@ export class OmpRuntimeAdapter extends PiRuntimeAdapter {
if (!tmuxSession) return null;
return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
}
/** #875: OMP keeps its guidance in the repository AGENTS.md. Pi's agent-folder loading was shown natively; OMP's wasn't. */
override guidanceFileFor(): { file: string | null; note?: string } {
return { file: null };
}
}
@@ -22,7 +22,9 @@ import type {
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import { guidanceFileSharedByLink } from "../domain/restore-guidance.js";
import { observePiResourceTrust, observeOmpApprovalMode } from "../domain/permission-drift.js";
import {
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
@@ -40,6 +42,8 @@ export interface PiAdapterFsOps {
listFiles?(dirPath: string): string[];
statMode?(path: string): number;
chmod?(path: string, mode: number): void;
/** Whether a path is a symlink and its hard-link count, or null when it doesn't exist (an lstat). */
linkInfo?(path: string): { link: boolean; nlink: number } | null;
}
export interface PiRuntimeAdapterDeps {
@@ -63,6 +67,9 @@ export interface PiRuntimeAdapterDeps {
usageFactsEnabled?: () => boolean;
/** OPR.0.7.0.2 K4: whether usage.pi_openrouter_charge is on, read at each launch (absent = off). */
openRouterChargeEnabled?: () => boolean;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}
export class PiRuntimeAdapter implements RuntimeAdapter {
@@ -77,8 +84,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
private newLaunchId: () => string;
private usageFactsEnabled: () => boolean;
private openRouterChargeEnabled: () => boolean;
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: PiRuntimeAdapterDeps) {
this.managedBlockRecords = deps.managedBlockRecords;
this.usageFactsEnabled = deps.usageFactsEnabled ?? (() => false);
this.openRouterChargeEnabled = deps.openRouterChargeEnabled ?? (() => false);
this.tmux = deps.tmux;
@@ -182,9 +191,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
switch (hint) {
case "guidance_merge": {
// ADDITIVE context-file merge (Omnigent best-practice: never
// replace Pi's default system prompt). Pi reads AGENTS.md from
// the managed cwd as a project context file.
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
// replace Pi's default system prompt). Pi reads AGENTS.md from its
// agent folder and from the managed cwd as context files.
const targetPath = this.guidanceTarget(binding, warnings);
if (!targetPath) continue; // not written through a link: not counted as delivered
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
@@ -440,9 +450,32 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
};
}
/** #875: a Pi seat's managed guidance goes into its own agent folder's AGENTS.md, which Pi loads before the repository's
* (PI_CODING_AGENT_DIR is that folder), so the person's tracked repository AGENTS.md is never written. The repository
* file stays when there's no session yet, or when the folder or its AGENTS.md is a link: through a link, other sessions
* (a person's own Pi home, a shared file) would read this rig's guidance. */
guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } {
if (!binding.tmuxSession) return { file: null };
const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md");
const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null);
return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file };
}
/** The file this launch merges guidance into: the one its startup chose (guidanceFileFor, or a restore's saved choice),
* else the repository AGENTS.md in the managed cwd. Null when a chosen file has since become a link (#2432): nothing is
* written through it, and nothing falls back to the repository file. */
private guidanceTarget(binding: NodeBinding, warnings: string[]): string | null {
if (!binding.guidanceFile) return nodePath.join(binding.cwd, "AGENTS.md");
const linked = guidanceFileSharedByLink(binding.guidanceFile, (path) => this.fs.linkInfo?.(path) ?? null);
if (!linked) return binding.guidanceFile;
warnings.push(`${this.runtime}: ${linked} is now a link, so this seat's managed guidance wasn't written to ${binding.guidanceFile}, which other sessions could read`);
return null;
}
private projectEntry(entry: ProjectionEntry, binding: NodeBinding, warnings: string[]): boolean {
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const targetPath = this.guidanceTarget(binding, warnings);
if (!targetPath) return false;
const content = this.fs.readFile(entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
}
@@ -500,10 +533,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
warnings,
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fs, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
}
@@ -24,7 +24,8 @@ import type {
} from "../domain/runtime-adapter.js";
import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { isPerSeatRoleGuidance, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { writeManagedGuidanceBlock, type ManagedBlockRecords } from "../domain/managed-block-records.js";
import {
stubSeatSidecarPath, buildStubRunnerCommand, parseStubRunnerState,
type StubRunnerState,
@@ -56,6 +57,9 @@ export interface StubRuntimeAdapterDeps {
sleep?: (ms: number) => Promise<void>;
/** Launch-attempt id minting (tests inject; defaults to randomUUID). */
newLaunchId?: () => string;
/** #2456: with it, every guidance block this adapter writes is checked against the record and recorded; absent, the
* plain merge, as before. */
managedBlockRecords?: ManagedBlockRecords;
}
/** In-memory launch record: the readiness fallback for the hermetic path (no
@@ -74,8 +78,10 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
private sleep: (ms: number) => Promise<void>;
private newLaunchId: () => string;
private readonly launchRecords = new Map<string, StubLaunchRecord>();
private managedBlockRecords: ManagedBlockRecords | undefined;
constructor(deps: StubRuntimeAdapterDeps) {
this.managedBlockRecords = deps.managedBlockRecords;
this.tmux = deps.tmux;
this.fsOps = deps.fsOps;
this.runtime = deps.runtime ?? "stub";
@@ -329,10 +335,10 @@ export class StubRuntimeAdapter implements RuntimeAdapter {
if (warning) warnings.push(warning);
return false;
}
mergeManagedBlock(this.fsOps, targetPath, blockId, content, {
warnings,
// #2456: a block a person edited since OpenRig wrote it is kept and named, not counted as merged.
return writeManagedGuidanceBlock(this.fsOps, targetPath, blockId, content, {
records: this.managedBlockRecords, sourcePath, warnings,
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
return true;
}
}
+34 -1
View File
@@ -8,6 +8,24 @@ const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
// exec's 1 MiB default holds about a dozen 1000-line panes; a batched capture chains 24 (#161). A bound, not an allocation.
export const TMUX_MAX_BUFFER = 64 * 1024 * 1024;
export const TMUX_EXEC_TIMEOUT_MS = 30_000;
const tmuxExecOptions = { windowsHide: true, maxBuffer: TMUX_MAX_BUFFER, timeout: TMUX_EXEC_TIMEOUT_MS, killSignal: "SIGKILL" as const };
function warnTmuxDeadline(err: unknown, started: number, argv: string[]): void {
const failure = err as { killed?: boolean; signal?: string } | null;
if (!failure?.killed || failure.signal !== "SIGKILL" || performance.now() - started < TMUX_EXEC_TIMEOUT_MS) return;
let command = 1;
while (argv[command]?.startsWith("-")) command += ["-L", "-S", "-f", "-c", "-T"].includes(argv[command]!) ? 2 : 1;
const paneTarget = argv.indexOf("-t", command + 1);
const sessionTarget = ["new-session", "detach-client"].includes(argv[command] ?? "") ? argv.indexOf("-s", command + 1) : -1;
const target = paneTarget >= 0 ? paneTarget : sessionTarget;
// Name the operation and target, never payloads, environment values or whole command lines.
console.warn("[tmux] execution deadline exceeded; client killed", {
command: argv[command] ?? argv[0], target: target < 0 ? "(server)" : argv[target + 1] ?? "(unknown)", timeoutMs: TMUX_EXEC_TIMEOUT_MS,
});
if (err instanceof Error) err.message = "tmux did not answer within 30 s and its client was ended; the write may still have happened. Check with rig capture before resending.\n" + err.message;
}
/**
* The first regular executable `name` on `pathEnv`, in execvp order. Null when there is none, or when an
@@ -73,6 +91,19 @@ export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
}
};
/** Replace the shell only for tmux clients; other adapter commands keep shell builtins such as kill. */
export const execTmuxCommand: ExecFn = async (cmd: string): Promise<string> => {
const started = performance.now();
try {
return (await execFileAsync("/bin/sh", ["-c", /^tmux\s/.test(cmd) ? `exec ${cmd}` : cmd], tmuxExecOptions)).stdout;
} catch (err) {
// Tokens are diagnostic labels only; the command is executed by the shell without rewriting its arguments.
const labels = (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(word => word.replace(/^(['"])(.*)\1$/, "$2"));
warnTmuxDeadline(err, started, labels);
throw withExecOutput(err);
}
};
/**
* Production ArgvExecFn for TmuxAdapter.
* Runs argv[0] directly via execFile: no shell, no quoting layer. On POSIX
@@ -83,9 +114,10 @@ export const execCommand: ExecFn = async (cmd: string): Promise<string> => {
export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<string> => {
const [bin, ...args] = argv;
if (!bin) throw new Error("execArgvCommand: empty argv");
const started = performance.now();
// Nothing ran when no file was found: a shell also finds tmux through an entry the lookup skips, such as a literal
// `~/bin`, and reports a missing one as the shell transport does.
const opts = { windowsHide: true, maxBuffer: TMUX_MAX_BUFFER };
const opts = tmuxExecOptions;
const run = (file: string | null) => file ? execFileAsync(file, args, opts) : execFileAsync("/bin/sh", ["-c", 'exec "$0" "$@"', bin, ...args], opts);
try {
if (process.platform === "win32") return (await execFileAsync(bin, args, opts)).stdout;
@@ -100,6 +132,7 @@ export const execArgvCommand: ArgvExecFn = async (argv: string[]): Promise<strin
return (await run(null)).stdout;
}
} catch (err) {
warnTmuxDeadline(err, started, argv);
throw withExecOutput(err);
}
};
+10 -1
View File
@@ -919,7 +919,16 @@ 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, false, text); await options?.beforePaste?.(); },
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
// checks pass; they then aren't run a second time.
beforePaste: async () => {
let checked = false;
const checks = async () => { await options?.beforePaste?.(); beforeWrite(); beforeInput?.(); checked = true; };
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
const enter = async () => { beforeWrite(); beforeInput?.(); return (await this.sendKeysUnchecked(pane, ["Enter"])).ok; };
if (checkDraft) await guard!.beforeInput(identity, false, text, { checks, enter });
if (!checked) await options?.beforePaste?.();
},
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
return this.sendTextUnchecked(pane, text, () => { beforeWrite(); beforeInput?.(); }, options?.bracketed, policy);
+8
View File
@@ -1,6 +1,10 @@
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
import { nodeStartupGuidanceFileSchema } from "./migrations/146_node_startup_guidance_file.js";
import { managedBlockWritesSchema } from "./migrations/147_managed_block_writes.js";
import { outboxDispatchedIndexSchema } from "./migrations/148_outbox_dispatched_index.js";
import { streamIntakeCursorSchema } from "./migrations/149_stream_intake_cursor.js";
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
@@ -293,4 +297,8 @@ export const ALL_MIGRATIONS: Migration[] = [
activityGrantsSchema,
environmentBindingsSchema,
nodePermissionsToSchema,
nodeStartupGuidanceFileSchema,
managedBlockWritesSchema,
outboxDispatchedIndexSchema,
streamIntakeCursorSchema,
];
@@ -0,0 +1,9 @@
import type { Migration } from "../migrate.js";
/** The file a seat's launch chose for its managed guidance blocks, when that isn't the rule's file in its working directory
* (a Pi seat's own agent folder AGENTS.md, #875). Null (the default, and every row written before this) means the rule's
* file, so restore, teardown and inventory keep reading exactly what they read before. */
export const nodeStartupGuidanceFileSchema: Migration = {
name: "146_node_startup_guidance_file.sql",
sql: "ALTER TABLE node_startup_context ADD COLUMN guidance_file TEXT;",
};
@@ -0,0 +1,20 @@
import type { Migration } from "../migrate.js";
/**
* #2456: what OpenRig last wrote in each managed guidance block, by the guidance file's real path and the block id: the
* sha256 of the block's body without the writer's framing. A block whose body still has that hash is OpenRig's own and is
* refreshed from its source; any other body is a person's edit and is kept. No row (every block written before this) means
* OpenRig can't tell which it is.
*/
export const managedBlockWritesSchema: Migration = {
name: "147_managed_block_writes.sql",
sql: `
CREATE TABLE managed_block_writes (
path_key TEXT NOT NULL,
block_id TEXT NOT NULL,
body_sha256 TEXT NOT NULL,
written_at TEXT NOT NULL,
PRIMARY KEY (path_key, block_id)
);
`,
};
@@ -0,0 +1,13 @@
import type { Migration } from "../migrate.js";
/**
* GET /api/queue/outbox/list?rig= matches session names by suffix, which no session index can serve. This index lets
* that read walk the outbox newest-first and stop at its limit, instead of scanning and sorting the whole table. The
* session columns ride along so the match is tested in the index, and only matching rows are read.
*/
export const outboxDispatchedIndexSchema: Migration = {
name: "148_outbox_dispatched_index.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_outbox_entries_ts_dispatched ON outbox_entries(ts_dispatched, sender_session, destination_session);
`,
};
@@ -0,0 +1,15 @@
import type { Migration } from "../migrate.js";
/** Stream intake cursor table tracking the last processed stream sort key. */
export const streamIntakeCursorSchema: Migration = {
name: "149_stream_intake_cursor.sql",
sql: `
CREATE TABLE IF NOT EXISTS stream_intake_cursor (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_sort_key TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stream_items_archived_sort_key ON stream_items(archived_at, stream_sort_key);
CREATE INDEX IF NOT EXISTS idx_stream_items_sort_key ON stream_items(stream_sort_key);
`,
};
@@ -186,6 +186,7 @@ export class BootstrapOrchestrator {
try {
const podSource = await this.deps.podBundleSourceResolver.resolve(sourceRef);
podBundleTempDir = podSource.tempDir;
warnings.push(...(podSource.warnings ?? []).filter(w => !warnings.includes(w)));
try {
const sourceYaml = this.deps.fsOps.readFile(podSource.specPath);
const sharedProject = rigNameComposeProjectConflict(sourceYaml, opts.rigName);
@@ -309,6 +310,7 @@ export class BootstrapOrchestrator {
try {
bundleSource = await this.deps.bundleSourceResolver.resolve(sourceRef);
bundleTempDir = bundleSource.tempDir;
warnings.push(...(bundleSource.warnings ?? []).filter(w => !warnings.includes(w)));
specDir = nodePath.dirname(bundleSource.specPath);
const rawYaml = withRigName(this.deps.fsOps.readFile(bundleSource.specPath), opts.rigName);
const raw = RigSpecCodec.parse(rawYaml);
@@ -780,7 +782,7 @@ export class BootstrapOrchestrator {
}
const failureWarnings = routingFailureWarnings(bundleRouting);
stages.push({ stage: "route_bundle_contents", status: failureWarnings.length > 0 ? "failed" : "ok", detail: bundleRouting });
warnings.push(...failureWarnings);
warnings.push(...(bundleRouting.warnings ?? []).filter(w => !warnings.includes(w)), ...failureWarnings);
return { ok: true };
}
: undefined;
+35 -3
View File
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import * as tar from "tar";
import { verifyIntegrity, type IntegrityFsOps } from "./bundle-integrity.js";
import { verifyIntegrity, isHarnessLoginPath, type IntegrityFsOps } from "./bundle-integrity.js";
// TODO: AS-T12 — migrate to pod-aware bundle types
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, normalizeIncludedLoginFiles } from "./bundle-types.js";
@@ -124,6 +124,7 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
}
// Step 3: Extract (safe — pre-scanned and defensively filtered)
const fileModes: Array<{ file: string; mode: number }> = [];
fs.mkdirSync(outputDir, { recursive: true });
await tar.extract({
file: archivePath,
@@ -131,7 +132,11 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
filter: (p, entry) => {
if ("isSymbolicLink" in entry && typeof entry.isSymbolicLink === "function" && entry.isSymbolicLink()) return false;
const type = "type" in entry ? (entry as { type?: string }).type : undefined;
return unsafeArchiveEntryReason(p, type) === null;
if (unsafeArchiveEntryReason(p, type) !== null) return false;
if ((type === "File" || type === "OldFile" || type === "ContiguousFile") && typeof entry.mode === "number") {
fileModes.push({ file: nodePath.resolve(outputDir, p), mode: entry.mode & 0o777 });
}
return true;
},
});
@@ -184,7 +189,34 @@ export async function unpack(archivePath: string, outputDir: string): Promise<{
];
throw new Error(`Content integrity verification failed: ${details.join("; ")}`);
}
if (result.warnings) return { warnings: result.warnings };
const warnings = [...(result.warnings ?? [])];
// Keep every header in archive order: aliases and duplicates can interleave.
// POSIX device/inode identity groups aliases without folding distinct names
// on case-sensitive volumes. Read bigint IDs to avoid number precision loss.
if (process.platform !== "win32") {
const files = new Map<string, { file: string; mode: number; extracted: number; login: boolean }>();
for (const { file, mode } of fileModes) {
const stat = fs.lstatSync(file, { throwIfNoEntry: false, bigint: true });
// A later entry may have replaced this file with a directory.
if (!stat?.isFile()) continue;
const identity = `${stat.dev}:${stat.ino}`;
const login = isHarnessLoginPath(nodePath.relative(outputDir, file)) || !!files.get(identity)?.login;
files.set(identity, { file, mode, extracted: Number(stat.mode) & 0o7777, login });
}
for (const { file, mode, extracted, login } of files.values()) {
// Restore owner bits only. Keep extracted group/other read/execute bits,
// excluding group/other write and all special bits even on external tars.
const target = login ? mode & 0o600 : (mode & 0o700) | (extracted & 0o055);
if (extracted === target) continue;
try {
fs.chmodSync(file, target);
} catch {
const relative = nodePath.relative(outputDir, file);
warnings.push(`Bundle mode repair warning: chmod failed for '${relative}' (observed mode ${extracted.toString(8).padStart(4, "0")}). ${login ? "The owner-only login cap could not be applied. " : ""}Extract to a private folder on a filesystem that supports permissions.`);
}
}
}
if (warnings.length > 0) return { warnings };
}
}
@@ -262,6 +262,12 @@ export function describeBundleBehaviour(input: DescribeBundleInput): BundleBehav
if (kind === "launch_plugin_dir") { view.alsoRuns.push({ ...fact, trigger: "if selected runtime supports and loads this resource" }); hooks(fact); }
write(seat, kind, "openrig_seat_storage", dest(String(ref)), fact.sourceRefs, "copy");
}
// #2044: a member's plugin channels are names, not files: what its launches need from the installing host.
for (const [index, entry] of list(member.plugin_channels).entries()) {
if (!text(entry)) continue;
view.needs.push({ seat, kind: "claude_plugin_channel", name: `Claude plugin channel ${entry} (--channels): its plugin installed, and the channel admitted by Claude's channel policy`,
status: "not_checked", sourceRefs: [{ path: rigPath, field: `pods.${pod.id}.members.${member.id}.plugin_channels[${index}]` }] });
}
}
const startup = (block: StartupBlock | undefined, base: string, refs: SourceRef[]) => {
for (const f of block?.files ?? []) {
@@ -26,6 +26,7 @@ import { registerBundleProject, type ProjectRegistrationResult } from "./workspa
export type BundleContentKind = "bundle" | "skills" | "plugins" | "workflowSpecs" | "contextPacks" | "agentImages" | "project";
export interface BundleContentRouting {
warnings?: string[];
skillsRouting?: RouteSkillsResult;
pluginsRouting?: RoutePluginsResult;
workflowSpecsRouting?: RouteWorkflowSpecsResult;
@@ -98,7 +99,8 @@ export async function routeBundleContents(
tmpDir = bundleRoot;
let manifest: Record<string, unknown> | null = null;
try {
await unpack(bundlePath, bundleRoot);
const unpacked = await unpack(bundlePath, bundleRoot);
if (unpacked?.warnings?.length) routing.warnings = unpacked.warnings;
const manifestPath = nodePath.join(bundleRoot, "bundle.yaml");
if (fs.existsSync(manifestPath)) {
manifest = parsePodBundleManifest(fs.readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
@@ -17,6 +17,7 @@ export interface BundleResolvedSource {
packageRefMap: Record<string, ResolvedPackage>;
manifest: BundleManifest;
tempDir: string;
warnings?: string[];
}
/**
@@ -40,7 +41,7 @@ export class LegacyBundleSourceResolver {
try {
// Extract + verify (archive digest + content integrity)
await unpack(bundlePath, tempDir);
const unpacked = await unpack(bundlePath, tempDir);
// Parse bundle manifest
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
@@ -98,7 +99,7 @@ export class LegacyBundleSourceResolver {
packageRefMap[`./${entry.path}`] = result.resolved;
}
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir };
return { specPath, resolvedPackages, packageRefMap, manifest, tempDir, warnings: unpacked?.warnings };
} catch (err) {
// Clean up on failure
this.cleanup(tempDir);
@@ -125,6 +126,7 @@ export interface PodBundleResolvedSource {
specPath: string;
manifest: PodBundleManifest;
tempDir: string;
warnings?: string[];
}
/**
@@ -137,7 +139,7 @@ export class PodBundleSourceResolver {
try {
// Safe unpack with digest/symlink/integrity verification
await unpack(bundlePath, tempDir);
const unpacked = await unpack(bundlePath, tempDir);
const manifestPath = nodePath.join(tempDir, "bundle.yaml");
if (!fs.existsSync(manifestPath)) {
@@ -182,7 +184,7 @@ export class PodBundleSourceResolver {
throw new Error(`Bundle missing rig spec at ${manifest.rigSpec}`);
}
return { specPath, manifest, tempDir };
return { specPath, manifest, tempDir, warnings: unpacked?.warnings };
} catch (err) {
this.cleanup(tempDir);
throw err;
@@ -1,7 +1,7 @@
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
import { TerminalPolicyRefusal } from "./terminal-policy.js";
import { DeliveryGuardError } from "./seat-delivery-guard.js";
import type { SessionTransport, SendResult } from "./session-transport.js";
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type SendResult } from "./session-transport.js";
import type { ClaudeCompactionPolicy, SettingsStore } from "./user-settings/settings-store.js";
import { postCompactExtraSetting } from "./managed-restore-read-access.js";
import * as fs from "node:fs";
@@ -503,6 +503,8 @@ interface CompactionRecord {
* `compact_uncertain`: the attempt stopped after a /compact whose outcome is unknown, so its restore is owed if it ran.
* `done`: the post-restore audit was sent. `ended`: the attempt stopped or was cancelled. */
phase: "preparing" | PendingPostCompactStage | "compact_uncertain" | "done" | "ended";
/** Diagnostic only; older records may omit the reason an attempt stopped. */
reason?: string;
delivery: PreparationState["delivery"];
deadlineAt: number | null;
sends: number;
@@ -1368,11 +1370,11 @@ export class ClaudeCompactionEnforcer {
};
const send = async () => {
check();
// Both paths send only to an idle seat, with the same idle wait, bounded by the attempt's existing
// deadline. (The automatic path used to send into a seat still finishing its preparation turn.)
// Both paths send only to an idle seat. Include the readable observation's budget in the wait,
// still bounded by the attempt's existing deadline.
// Idle comes from a live pane read: the newest hook can predate the rest of the preparation turn.
return this.sessionTransport.send(input.sessionName, composed.command,
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
{ openrigNotice: true, preserveInput: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: Math.max(1, Math.min(this.compactSendIdleWaitMs + CAPTURE_READABLE_BUDGET_MS, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
};
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase">;
let guardRefused = false;
@@ -1428,7 +1430,7 @@ export class ClaudeCompactionEnforcer {
* why it couldn't be read. Never throws. */
private async inputBoxStatus(sessionName: string): Promise<string> {
try {
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true });
const capture = await this.sessionTransport.capture(sessionName, { inputBox: true, readable: { width: 120, height: 40 } });
return capture.inputBox?.status ?? capture.reason ?? "unknown";
} catch { return "unknown"; }
}
@@ -1505,9 +1507,9 @@ export class ClaudeCompactionEnforcer {
} catch { try { fs.rmSync(temporary, { force: true }); } catch { /* nothing to clean */ } }
}
private persistStage(sessionName: string, phase: CompactionRecord["phase"]): void {
private persistStage(sessionName: string, phase: CompactionRecord["phase"], reason?: string): void {
const current = this.records.get(sessionName);
if (current) this.persist(sessionName, current.record.attemptId, { phase, lastDelivery: undefined });
if (current) this.persist(sessionName, current.record.attemptId, { phase, ...(reason !== undefined ? { reason } : {}), lastDelivery: undefined });
}
/**
@@ -1593,7 +1595,7 @@ export class ClaudeCompactionEnforcer {
const attempt = this.pendingPreCompactPrep.get(session);
if ((expected && expected !== attempt) || !attempt || attempt.status === "stopped" || attempt.status === "compact-sent") return;
attempt.status = "stopped"; attempt.reason = reason; attempt.stoppedAt = this.now(); attempt.controller.abort();
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended" });
this.persist(session, attempt.attemptId, { phase: attempt.compact === "uncertain" ? "compact_uncertain" : "ended", reason });
if (attempt.mode === "manual") this.setManualStage(session, "skipped-or-failed", reason);
}
@@ -1609,7 +1611,7 @@ export class ClaudeCompactionEnforcer {
attempt.reason = "compaction_cancelled";
attempt.controller.abort();
}
this.persistStage(session, "ended");
this.persistStage(session, "ended", "compaction_cancelled");
this.pendingPostCompactRestore.delete(session);
this.pendingStageGeneration.delete(session);
this.restoreCarry.delete(session);
@@ -0,0 +1,13 @@
// #2044 — a Claude member's own plugin channels (spec `plugin_channels`). Claude Code's `--channels` registers the
// session for channel notifications (inbound push) from the MCP servers it names; Claude 2.1.316 hides the option from
// `claude --help`. OpenRig passes only plugin entries, `plugin:<name>@<marketplace>`, as one `--channels` followed by the
// entries. The option takes every following value up to the next option, so each launch puts OpenRig's identity options
// after it. One rule for the spec, the seat's additions record and identity proof.
/** A plugin channel entry as OpenRig passes it: a plain plugin name and marketplace name. Claude refuses to start on an
* entry that isn't `plugin:<name>@<marketplace>` or `server:<name>`, and `server:` entries aren't supported here. */
const CLAUDE_PLUGIN_CHANNEL = /^plugin:[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9._-]*$/;
export function isClaudePluginChannel(value: unknown): value is string {
return typeof value === "string" && CLAUDE_PLUGIN_CHANNEL.test(value);
}
@@ -0,0 +1,29 @@
/** Current interactive Claude 2.1.312 theme picker, including its tall preview.
* This establishes a dialog for the native send guard, never permission to cancel
* it. A later composer, quoted frame, partial menu or editor is not this surface. */
export function currentClaudeThemeMenu(pane: string): string | null {
const lines = pane.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
// OpenRig-launched Claude may draw its named seat rule below the menu footer.
if (/^─{3,} [\w.-]+@[\w.-]+ ─+$/.test(lines.at(-1) ?? "")) lines.pop();
if (lines.at(-1) !== "Enter to select · Esc to cancel") return null;
const header = lines.lastIndexOf("Theme");
if (header < 1 || !/^─{3,}$/.test(lines[header - 1]!)
|| lines[header + 1] !== "Choose the text style that looks best with your terminal") return null;
const body = lines.slice(header + 2, -1);
const labels = ["Auto (match terminal)", "Dark mode", "Light mode", "Dark mode (colorblind-friendly)",
"Light mode (colorblind-friendly)", "Dark mode (ANSI colors only)", "Light mode (ANSI colors only)", "New custom theme…"];
if (body.length !== 15) return null;
let pointers = 0, checks = 0;
const choices = body.slice(0, labels.length).map(line => {
if (/^❯\s+/.test(line)) { pointers++; line = line.replace(/^❯\s+/, ""); }
if (/^✔\s+/.test(line)) { checks++; line = line.replace(/^✔\s+/, ""); }
return line;
});
if (pointers !== 1 || checks !== 1 || !choices.every((line, i) => line === labels[i])) return null;
if (!/^╌{3,}$/.test(body[8]!) || !/^╌{3,}$/.test(body[13]!)) return null;
const preview = [/^1\s+function greet\(\) \{$/, /^2\s*-\s+console\.log\("Hello, World!"\);$/,
/^2\s*\+\s+console\.log\("Hello, Claude!"\);$/, /^3\s+\}$/];
if (!preview.every((pattern, i) => pattern.test(body[9 + i]!))
|| !/^Syntax theme: .+ \(ctrl\+t to disable\)$/.test(body[14]!)) return null;
return lines[header]!;
}
@@ -2,3 +2,5 @@
// then the elapsed time and "esc to interrupt" in parentheses, e.g. "• Working (1h 09m 39s • esc to interrupt)".
// Completed output that merely says "Working directory: …" or "Working tree is clean." never matches.
export const CODEX_TURN_STATUS_PATTERN = /^[•◦]\s+\S.*\((?:\d+[hms]\s*)+•\s*esc to interrupt\)/;
// Some Codex frames omit the closing parenthesis before the status separator. This is positive activity evidence only.
export const CODEX_LIVE_WORKING_FRAME_PATTERN = /^[•◦]\s+\S.*\((?:\d+[hms]\s*)+•\s*esc to interrupt(?:\)|\s+·(?:\s|$))/;
@@ -137,6 +137,8 @@ export interface ComposerInput {
state: "empty" | "text" | "unknown";
rows: string[];
columns: number;
/** How many columns short of `columns` the harness may wrap its text: a row boundary at a width in that range is a wrap. */
wrapSlack?: number;
/** Conservative visible capacity of the recognized Codex fullscreen composer. */
pasteViewport?: { columns: number; rows: number };
cursor?: ComposerSnapshot["cursor"];
@@ -158,6 +160,7 @@ export const COMPOSER_COLLAPSED_PASTE_PATTERN = /^\[Pasted text #\d+ \+(\d+) lin
export function composerContainsOwnedText(input: ComposerInput, expected: string): boolean {
if (input.state !== "text" || !input.cursorAtEnd || input.collapsedPaste || !input.rows.length) return false;
const text = expected.replace(/\r\n/g, "\n");
const narrowest = input.columns - (input.wrapSlack ?? 0);
let offsets = new Set([0]);
for (let index = 0; index < input.rows.length; index++) {
const row = input.rows[index]!;
@@ -167,10 +170,10 @@ export function composerContainsOwnedText(input: ComposerInput, expected: string
const end = offset + row.length;
if (index === input.rows.length - 1) { if (end === text.length) return true; continue; }
if (text[end] === "\n") next.add(end + 1);
if (stringWidth(row) >= input.columns) next.add(end);
if (stringWidth(row) >= narrowest) next.add(end);
if (text[end] === " ") {
const word = /^\S+/.exec(text.slice(end + 1))?.[0] ?? "";
if (word && stringWidth(row + " " + word) > input.columns) next.add(end + 1);
if (word && stringWidth(row + " " + word) > narrowest) next.add(end + 1);
}
}
offsets = next;
@@ -1,7 +1,8 @@
import { dirname, isAbsolute } from "node:path";
import nodePath, { dirname, isAbsolute } from "node:path";
import type Database from "better-sqlite3";
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
import { resolveProfileRuntimeResources } from "./profile-resolver.js";
import { reanchorBuiltinStartupFile } from "./builtin-startup-files.js";
/** What the seat's CURRENT authored specification selects for Claude activity
* hooks: `known` when the agent file and profile resolve, `unknown` when they
@@ -11,19 +12,67 @@ export type DesiredClaudeActivityHooks =
| { status: "known"; selected: boolean }
| { status: "unknown"; evidence: string };
/** Resolve the current selection from an agent reference and profile. A
* relative agent reference has no retained authoritative launch root here, so
* it resolves to `unknown` rather than guessing from cwd or rig name. */
type ResolvedAgent = Extract<ReturnType<typeof resolveAgentRef>, { ok: true }>;
/** #1530: a `local:` agent ref resolved as at launch. Launch resolves it against the rig root, which isn't kept on its own,
* but every rig, pod and member startup file records that root as its `ownerRoot`. So the ref is resolved against each
* saved startup file's owner root (re-anchored to the running install for shipped specs, as the runtime switch reads them,
* #261). Launch also records the folder it read the agent from, as the owner root of the agent's own startup files, so a
* folder counts only when the ref lands on a recorded root, holds an `agent.yaml` with the seat's resolved spec name, and
* is the only one that does. Where the records can't prove which folder is the seat's spec (an import or author folder can
* share its name), the answer is an error, never a guess. */
function agentResolvedAgainstRecordedRoots(
agentRef: string,
resolvedSpecName: string | null,
resolvedStartupFiles: readonly unknown[],
fsOps: AgentResolverFsOps,
): { ok: true; agent: ResolvedAgent } | { ok: false; error: string } {
const rel = agentRef.startsWith("local:") ? agentRef.slice("local:".length) : "";
if (!rel) return { ok: false, error: "Current agent reference/profile has no established absolute source location" };
const roots = new Set<string>();
for (const raw of resolvedStartupFiles) {
if (!raw || typeof raw !== "object") continue;
const { path, absolutePath, ownerRoot } = raw as Record<string, unknown>;
if (typeof path !== "string" || typeof absolutePath !== "string" || typeof ownerRoot !== "string") continue;
const root = reanchorBuiltinStartupFile({ path, absolutePath, ownerRoot }).ownerRoot;
if (isAbsolute(root)) roots.add(nodePath.resolve(root));
}
const folders = new Set([...roots].map((root) => nodePath.resolve(root, rel)).filter((folder) => roots.has(folder)));
const agents = [...folders]
.filter((folder) => fsOps.exists(nodePath.join(folder, "agent.yaml")))
.map((folder) => resolveAgentRef(`path:${folder}`, folder, fsOps))
.flatMap((result) => result.ok && result.resolved.spec.name === resolvedSpecName ? [result] : []);
if (agents.length !== 1) {
return { ok: false, error: agents.length === 0
? `The seat's agent spec (${resolvedSpecName ?? agentRef}) wasn't found by resolving ${agentRef} against a rig root its last launch recorded`
: `The seat's agent spec (${resolvedSpecName}) resolved to more than one folder: ${agents.map((agent) => agent.resolved.sourcePath).join(", ")}` };
}
return { ok: true, agent: agents[0]! };
}
/** Resolve the current selection from an agent reference and profile. An absolute `path:` ref is read where it points; a
* `local:` ref is resolved against the rig roots its saved launch recorded (#1530). Anything else, or a `local:` ref that
* doesn't resolve to exactly one folder holding the seat's spec, resolves to `unknown` rather than guessing. */
export function resolveDesiredClaudeActivityHooks(
agentRef: string | null,
profile: string | null,
fsOps: AgentResolverFsOps,
saved?: { resolvedSpecName: string | null; resolvedStartupFiles: readonly unknown[] },
): DesiredClaudeActivityHooks {
if (!agentRef?.startsWith("path:") || !isAbsolute(agentRef.slice(5)) || !profile) {
if (!agentRef || !profile) {
return { status: "unknown", evidence: "Current agent reference/profile has no established absolute source location" };
}
const resolved = resolveAgentRef(agentRef, dirname(agentRef.slice(5)), fsOps);
if (!resolved.ok) return { status: "unknown", evidence: "Current agent specification could not be resolved" };
let resolved: ResolvedAgent;
if (agentRef.startsWith("path:") && isAbsolute(agentRef.slice(5))) {
const result = resolveAgentRef(agentRef, dirname(agentRef.slice(5)), fsOps);
if (!result.ok) return { status: "unknown", evidence: "Current agent specification could not be resolved" };
resolved = result;
} else {
if (!saved) return { status: "unknown", evidence: "Current agent reference/profile has no established absolute source location" };
const found = agentResolvedAgainstRecordedRoots(agentRef, saved.resolvedSpecName, saved.resolvedStartupFiles, fsOps);
if (!found.ok) return { status: "unknown", evidence: found.error };
resolved = found.agent;
}
const selected = resolveProfileRuntimeResources(resolved.resolved, resolved.imports, profile);
if (!selected.ok) return { status: "unknown", evidence: selected.errors.join("; ") };
return {
@@ -33,15 +82,24 @@ export function resolveDesiredClaudeActivityHooks(
};
}
/** Read the live node record and resolve its current selection. A missing row
* resolves to `unknown`: the caller reports the gap instead of projecting. */
/** Read the live node record and its saved startup files, and resolve its current selection. A missing row resolves to
* `unknown`: the caller reports the gap instead of projecting. Saved startup files that can't be read are left out, so a
* `local:` ref that needs them stays `unknown`. */
export function createDesiredClaudeActivityHooksReader(
db: Database.Database,
fsOps: AgentResolverFsOps,
): (nodeId: string) => DesiredClaudeActivityHooks {
return (nodeId) => {
const node = db.prepare("SELECT agent_ref, profile FROM nodes WHERE id = ?").get(nodeId) as
{ agent_ref: string | null; profile: string | null } | undefined;
return resolveDesiredClaudeActivityHooks(node?.agent_ref ?? null, node?.profile ?? null, fsOps);
const node = db.prepare("SELECT agent_ref, profile, resolved_spec_name FROM nodes WHERE id = ?").get(nodeId) as
{ agent_ref: string | null; profile: string | null; resolved_spec_name: string | null } | undefined;
let resolvedStartupFiles: unknown[] | undefined;
try {
const row = db.prepare("SELECT resolved_files_json FROM node_startup_context WHERE node_id = ?").get(nodeId) as
{ resolved_files_json: string } | undefined;
const parsed: unknown = row ? JSON.parse(row.resolved_files_json) : undefined;
if (Array.isArray(parsed)) resolvedStartupFiles = parsed;
} catch { /* unreadable: left out */ }
return resolveDesiredClaudeActivityHooks(node?.agent_ref ?? null, node?.profile ?? null, fsOps,
resolvedStartupFiles ? { resolvedSpecName: node?.resolved_spec_name ?? null, resolvedStartupFiles } : undefined);
};
}
@@ -10,7 +10,8 @@ export interface GuardedDeliverySummary {
}
/** Historical attempts contain outcomes, never executable options or callbacks. */
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation }
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation;
watchdog?: { nextAction?: string } }
type Deliver = (session: string, text: string, opts: SendOpts) => Promise<SendResult>;
/** Applicability needs the intended queue row and recipient, not a saved delivery receipt. */
export type QueueWakeFacts = Pick<OutboxEntry, "outboxId" | "auditPointer" | "destinationSession">;
@@ -34,7 +35,7 @@ export class DraftAwareDelivery {
const entry = this.outbox.getById(id)!;
const state = entry.deliveryState === "retained" || entry.deliveryState === "retired" ? "held"
: entry.deliveryState === "delivered" ? "complete" : entry.deliveryState === "sending" ? "sending" : entry.deliveryState === "failed" ? "failed" : "indeterminate";
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason
const reason = entry.deliveryState === "retired" ? "message_retired" : receipt.result?.reason ?? receipt.watchdog?.nextAction
?? (state === "sending" ? "delivery_in_progress" : state === "complete" ? "delivered" : "interrupted_write");
return { id: receipt.ids[0]!, state, reason };
}
@@ -74,7 +75,9 @@ export class DraftAwareDelivery {
/* Legacy bookkeeping cannot veto input; report its absence with the actual result. */
}
try {
await this.guard.beforeInput(session);
// A paste the guard left unsubmitted (#2429) is recognized here, to be submitted ahead of this delivery, unless the
// delivery needs an idle seat: submitting it starts a turn, so that delivery is held as before.
await this.guard.beforeInput(session, false, undefined, opts.waitForIdleMs === undefined && !opts.preserveInput ? "recognize" : undefined);
result = await this.deliver(session, text, { ...opts,
onInputEffect: phase => { inputWritten = true; opts.onInputEffect?.(phase); },
beforeWrite: () => {
@@ -106,6 +109,9 @@ export class DraftAwareDelivery {
// A wait-path failure can follow a dispatched native write. Only a positive pre-input result proves no write.
const { sent: _sent, ...uncertain } = result; result = uncertain;
}
// Whatever became of this delivery's own text, its record says it submitted OpenRig's earlier paste.
if (this.guard.submittedOwnLeftover(session)) result = { ...result, ownPasteSubmitted: true,
warning: [result.warning, "OpenRig first submitted its own earlier paste, left unsubmitted in this seat's input."].filter(Boolean).join(" ") };
if (opts.deliveryId && !opts.committedOutboxIds) result = { ...result,
warning: [result.warning, "Delivery IDs do not deduplicate requests; this is a new input attempt."].filter(Boolean).join(" ") };
const state = noWrite ? "retained" : result.sent === false ? "failed" : result.ok && (result.verified || !opts.verify) ? "delivered" : "indeterminate";
@@ -328,6 +328,21 @@ export function participantsView(home: string = getOpenRigHome()): ParticipantsV
};
}
/** The participant JSON shared by the CLI and daemon read route. Never return the raw default-person fragment,
* which includes connector credential pointers; the participant records carry the public address and policy. */
export function participantsJsonView(home: string = getOpenRigHome(), organization?: string) {
const view = participantsView(home);
if (!view.ok) return view;
const side = readParticipantsFile(home);
const defaultPerson = view.default.person
? { id: view.default.person.entityId, source: view.default.source }
: { id: null, reason: view.default.source === "none" ? view.default.reason : null };
return { ok: true as const,
participants: organization ? view.participants.filter((participant) => participant.organization?.id === organization) : view.participants,
default: defaultPerson, organizations: side.ok ? side.file.organizations : null, groups: side.ok ? side.file.groups : null,
warnings: view.warnings, participantFile: view.sideFile };
}
// ── The writes behind the `rig gateway` verbs. Each goes through updateParticipantsFile, so each is validated the same
// way a read is and written atomically.
@@ -134,8 +134,9 @@ export function buildImageBlocks(mediaRefs: readonly SlackMediaRef[] | undefined
* exactly these (one producer, one parser: see parseQuestionAction). */
export const QUESTION_BLOCK_PREFIX = "or-q:";
export const OPTION_ACTION_PREFIX = "or-opt:";
const TYPED_REPLY_HINT = "Or reply in this thread with your own answer. A typed reply submits your answer without waiting for every question to be answered.";
const CHANNEL_REPLY_HINT = "Reply in this thread with your answer.";
export const TYPED_REPLY_HINT = "Or reply in this thread with your own answer. A typed reply submits your answer without waiting for every question to be answered.";
export const CHANNEL_REPLY_HINT = "Reply in this thread with your answer.";
export const HUMAN_REQUIRED_REPLY_HINT = "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation.";
/** Parse a clicked button back into its question and option ids; null if it is not ours. */
export function parseQuestionAction(blockId: unknown, actionId: unknown): { questionId: string; optionId: string } | null {
@@ -597,7 +598,7 @@ export function buildOutboundMessage(q: QitemLike, opts: OutboundMessageOpts): S
throw new HumanMessageShapeError("Extra blocks have no complete accessible fallback. Use mediaRefs for images or author supplemental human detail.");
}
const replyHint = q.ownerNotificationKind === "human-required"
? "Reply in this thread after this question to answer it. Earlier and extra messages still reach the agent as conversation." : null;
? HUMAN_REQUIRED_REPLY_HINT : null;
const fallback = (context: string | null, repeat: string | null = null) => [attr, headline, repeat, context, body, questionParts?.text, replyHint, evidence ? evidence.text : null, attachmentText, opts.reconcileMarker].filter(Boolean).join("\n");
// The gate context line is supplementary: when it alone would push the complete fallback over
// budget, the instance part alone is tried, and the line is left out only when even that
@@ -19,6 +19,7 @@ export function budgetLeft(budget: RecoveryBudget, now: number): boolean {
export class MethodBackoff {
private readonly until = new Map<string, number>();
blocked(method: string, now: number): boolean { return (this.until.get(method) ?? 0) > now; }
waitMs(method: string, now: number): number { return Math.max(0, (this.until.get(method) ?? 0) - now); }
hold(method: string, at: number): void { this.until.set(method, Math.max(at, this.until.get(method) ?? 0)); }
}
@@ -0,0 +1,243 @@
// Edit the posted primary, never re-render an old ask from its mutable queue body.
// Queue receipts are the durable backlog; successful edits alone get an edited receipt.
import type { QueueRepository } from "../../queue-repository.js";
import type { QueueTransition } from "../../queue-transition-log.js";
import { createHash } from "node:crypto";
import { resolveRegisteredHumanAddress } from "../human-registry.js";
import { callWebApi, type FetchImpl, type WebApiResult } from "./slack-api.js";
import { budgetLeft, HISTORY, REPLIES, MethodBackoff, newRecoveryBudget } from "./recovery-budget.js";
import { parsePostedStamp } from "./thread-seat-map.js";
import { TYPED_REPLY_HINT, CHANNEL_REPLY_HINT, HUMAN_REQUIRED_REPLY_HINT, QUESTION_BLOCK_PREFIX, escapeSlackText, redactSecrets } from "./message.js";
const UPDATE = "chat.update";
const EDITED = "slack-ask-settled";
const RATE_LIMIT = "slack-ask-edit-rate-limit";
const STATUS_BLOCK = "or-settled";
const permanentErrors = new Set(["missing_scope", "not_in_channel", "channel_not_found", "is_archived",
"cant_update_message", "invalid_blocks", "message_not_found", "thread_not_found", "original message unavailable"]);
const hints = new Set([TYPED_REPLY_HINT, CHANNEL_REPLY_HINT, HUMAN_REQUIRED_REPLY_HINT]);
const terminal = new Set(["done", "canceled", "denied", "failed", "handed-off"]);
const field = (note: string, key: string) => note.split(/\s+/).find((part) => part.startsWith(`${key}=`))?.slice(key.length + 1);
type Block = { type?: string; block_id?: string; elements?: Array<{ text?: string }>; [key: string]: unknown };
/** Remove only our answer controls, retaining authored content and reconciliation identity. */
function handledMessage(message: { text: string; blocks: Block[] }, status: string) {
let text = message.text;
const previousStatus = message.blocks.find((b) => b.block_id === STATUS_BLOCK)?.elements?.[0]?.text;
if (previousStatus && text.startsWith(previousStatus + "\n")) text = text.slice(previousStatus.length + 1);
const blocks = message.blocks.flatMap((block): Block[] => {
if (block.block_id === STATUS_BLOCK) return [];
if (block.type === "actions" && block.block_id?.startsWith(QUESTION_BLOCK_PREFIX)) {
// The labels remain history after their controls disappear, as in the resolved-notice render.
const buttons = block.elements as unknown as Array<{ text?: { text?: string }; style?: string }>;
return [{ type: "section", text: { type: "mrkdwn", text: buttons.map((b) => `• ${b.text?.text ?? ""}${b.style === "primary" ? " (recommended)" : ""}`).join("\n") } }];
}
const hint = block.type === "context" && block.elements?.length === 1 ? block.elements[0]?.text : undefined;
if (!hint || !hints.has(hint)) return [block];
// This generated hint's last occurrence, never a matching sentence earlier in the brief.
const at = text.lastIndexOf("\n" + hint);
if (at >= 0) text = text.slice(0, at) + text.slice(at + hint.length + 1);
return [];
});
return { text: status + "\n" + text,
blocks: [{ type: "context", block_id: STATUS_BLOCK, elements: [{ type: "mrkdwn", text: status }] }, ...blocks] };
}
function statusLine(t: QueueTransition, label?: string): string {
const what = label ?? (t.ownerNotificationKind === "human-decision-resolved" ? "Answered" : t.state === "done" ? "Done"
: t.state === "canceled" ? "Canceled" : terminal.has(t.state) ? t.state : "Handled");
const note = redactSecrets((t.transitionNote ?? "").replace(/\s+/g, " ")).slice(0, 1000);
return escapeSlackText([`${what}${t.closureReason ? ` · ${t.closureReason}` : ""}`, t.ts, `by ${t.actorSession}`, note].filter(Boolean).join(" · "));
}
export class SlackAskSettlement {
private readonly pending = new Map<string, number>();
private readonly failures = new Map<string, number>();
private readonly unavailable = new Set<string>();
private readonly backoff = new MethodBackoff();
private budget = newRecoveryBudget(Date.now());
private readonly controller = new AbortController();
private unsubscribe?: () => void;
private timer?: ReturnType<typeof setTimeout>;
private running = false;
private stopped = true;
private lastFailure: string | null = null;
constructor(private readonly opts: { queue: QueueRepository; token: string; fetchImpl?: FetchImpl; log: (message: string) => void }) {}
start(): void {
this.stopped = false;
this.unsubscribe = this.opts.queue.subscribeChanges((id) => this.enqueue(id));
for (const id of this.opts.queue.transitionLog.postedHumanAskIds()) {
for (const transition of this.opts.queue.transitionLog.listForQitem(id)) {
const note = transition.transitionNote ?? "";
if (!note.startsWith(RATE_LIMIT + " ")) continue;
const method = field(note, "method");
const until = Number(field(note, "retry_at"));
if (method && Number.isFinite(until)) this.backoff.hold(method, until);
}
this.enqueue(id);
}
}
stop(): void {
this.stopped = true;
this.unsubscribe?.();
if (this.timer) clearTimeout(this.timer);
this.controller.abort();
}
status() { return { pending: this.pending.size, lastFailure: this.lastFailure }; }
private enqueue(id: string): void {
if (this.stopped) return;
if (!this.pending.has(id)) this.pending.set(id, 0);
if (!this.running && !this.timer) this.schedule(0);
}
private schedule(ms: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => { this.timer = undefined; void this.pump(); }, Math.max(0, ms));
this.timer.unref?.();
}
private async pump(): Promise<void> {
this.running = true;
try {
while (!this.stopped && this.pending.size) {
const now = Date.now();
if (now >= this.budget.deadline) this.budget = newRecoveryBudget(now);
if (!budgetLeft(this.budget, now)) { this.schedule(this.budget.deadline - now); return; }
const next = [...this.pending].find(([, at]) => at <= now);
if (!next) { this.schedule(Math.min(...this.pending.values()) - now); return; }
const id = next[0];
const wait = await this.settleOne(id);
if (wait === null) this.pending.delete(id);
else { this.pending.delete(id); this.pending.set(id, Date.now() + wait); }
}
} catch (error) {
this.lastFailure = `settlement retained: ${(error as Error).message}`;
this.opts.log(this.lastFailure);
this.schedule(30_000);
} finally { this.running = false; }
}
private async api(method: string, body: Record<string, unknown>): Promise<WebApiResult> {
this.budget.calls++;
const fetchImpl: FetchImpl = (url, init) => (this.opts.fetchImpl ?? fetch)(url, { ...init,
signal: init?.signal ? AbortSignal.any([init.signal, this.controller.signal]) : this.controller.signal });
return callWebApi(method, this.opts.token, body, fetchImpl, Math.max(1, this.budget.deadline - Date.now()),
method === HISTORY || method === REPLIES ? "get-query" : "json-post");
}
private failed(id: string, key: string, method: string, result: WebApiResult): number {
this.lastFailure = `${id}: ${method} not done (${result.error ?? result.status})`;
this.opts.log(`slack ask settlement retained: ${this.lastFailure}`);
if (permanentErrors.has(result.error ?? "")) { this.unavailable.add(key); return 0; }
const count = (this.failures.get(key) ?? 0) + 1;
this.failures.set(key, count);
const wait = Math.max(Math.min(30_000 * 2 ** Math.min(count - 1, 7), 3_600_000), (result.retryAfterSeconds ?? 0) * 1000);
if (result.status === 429) {
const until = Date.now() + wait;
this.backoff.hold(method, until);
this.opts.queue.update({ qitemId: id, actorSession: "daemon@kernel",
transitionNote: `${RATE_LIMIT} method=${method} retry_at=${until}` });
}
return wait;
}
private desired(id: string, episode: QueueTransition, recipient: string, transitions: QueueTransition[]) {
const item = this.opts.queue.getById(id);
if (!item) return undefined;
const after = transitions.filter((t) => t.transitionId > episode.transitionId && ![
"slack-posted ", "slack-owner-notification-posted ", EDITED + " ", RATE_LIMIT + " ",
].some((prefix) => (t.transitionNote ?? "").startsWith(prefix)));
const make = (t: QueueTransition, label?: string) => {
const status = statusLine(t, label);
return { status, revision: createHash("sha256").update(status).digest("hex").slice(0, 24) };
};
const exit = after.find((t) => terminal.has(t.state) || t.ownerNotificationKind === "human-decision-resolved"
|| (episode.state === "blocked" && t.state !== "blocked"));
const replacement = after.find((t) => t.ownerNotificationKind === "human-required");
if (replacement) return exit && exit.transitionId < replacement.transitionId
? make(exit) : make(replacement, "Asked again");
const rerouted = after.find((t) => (t.transitionNote ?? "").startsWith("fallback-routed:"));
if (rerouted) return make(rerouted, "Handled");
if (terminal.has(item.state)) {
const latest = after.filter((t) => t.state === item.state).at(-1);
return latest ? make(latest) : undefined;
}
const registry = this.opts.queue.humanRegistry();
const samePerson = (address: string | null) => address === recipient || (registry.ok &&
resolveRegisteredHumanAddress(recipient, registry.entities) !== null &&
resolveRegisteredHumanAddress(address, registry.entities) === resolveRegisteredHumanAddress(recipient, registry.entities));
if (episode.state === "pending" && samePerson(item.destinationSession)) {
const reopened = after.filter((t) => (t.transitionNote ?? "").startsWith("reopen acknowledged:")).at(-1);
if (reopened) {
const answered = this.opts.queue.getHumanAnswer(id, recipient) ?? this.opts.queue.committedAnswerFromAnother(id, recipient);
return make(reopened, answered ? "Open again · Earlier answer retained" : "Open again · Reply in this thread to answer");
}
}
// A reopened work row does not reopen the older park it already left. A new human park has its own episode above.
if (exit) return make(exit);
if (episode.state === "blocked" && item.state === "blocked" && registry.ok && !samePerson(item.blockedOn)) {
const moved = after.at(-1);
if (moved) return make(moved, "Handled");
}
return undefined;
}
/** null = no remaining edits; 0 = next primary; positive = retained retry. */
private async settleOne(id: string): Promise<number | null> {
if (!this.opts.queue.db.prepare("SELECT 1 FROM queue_items WHERE qitem_id = ?").get(id)) return null;
const transitions = this.opts.queue.transitionLog.listForQitem(id);
for (const receipt of transitions) {
const note = receipt.transitionNote ?? "";
if (!note.startsWith("slack-owner-notification-posted ") || field(note, "kind") !== "human-required" || field(note, "digest")) continue;
const key = field(note, "notification_key");
const episode = transitions.find((t) => `${id}:${t.transitionId}` === key);
if (!episode) continue;
const messageTs = field(note, "message_ts");
const stamp = transitions.filter((t) => t.transitionId < receipt.transitionId)
.map((t) => parsePostedStamp(t.transitionNote ?? ""))
.filter((s) => s !== null && s.messageTs === messageTs && s.origin === "posted").at(-1);
if (!stamp) continue; // No authoritative channel/timestamp: never guess a replacement post.
const edited = `${EDITED} notification_key=${key} channel=${stamp.channel} message_ts=${messageTs}`;
if (this.unavailable.has(edited)) continue;
const desired = this.desired(id, episode, stamp.human, transitions);
if (!desired) continue;
const lastAck = transitions.filter((t) => t.transitionNote === edited || t.transitionNote?.startsWith(edited + " ")).at(-1);
if (lastAck && field(lastAck.transitionNote!, "revision") === desired.revision) continue;
if (this.budget.calls + 2 > 4) return Math.max(1, this.budget.deadline - Date.now());
const method = stamp.messageTs === stamp.threadTs ? HISTORY : REPLIES;
const held = Math.max(this.backoff.waitMs(method, Date.now()), this.backoff.waitMs(UPDATE, Date.now()));
if (held) return held;
// Exact original message, including on an upgrade with only the old posted stamps.
const read = await this.api(method, { channel: stamp.channel, limit: 1, inclusive: true,
...(method === HISTORY ? { latest: messageTs } : { ts: stamp.threadTs, oldest: messageTs }) });
if (this.stopped) return null;
if (!read.ok) return this.failed(id, edited, method, read);
this.budget.entries++;
const message = (Array.isArray(read.json.messages) ? read.json.messages : []).find((m) => m?.ts === messageTs);
if (!message || typeof message.text !== "string" || !Array.isArray(message.blocks)) {
return this.failed(id, edited, method, { ...read, error: "original message unavailable" });
}
if (!budgetLeft(this.budget, Date.now())) return 0;
// A queue change during the read may add information; derive again before writing.
const current = this.desired(id, episode, stamp.human, this.opts.queue.transitionLog.listForQitem(id));
if (!current || this.stopped) return null;
const update = await this.api(UPDATE, { channel: stamp.channel, ts: messageTs, ...handledMessage(message, current.status) });
if (this.stopped) return null;
if (!update.ok) return this.failed(id, edited, UPDATE, update);
if (update.json.ts !== messageTs || update.json.channel !== stamp.channel) {
return this.failed(id, edited, UPDATE, { ...update, error: "update acknowledgement did not name the requested message" });
}
const afterUpdate = this.desired(id, episode, stamp.human, this.opts.queue.transitionLog.listForQitem(id));
if (afterUpdate?.revision !== current.revision) return 0; // The write landed for an obsolete state; reconcile it again.
this.failures.delete(edited);
this.opts.queue.update({ qitemId: id, actorSession: "daemon@kernel", transitionNote: `${edited} revision=${current.revision}` });
return 0;
}
return null;
}
}
@@ -147,10 +147,23 @@ export async function callWebApi(
...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retryAfterSeconds: retryAfter } : {}), error: ok ? undefined : String(json.error ?? `http ${res.status}`) };
});
} catch (e) {
return { ok: false, status: 0, grantedScopes: [], json: {}, error: `web-api transport: ${(e as Error).message}` };
return { ok: false, status: 0, grantedScopes: [], json: {}, error: `web-api transport: ${withoutToken((e as Error).message, token)}` };
}
}
/** A transport error's message can quote the request's Authorization header (Node's fetch does for an invalid value),
* so the token, and each line of it, is removed before the message goes anywhere, as sent and as trimmed (Node trims
* the value before quoting it). A line shorter than 6 characters is left: removing it everywhere would garble the rest
* of the message. One pass, so a marker is never rescanned. */
function withoutToken(message: string, token: string): string {
const lines = token.split(/[\r\n]+/).flatMap((line) => [line, line.trim()]).filter((part) => part.length >= 6);
const parts = [...new Set([token, token.trim(), ...lines])].filter((part) => part.length > 0)
.sort((a, b) => b.length - a.length);
if (parts.length === 0) return message;
const pattern = new RegExp(parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
return message.replace(pattern, "[redacted]");
}
/** Item 5: read the ACTUAL granted scopes from the response header (configured != granted). */
export async function getGrantedScopes(
token: string,
@@ -251,7 +264,7 @@ export async function downloadPrivateFile(
return { ok: true as const, bytes };
});
} catch (e) {
return { ok: false, error: (e as Error).message || "download failed" };
return { ok: false, error: withoutToken((e as Error).message, token) || "download failed" };
}
}
@@ -289,6 +302,49 @@ export async function verifyChannelMembership(
return { ok: true, isMember: ch.is_member === true, name: ch.name };
}
/** One page of a directory read: only the projected fields, never Slack's whole object; on failure, Slack's own result. */
export type DirectoryPage<T> = { ok: true; items: T[]; nextCursor: string | null } | { ok: false; result: WebApiResult };
export interface DirectoryChannel { id: string; name: string; isMember: boolean }
export interface DirectoryUser { id: string; name: string; realName: string | null; displayName: string | null; isBot: boolean; deleted: boolean }
function directoryPage<T>(r: WebApiResult, key: string, project: (raw: Record<string, unknown>) => T): DirectoryPage<T> {
if (!r.ok) return { ok: false, result: r };
const raw = Array.isArray(r.json[key]) ? (r.json[key] as Record<string, unknown>[]) : [];
const next = (r.json.response_metadata as { next_cursor?: unknown } | undefined)?.next_cursor;
return { ok: true, items: raw.map(project), nextCursor: typeof next === "string" && next ? next : null };
}
/** One page of the workspace's public, unarchived channels (conversations.list; channels:read). */
export async function listPublicChannels(
token: string,
page: { cursor?: string; limit: number },
fetchImpl: FetchImpl = defaultFetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<DirectoryPage<DirectoryChannel>> {
const r = await callWebApi("conversations.list", token,
{ types: "public_channel", exclude_archived: true, limit: page.limit, cursor: page.cursor }, fetchImpl, timeoutMs, "get-query");
return directoryPage(r, "channels", (c) => ({ id: String(c.id), name: String(c.name ?? ""), isMember: c.is_member === true }));
}
/** One page of the workspace's people (users.list; users:read), without email or other profile fields. */
export async function listUsers(
token: string,
page: { cursor?: string; limit: number },
fetchImpl: FetchImpl = defaultFetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<DirectoryPage<DirectoryUser>> {
const r = await callWebApi("users.list", token, { limit: page.limit, cursor: page.cursor }, fetchImpl, timeoutMs, "get-query");
return directoryPage(r, "members", (u) => {
const profile = (u.profile ?? {}) as { display_name?: unknown };
return {
id: String(u.id), name: String(u.name ?? ""),
realName: typeof u.real_name === "string" ? u.real_name : null,
displayName: typeof profile.display_name === "string" && profile.display_name ? profile.display_name : null,
isBot: u.is_bot === true, deleted: u.deleted === true,
};
});
}
/** Inbound Socket Mode: open a WebSocket URL via apps.connections.open (app-level xapp token). */
export async function openSocketConnection(
appToken: string,
@@ -26,6 +26,7 @@ import { resolveSecret } from "./secrets.js";
import { SeenStore, DeadLetterStore, InboundReceiptStore, slackMicros } from "./state-store.js";
import { makeQueuePorts } from "./queue-access.js";
import { SlackOutboundDriver, OUTBOUND_OP, type OutboundPostPayload } from "./outbound-driver.js";
import { SlackAskSettlement } from "./settled-asks.js";
import { abortableSleep, subsystemSlackDeliver } from "./slack-delivery.js";
import { InboundRouter, SLACK_THREAD_ROOT_TAG, handleEnvelope, type SocketEnvelope, type SlackEvent, type SlackBlockActions, type InboundFilePort, type InboundFileResult, type StoredInboundFile, type FailedInboundFile } from "./inbound.js";
import { makeInboundSenderResolver, type RegistrySurface } from "./inbound-admission.js";
@@ -1006,6 +1007,11 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
// hermeticity rule, same as every monitor in the supervision tree.
const stops: Array<() => void> = [];
const starts: Array<() => void> = [];
const settlement = outboundReady ? new SlackAskSettlement({ queue: opts.queueRepo, token: bot!, fetchImpl: opts.fetchImpl, log }) : null;
if (settlement) {
starts.push(() => settlement.start());
stops.push(() => settlement.stop());
}
let inboundHandle: SocketInboundHandle | undefined;
let recovery: SlackRecovery | undefined;
// Recovery covers the channels this machine owns and the threads tracked in them. The configured
@@ -1200,6 +1206,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
platform: "slack", configurationDigest: channelStateDigest(cfg),
outboundReady,
inboundReady,
...(settlement ? { settlement: settlement.status() } : {}),
inboundRouting,
recovery: recovery?.status() ?? { state: "unavailable", reason: "inbound-not-configured" },
inbound: inboundHandle?.status() ?? { state: inboundReady ? "not-started" : "not-configured", generation: 0, reconnects: 0 },
@@ -59,7 +59,8 @@ export interface KernelBootStatus {
* terminal/skipped states. */
firstUnreadySince: string | null;
/** Filename of the picked variant (rig.yaml / rig-claude-only.yaml /
* rig-codex-only.yaml). null when no variant was selected. */
* rig-codex-only.yaml), retained from automatic boot across daemon restarts.
* null when no selection was recorded. This is origin metadata, not the current runtime mix. */
variant: string | null;
/** Human-readable detail for auth_blocked / spec_missing /
* bootstrap_failed / degraded states. null otherwise. */
@@ -105,10 +106,11 @@ export class KernelBootTracker {
/** Mark the tracker as having intentionally not booted (--no-kernel,
* already-managed short-circuit, VITEST auto-skip). Terminal state;
* no degraded timer. */
setSkipped(detail: string): void {
setSkipped(detail: string, variant: string | null = null): void {
this.cancelTimer();
this.state = "skipped";
this.detail = detail;
this.variant = variant;
this.firstUnreadySince = null;
}
+7 -3
View File
@@ -85,6 +85,10 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
degradedTimeoutMs: deps.degradedTimeoutMs,
});
const kernels = deps.rigRepo.findUnarchivedRigsByName("kernel");
const recordedVariant = kernels.length === 1
? deps.rigRepo.getKernelVariantFromBootstrap(kernels[0]!.id, deps.specsDir) : null;
// 1. --no-kernel opt-out (CLI flag projected via OPENRIG_NO_KERNEL).
// VITEST env additionally short-circuits the live runtime probe
// path so daemon-composition tests don't block 5s+ on shelled-out
@@ -98,7 +102,7 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
if (process.env["OPENRIG_NO_KERNEL"] === "1" || (process.env["VITEST"] === "true" && !probeInjected)) {
const reason = process.env["OPENRIG_NO_KERNEL"] === "1" ? "OPENRIG_NO_KERNEL=1" : "VITEST=true";
log("info", `kernel-boot: ${reason} — skipping kernel auto-boot`);
tracker.setSkipped(reason);
tracker.setSkipped(reason, recordedVariant);
return tracker;
}
@@ -111,14 +115,14 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
// Startup reconcile found none of its seats running and marked them detached: a reboot or
// crash took it down, not `rig down` (which marks them exited). Bring it back as it was.
log("info", "kernel-boot: kernel rig was lost (all seats detached); restoring it");
tracker.startBooting(null, deps.restoreLostKernel(managed.rigId), managed.seats, { existingRestore: true });
tracker.startBooting(recordedVariant, deps.restoreLostKernel(managed.rigId), managed.seats, { existingRestore: true });
return tracker;
}
log("info", `kernel-boot: kernel rig already managed (${managed.kind}); skipping builtin boot`);
// A stale kernel stays down on purpose; say why and how to bring it back (`rig start` prints it).
tracker.setSkipped(managed.kind === "stale"
? "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing"
: "kernel rig already managed");
: "kernel rig already managed", recordedVariant);
if (managed.kind === "live") tracker.observeManagedKernel();
return tracker;
}
@@ -0,0 +1,129 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import nodePath from "node:path";
import type Database from "better-sqlite3";
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
export interface ManagedBlockRecords {
/** The hash of what OpenRig last wrote in this block of this file, or undefined when there's no record. Throws when the
* record can't be read, which is unknown, never "no record". */
get(file: string, blockId: string): string | undefined;
/** Record that OpenRig's block in this file now holds `body`. Throws when the record can't be saved. */
set(file: string, blockId: string, body: string): void;
}
/** The hash a record keeps: sha256 of the body without the writer's framing, so framing never reads as an edit. */
export function managedBlockBodyHash(body: string): string {
return createHash("sha256").update(frameManagedBlockBody(body)).digest("hex");
}
/** A guidance file's key: the file's own real path, so every link to one file names the same file; a file that doesn't
* exist yet is keyed by its folder's real path and its name. */
export function guidanceFileKey(file: string): string {
const resolved = nodePath.resolve(file);
try { return fs.realpathSync.native(resolved); } catch { /* not there yet */ }
try { return nodePath.join(fs.realpathSync.native(nodePath.dirname(resolved)), nodePath.basename(resolved)); }
catch { return resolved; }
}
/** Whether a database error is a schema without the records table (an older test or tool schema), which has no records. */
function noRecordsTable(err: unknown): boolean {
return err instanceof Error && err.message.includes("no such table: managed_block_writes");
}
/** The records kept in the daemon database (`managed_block_writes`). A database without that table has no records: reads
* find none and writes record nothing, so its blocks read as unrecorded. Any other failure is thrown, so a caller can
* name it instead of reading it as no record. */
export function managedBlockRecords(db: Pick<Database.Database, "prepare">, now: () => Date = () => new Date()): ManagedBlockRecords {
return {
get(file, blockId) {
try {
const row = db.prepare("SELECT body_sha256 FROM managed_block_writes WHERE path_key = ? AND block_id = ?")
.get(guidanceFileKey(file), blockId) as { body_sha256: string } | undefined;
return row?.body_sha256;
} catch (err) {
if (noRecordsTable(err)) return undefined;
throw err;
}
},
set(file, blockId, body) {
try {
db.prepare(`INSERT INTO managed_block_writes (path_key, block_id, body_sha256, written_at) VALUES (?, ?, ?, ?)
ON CONFLICT(path_key, block_id) DO UPDATE SET body_sha256 = excluded.body_sha256, written_at = excluded.written_at`)
.run(guidanceFileKey(file), blockId, managedBlockBodyHash(body), now().toISOString());
} catch (err) {
if (!noRecordsTable(err)) throw err;
}
},
};
}
/** Record a block just written. A record that can't be saved is named in `notes`; the write itself still counts. */
export function recordManagedBlockWrite(records: ManagedBlockRecords, targetPath: string, blockId: string, body: string, notes?: string[]): void {
try { records.set(targetPath, blockId, body); }
catch { notes?.push(`guidance_block_record_not_saved: ${targetPath}: ${blockId} was written, but its write record wasn't saved, so a later edit may read as unrecorded`); }
}
/** The note for a block left as it is, named for the seat and the person. */
export function keptGuidanceNote(standing: "edited" | "unrecorded", targetPath: string, blockId: string, sourcePath: string): string {
return standing === "edited"
? `guidance_block_edited: ${targetPath}: ${blockId} was edited by hand since OpenRig wrote it, so it wasn't refreshed from ${sourcePath}`
: `guidance_block_may_be_stale: ${targetPath}: ${blockId} differs from ${sourcePath} and OpenRig has no record of writing it, so it was left as it is`;
}
/** The note for a block a start replaced although OpenRig had no record of writing it. */
export function refreshedUnrecordedGuidanceNote(targetPath: string, blockId: string, sourcePath: string): string {
return `guidance_block_refreshed_unrecorded: ${targetPath}: ${blockId} was replaced from ${sourcePath}; OpenRig had no record of writing it, as for every block written before records were kept`;
}
/** #2456: what to do with one block, from the file's text, the source's text and the record:
* - write it when it's absent (unless `onlyIfPresent`), current, OpenRig's own last write, or (`unrecorded: "refresh"`,
* a start, as starts always replaced) different with no record, which is named;
* - keep it, named, when a person edited it since OpenRig wrote it, or (`"keep"`, exact resume and handover) when it
* differs with no record;
* - keep it, named, when it differs and its record can't be read (`unknown`): that's never read as no record. */
export function decideManagedBlock(content: string, blockId: string, source: string, records: ManagedBlockRecords, targetPath: string,
opts: { unrecorded: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string }): { standing: ManagedBlockStanding | "unknown"; write: boolean; note?: string } {
let recorded: string | undefined;
let unreadable = false;
try { recorded = records.get(targetPath, blockId); } catch { unreadable = true; }
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
const named = opts.sourcePath ?? "its source";
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
if (unreadable && standing === "unrecorded") {
return { standing: "unknown", write: false,
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
}
if (standing === "edited" || (standing === "unrecorded" && opts.unrecorded === "keep")) {
return { standing, write: false, note: keptGuidanceNote(standing, targetPath, blockId, named) };
}
if (standing === "unrecorded") return { standing, write: true, note: refreshedUnrecordedGuidanceNote(targetPath, blockId, named) };
return { standing, write: true };
}
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
* whether the block was written. */
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
warnings?: string[]; replaceBlockIds?: string[];
}): boolean {
const merge = () => mergeManagedBlock(files, targetPath, blockId, content, { warnings: opts.warnings, replaceBlockIds: opts.replaceBlockIds ?? [] });
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
if (!opts.records) {
if (opts.onlyIfPresent) return false;
merge();
return true;
}
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
if (decision.note) opts.warnings?.push(decision.note);
if (!decision.write) return false;
merge();
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
return true;
}
+31 -2
View File
@@ -107,11 +107,40 @@ function managedBlockPattern(blockId: string): RegExp {
return new RegExp(`${escapeRegex(MANAGED_BLOCK_START(blockId))}([\\s\\S]*?)${escapeRegex(MANAGED_BLOCK_END(blockId))}`, "g");
}
/** A block body without the writer's own framing: CRLF line endings become LF, and newlines at either end go. */
export function frameManagedBlockBody(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
}
/** Whether a block's body still holds `written`: the same bytes, ignoring only the writer's own framing (newlines at either
* end, and CRLF line endings). A person's spaces count, so an indented line is an edit. */
export function managedBlockBodyMatches(body: string, written: string): boolean {
const frame = (text: string) => text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
return frame(body) === frame(written);
return frameManagedBlockBody(body) === frameManagedBlockBody(written);
}
/** The bodies of every `blockId` block in `content`, in the current or the legacy marker form. */
export function managedBlockBodies(content: string, blockId: string): string[] {
const legacy = new RegExp(`${escapeRegex(LEGACY_BLOCK_START(blockId))}([\\s\\S]*?)${escapeRegex(LEGACY_BLOCK_END(blockId))}`, "g");
return [...content.matchAll(managedBlockPattern(blockId)), ...content.matchAll(legacy)].map((m) => m[1]!);
}
/** #2456: how a block already in a guidance file stands against its current source and OpenRig's record of what it last
* wrote there (a hash of the framed body, `managedBlockBodyHash`):
* - `absent`: the file holds no such block;
* - `current`: it holds the source's text;
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
* - `edited`: it holds something else, which someone wrote by hand;
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
* A block written more than once counts as edited when any copy is. */
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
hash: (body: string) => string): ManagedBlockStanding {
const bodies = managedBlockBodies(content, blockId);
if (bodies.length === 0) return "absent";
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
if (recordedHash === undefined) return "unrecorded";
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
}
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
@@ -39,6 +39,7 @@
// closure_target+handed_off_to=<destination>;
// creates new qitem at destination (4-step canonical)
import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import type { EventBus } from "../event-bus.js";
import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
@@ -100,6 +101,9 @@ export interface MissionControlActionInput {
* the default person or the claim; the audit notes say whose ask it was. Only a request with no transport identity
* carries it. A surface that knows its person sends them without it. */
authority?: "primary-user";
/** #2446, opt-in: the `decisionRevision` the caller read. A settling act then runs only on that decision; omitted, the
* act and its result are exactly as before. An `operationId` in `auditNotes` lets a retry be answered from the record. */
expectedRevision?: string;
/**
* For handoff: opt-in best-effort wake. Default true (PL-004 Phase A R1
* pattern: durable + waking by default; operators opt out for cold queues).
@@ -119,6 +123,12 @@ export interface MissionControlActionResult {
auditedAt: string;
/** For `answer`: recorded with questions still open, or answered (returned to the asker, and the ask closed). */
answer?: { outcome: "recorded" | "answered"; unanswered: string[]; message?: string };
/** With expectedRevision only: the decision's revision as saved. */
decisionRevision?: string;
/** With expectedRevision only: this retry is the caller's own committed operation, answered from its record. */
reconciled?: true;
/** With expectedRevision on a verb that doesn't settle a decision: it wasn't checked, and the act ran as without it. */
revisionNote?: string;
}
interface WriteContractDeps {
@@ -150,6 +160,41 @@ export class MissionControlWriteContract {
* notify subscribers + opt-in transport wake (handoff only).
*/
async act(input: MissionControlActionInput): Promise<MissionControlActionResult> {
if (input.expectedRevision === undefined) return this.actOn(input);
// Only the settling verbs compare it. Another verb acts as today, and says the revision wasn't checked.
if (!SETTLING_VERBS.has(input.verb)) {
return { ...await this.actOn(input), revisionNote: `expectedRevision isn't checked for ${input.verb}; only answer, resolve, approve and deny compare it.` };
}
// #2446: the act runs only on the decision its caller read. No await separates this check from the act's first
// write, so no other request in this daemon changes the decision in between. The one path whose first write follows
// an await, a typed answer to a runner's permission ask, compares again before it (answerAsk).
const operation = typeof input.auditNotes?.operationId === "string" ? operationFingerprint(input) : null;
const item = this.queueRepo.getById(input.qitemId);
const current = item ? this.queueRepo.decisionRevision(item) : null;
if (item && current !== input.expectedRevision) {
const last = this.actionLog.listForQitem(input.qitemId, 1)[0];
// The caller's own committed operation retried after an uncertain result: answered from its record, nothing written.
if (operation && last?.auditNotes?.operation === operation) {
return { actionId: last.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: item, createdQitemId: null,
notifyAttempted: false, notifyResult: null, auditedAt: last.actedAt, reconciled: true, decisionRevision: current! };
}
throw this.revisionChanged(item, current!);
}
const result = await this.actOn(operation ? { ...input, auditNotes: { ...input.auditNotes, operation } } : input);
const saved = this.queueRepo.getById(input.qitemId);
return saved ? { ...result, decisionRevision: this.queueRepo.decisionRevision(saved) } : result;
}
/** A guarded act's decision changed since its revision was read: nothing was written, and the caller gets what's saved. */
private revisionChanged(item: QueueItem, current: string): MissionControlWriteContractError {
const last = this.actionLog.listForQitem(item.qitemId, 1)[0];
return new MissionControlWriteContractError("decision_revision_changed",
`qitem ${item.qitemId} changed since this revision was read, so nothing was written. Read it again for the current decision.`,
{ qitemId: item.qitemId, currentRevision: current, state: item.state, humanAnswers: item.humanAnswers ?? null,
lastAction: last ? { actionId: last.actionId, verb: last.actionVerb, actorSession: last.actorSession, actedAt: last.actedAt, auditNotes: last.auditNotes } : null });
}
private async actOn(input: MissionControlActionInput): Promise<MissionControlActionResult> {
if (input.verb === "annotate") {
return this.annotateOnly(input);
}
@@ -228,7 +273,7 @@ export class MissionControlWriteContract {
// P34: stage the successor's WAKE INTENT inside this transaction, so the
// close and the durable wake commit as ONE act or NONE. The pane write
// itself stays post-commit (reversed-never).
this.queueRepo.stageWakeIntent(
this.queueRepo.stageHandoffWake(
created.qitemId,
input.actorSession,
created.destinationSession,
@@ -573,6 +618,7 @@ export class MissionControlWriteContract {
const resume = !chose && !words && questions.length > 0 && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0;
const answersReply = (answers: HumanAnswers) => `${formatHumanAnswers(questions, answers).join("\n")}\n\n---\nAnswers (question id → option id): ${JSON.stringify(answers)}\nIn reply to: ${input.qitemId} (its humanAnswers field holds the same)`;
let reply: string;
let wrote = false; // whether this call changed the ask before the return below awaits
if (chose) {
const recorded = this.queueRepo.recordHumanAnswer({ qitemId: input.qitemId, actorSession: input.actorSession,
questionId: input.questionId ?? "", optionId: input.optionId ?? "", primaryUser });
@@ -580,6 +626,8 @@ export class MissionControlWriteContract {
throw new MissionControlWriteContractError("answer_not_recorded", `The answer wasn't recorded: ${recorded.reason}.`,
{ qitemId: input.qitemId, reason: recorded.reason });
}
// An already-complete set (an interrupted return) comes back without a write.
wrote = JSON.stringify(recorded.answers) !== JSON.stringify(ask.humanAnswers ?? {});
const [just] = formatHumanAnswers(recorded.questions.filter((q) => q.id === input.questionId), recorded.answers);
if (!recorded.complete) {
return this.recordAnswer(input, before, just ?? `answered ${input.questionId}`, {
@@ -624,6 +672,15 @@ export class MissionControlWriteContract {
const decision = chose || resume
? formatHumanAnswers(questions, this.queueRepo.getById(input.qitemId)?.humanAnswers ?? {}).join("; ")
: returned.qitemId === input.qitemId ? words : humanAnswerText(returned.body);
// #2446: on a runner's permission ask (no reply row), a call that changed nothing before the await above (typed words,
// or a choice whose set was already complete) still has its first write ahead, and the await let other requests in, so
// a guarded answer compares its revision again here, with no await before the close. A call that wrote was compared
// before that write.
if (input.expectedRevision !== undefined && replyId === input.qitemId && !wrote) {
const now = this.queueRepo.getById(input.qitemId);
const revision = now ? this.queueRepo.decisionRevision(now) : null;
if (now && revision !== input.expectedRevision) throw this.revisionChanged(now, revision!);
}
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })
&& this.queueRepo.getById(input.qitemId)?.state !== "done") {
throw new MissionControlWriteContractError("answer_not_closed",
@@ -720,6 +777,14 @@ interface ClosureMapping {
blockedOn?: string;
}
/** One logical operation (#2446): the caller's operationId with the verb, who sent it and what it carries, so a retry
* matches only itself, never another verb or payload sent under the same ID. */
function operationFingerprint(input: MissionControlActionInput): string {
const sent = [input.auditNotes?.operationId, input.verb, input.actorSession, input.authority ?? null, input.questionId ?? null,
input.optionId ?? null, input.decision ?? null, input.reason ?? null, input.destinationSession ?? null, input.body ?? null, input.annotation ?? null];
return createHash("sha256").update(JSON.stringify(sent)).digest("hex").slice(0, 24);
}
function verbToClosure(input: MissionControlActionInput): ClosureMapping {
switch (input.verb) {
case "approve":
@@ -1,5 +1,6 @@
import { isShellForeground } from "./shell-classifier.js";
import { EMPTY_CLAUDE_MCP_CONFIG } from "./claude-mcp.js";
import { isClaudePluginChannel } from "./claude-plugin-channels.js";
import { runAsyncSite } from "./sync-site-wrap.js";
import { readNativeExecutablePaths } from "./native-process-executable.js";
import { readNativeProcessArguments } from "./native-process-arguments.js";
@@ -284,6 +285,23 @@ export function claudePathBounds(path: string): boolean {
return parts.length > 0 && parts.join(" ") === path.trim() && !parts[0]!.startsWith("-") && !parts.some(opensSpan);
}
// #2044: a seat's plugin channels, read the same way by both selectors. Claude's `--channels` is variadic: it takes
// every following value up to the next option, and OpenRig passes one or more `plugin:<name>@<marketplace>` entries
// there. Every value must be such an entry, so a bare word or a `server:` entry after the option is no proof;
// `--channels=<entry>` carries one entry. Returns the index of the last value; null when there is none or one isn't an
// entry; undefined when the argument isn't the option.
function claudeChannelsValueEnd(args: string[], index: number): number | null | undefined {
const arg = args[index]!;
if (arg.startsWith("--channels=")) return isClaudePluginChannel(arg.slice("--channels=".length)) ? index : null;
if (arg !== "--channels") return undefined;
let end = index;
while (end + 1 < args.length && !args[end + 1]!.startsWith("-")) {
if (!isClaudePluginChannel(args[end + 1])) return null;
end += 1;
}
return end > index ? end : null;
}
/** The conversation a parsed Claude argv names. A fork's --resume names its parent, so with
* --fork-session only the fork's own --session-id names it, and both must be present. Without
* --fork-session one identity names it and two prove nothing. undefined: no positive proof. */
@@ -323,6 +341,9 @@ function claudeSessionToken(args: string[]): string | null {
const additionEnd = claudeAdditionValueEnd(args, index);
if (additionEnd === null) return null;
if (additionEnd !== undefined) { index = additionEnd; continue; }
const channelsEnd = claudeChannelsValueEnd(args, index);
if (channelsEnd === null) return null;
if (channelsEnd !== undefined) { index = channelsEnd; continue; }
if (arg === "--fork-session") { fork = true; continue; }
const identity = arg.match(/^--(session-id|resume)(?:=(.*))?$/);
if (!identity) return null; // Unknown argv is not positive identity proof.
@@ -366,6 +387,9 @@ function claudeSessionIdentity(args: string[]): string | null | { unparsed: true
const additionEnd = claudeAdditionValueEnd(args, index);
if (additionEnd === null) return unparsed;
if (additionEnd !== undefined) { index = additionEnd; continue; }
const channelsEnd = claudeChannelsValueEnd(args, index);
if (channelsEnd === null) return unparsed;
if (channelsEnd !== undefined) { index = channelsEnd; continue; }
if (arg === "--fork-session") { fork = true; continue; }
const identity = arg.match(/^--(session-id|resume)(?:=(.*))?$/);
if (!identity) return unparsed; // Unknown argv is not positive identity proof.
+24 -2
View File
@@ -848,9 +848,28 @@ function buildInventoryEntry(
// never observed; carries evidence on mismatch/missing.
identityVerdict,
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, sessionStatus),
...(row.handover_at ? startedWithoutRequired(db, row.node_id, row.session_id) : {}),
};
}
/** #2456: the required startup files the seat's current occupant started without, as its handover recorded them. Only
* the handover whose successor session is still the current one counts, so a later launch or handover clears it. Read
* only for a seat that has had a handover. */
function startedWithoutRequired(db: Database.Database, nodeId: string, sessionId: string | null): { startedWithoutRequired?: string[] } {
if (!sessionId) return {};
const row = db.prepare(
"SELECT payload FROM events WHERE node_id = ? AND type = 'seat.handover_completed' ORDER BY seq DESC LIMIT 1"
).get(nodeId) as { payload: string } | undefined;
if (!row) return {};
try {
const event = JSON.parse(row.payload) as { startedWithoutRequired?: unknown; successorSessionId?: unknown };
const files = Array.isArray(event.startedWithoutRequired) ? event.startedWithoutRequired.filter((f): f is string => typeof f === "string") : [];
return event.successorSessionId === sessionId && files.length > 0 ? { startedWithoutRequired: files } : {};
} catch {
return {};
}
}
/** FS-1 W1.2 — all-rigs batched form of `readRigWorkspaceJson`: one query,
* `rigId → WorkspaceSpec`. Rigs with no/malformed workspace are absent (callers
* default to null — byte-identical to `readRigWorkspaceJson`'s null return). */
@@ -994,7 +1013,8 @@ export function getNodeDetail(
// The seat's guidance file as its runtime resolves it now, and what it holds, read once for the guidance entries.
const guidanceEvidence = (): { file: string | null; content: string | null } => {
const runtime = (ctxRow as { runtime?: string | null } | undefined)?.runtime ?? entry.runtime;
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId)) : null;
const recorded = (ctxRow as { guidance_file?: string | null } | undefined)?.guidance_file;
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId), recorded) : null;
let content: string | null = null;
if (file) {
try { content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; } catch { content = null; }
@@ -1216,6 +1236,7 @@ const NEEDS_INPUT_RUNGS: readonly EvidenceRungId[] = ["needs-input-chrome", "lif
* and the seat row must not disagree about the same seat.
* - a held seat (`heldReason` present)
* - a recorded startup error (`latestError` present)
* - an occupant a handover started without a required startup file (`startedWithoutRequired`, #2456)
*/
export function seatNeedsAttention(
entry: NodeInventoryEntry,
@@ -1227,7 +1248,8 @@ export function seatNeedsAttention(
|| entry.startupStatus === "failed"
|| (arbitratedNeedsInput ?? activity?.state === "needs_input")
|| entry.heldReason != null
|| entry.latestError != null;
|| entry.latestError != null
|| (entry.startedWithoutRequired?.length ?? 0) > 0;
}
/**
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more