Compare commits

...
Author SHA1 Message Date
dev-guard e2c4fddc4a test(kernel): reproduce hook review behind managed Codex shell 2026-11-02 22:54:57 +00:00
Mike Schwarz 8fca221aab fix(kernel): start a new kernel in the workspace's own kernel folder (#2402)
A newly created default kernel starts in `<workspace.root>/kernel` instead of `workspace.root`, through both creation paths (daemon start and the TUI's first setup), so a Claude kernel seat's managed `CLAUDE.md` no longer lands above the default projects folder and isn't loaded by every project seat. If the folder can't be prepared, the kernel falls back to `workspace.root` with a warning; nothing refuses a boot. Existing kernels keep their stored working folder and restored conversations; nothing is moved or cleaned up. The operator role's working-directory line follows the new folder, and `docs/reference/agent-startup-guide.md` explains the optional move for existing users: a fresh launch starts a new native conversation, custom text in a shared `CLAUDE.md` is kept, and step 3 waits until no running rig's seat still works in the old folder, as `rig down` checks.

Reported by @ch-davidriott in #2390.

Daemon +24/-11, guidance and docs +28/-1, tests +59/-6.
2026-11-02 14:21:42 -08:00
Mike Schwarz 0b60f8da4a feat(cli): assistants connected through a grant service work with each other and the team (#2384)
An assistant connected through `rig mcp serve --http --grant-service` can now claim, update and park the rows it owns and see its team, with the scope that show, list and create already apply to external callers: claim and update responses go through `projectForExternal`, so a blocker, closure target or waiting detail the caller can't read stays out; an external owner needs both the exact destination and the existing item scope, so a known id outside the grant gets `out_of_scope` and stays unchanged; an external park must name a `blockedOn` the caller can read, so a bare re-park can't restore or reveal the blocker saved at claim; and `rig_team`'s `lastWake` for a hosted member counts only rows in the caller's item scope. Local and token-grant behaviour and the error codes are unchanged; refusals use the existing `out_of_scope`. `docs/reference/hosted-members.md` describes the loop.

MCP server +51/-16, docs +32, tests +329.
2026-11-02 14:08:23 -08:00
Mike Schwarz fbca740dd1 feat(daemon): Pi's stream beat becomes the runner contract's working beat (#2399)
Pi's stream heartbeat (#2336, by @porterchild: one 5 s tick, a beat once 5 s pass since the runner's last post, stopping when streaming clears or the runner exits) now posts the runner contract's working beat (section 4): `active` / `liveness` with the open run's `turnId` and `livenessSeconds: 30`, in place of `active` / `streaming`. The Pi runner now posts `UserPromptSubmit` / `accepted` with the next `turnId` for each prompt it gives Pi: a prompt or `/followup` sent into an open run is posted as it's sent, and a prompt sent while idle is posted when its run opens, so a command Pi handles without a run (such as `/model`) opens no turn. `/abort` opens no turn, and OMP is unchanged.

Named limit: after a daemon restart in the middle of a run, the run's beats are ignored until the next prompt.

Pi runner +30/-4, tests +145/-19.
2026-11-02 13:59:26 -08:00
Mike Schwarz 522d635cdf docs(slack): correct missed-message recovery channel coverage (#2401)
docs/reference/slack-app-setup.md's channel-map section said missed-message recovery covers the default channel only. Since #1536 it recovers the default channel and every mapped channel, except a channel relayed to another machine, as the guide's reconnect-recovery sections already say. The historical 0.6.12 release note is unchanged.

Docs +1/-1. Reported by @ziadeh.
2026-11-02 13:36:26 -08:00
Mike Schwarz 8834943465 feat(docs): the dsh example runner beats, and the conformance check orders idle reports by the runner's sequence (#2388)
The dsh example runner now posts the runner contract's working beat (section 4): while a turn runs and nothing waits on a person, it posts `active` / `liveness` every 10 s with the turn's `turnId`, `livenessSeconds: 30` and its rising `sequence` and `occurredAt`, and stops at the turn's `Stop` and at dsh's exit. It pauses from a permission ask until the agent has its answer, and posts `active` / `permission_resolved` with the turn and `askId` once no ask waits and none is being submitted, never after a refusal, a cancel, a permit with no ask open, or dsh's exit. The agent's own reports go out only when a beat could.

The conformance check's cases 4b and 7 now count only the non-exit `Stop`s posted after the turn's accepted prompt, ordered as OpenRig orders them (`occurredAt` when it reads as a time, else arrival; then a positive `sequence`), so a runner's idle report from before the turn is no longer a false failure. The check still doesn't validate those two fields' types, and it is stricter than OpenRig for a `Stop` that precedes a later post that arrived first.

Example and check +69/-16, tests +213/-16.
2026-11-02 13:24:09 -08:00
Aditya Chandrajit Deore b9a4390b96 fix(daemon): recover current Claude hooks when saved startup context is absent (#2373)
A restore whose snapshot has no saved startup context reaches #2338's existing hook recovery only for a legacy (non-pod) seat whose live runtime still matches the snapshot; a changed runtime or a pod-aware seat gets no new hooks write, and the restore itself is unchanged. Every restore step that uses the seat's directory now takes the launch directory (the snapshot's cwd, else the live node's, as the launcher does): the projected hooks, the startup replay's plan and binding, the legacy resume's managed launch and read policy, and both saved-guidance checks. A snapshot with no cwd therefore no longer writes the context collector or settings into the daemon's own folder, or prepares a resume at the filesystem root. Seats whose snapshot has a cwd are unchanged.

Restore orchestrator +48/-12, tests +232/-6.
2026-11-02 12:19:22 -08:00
NIKHIL c3eb2f73e5 fix(tui): pick JUST FINISHED by finish time so an old item closed last shows (#2392)
The queue list route accepts a sort by last update, and the TUI uses it for JUST FINISHED on Pulse and RECENTLY FINISHED on an agent's page, so those lanes hold the items closed most recently instead of those created most recently. It is a read order only: no queue state, write or delivery path changes, and the default list order is unchanged.

Source +13/-10, tests +33/-8.
2026-11-02 11:37:38 -08:00
JUSHUANGHUI LI d942e12954 fix(daemon): avoid rescanning long transcript lines during grep (#2397)
The transcript store's forward reader keeps an unfinished line as fragments until a newline arrives, instead of prepending it to each new 64 KiB chunk and splitting the whole result again. A very long captured line is therefore scanned once rather than once per chunk, so `rig transcript --grep` holds the daemon for less time. The lines it yields, and so the grep output, are unchanged.

Transcript store +11/-5, tests +14/-0.
2026-11-02 11:33:09 -08:00
NIKHIL c1783989c8 fix(tui): show JUST FINISHED clocks in the configured time zone (#2393)
`rig tui`'s Pulse shows each JUST FINISHED row's clock in the TUI's configured time zone (`ui.timezone`), through the existing display-time helper, rather than as UTC with no zone mark. Other Pulse lanes and the stored timestamps are unchanged.

TUI +12/-16, tests +23/-2.
2026-11-02 11:29:35 -08:00
JUSHUANGHUI LI 551fb0e2f7 fix(daemon): report malformed optional workspace frontmatter (#2396)
The workspace frontmatter validator reports an opened but unclosed frontmatter block, and a YAML sequence instead of a metadata mapping, as a parse-error gap, including when no workspace kind is selected. An unclosed block is recognized as present rather than silently treated as body text. The report path, gap kinds and HTTP response are unchanged, so `rig workspace validate` now exits 1 for such a file through its existing gap exit status; nothing else gates on it.

Validator +4/-3, tests +50/-0.
2026-11-02 11:28:42 -08:00
JUSHUANGHUI LI d1839dfcf1 fix(cli): keep progress edits out of fenced code blocks (#2395)
`rig scope ... progress` edits only the tasks a reader sees. `--set` no longer changes or counts example checkboxes inside fenced code blocks, and a text found only inside an example takes the existing no-match error, leaving the example untouched. `--add` ignores example headings and rows when it picks a section, and inserts before an unfinished trailing fence rather than inside it. The fence rules match the progress indexer's.

Command +36/-7, tests +42/-0.
2026-11-02 11:26:43 -08:00
Mike Schwarz 2b929b3d76 fix(daemon): rig down takes back OpenRig's own additions once nothing uses them (#875) (#2387)
`rig down` removes OpenRig's managed blocks from the guidance file of every seat runtime that writes one, now including Pi and omp (AGENTS.md), with the same target used for teardown, live-file protection, exact resume and node inventory. Once a working folder has no live Claude seat of any rig, down also removes OpenRig's own Claude additions there: the collector status line and the relay hook groups in .claude/settings.local.json (matched exactly as launch claims them, keeping other hooks and fields), and .openrig/context-collector.cjs when its bytes still match the shipped asset. Empty .openrig and .claude folders are removed without recursion, and settings.local.json is kept as {}. Edited, unreadable and still-shared files, including targets another live seat reaches through a symlink or hardlink, are kept and reported. An exact resume puts its activity hooks back.

Source +211/-64, tests +248/-8, docs +4/-2.
2026-11-02 00:44:55 -08:00
NIKHIL b28dcd8f21 fix(transport): stop reporting delivery on a broadcast that reached nobody (#2359)
The /broadcast route adds its unsigned-sender notice only when the broadcast reached at least one recipient, counting sends a recipient holds for later delivery (sent + retained > 0). When every recipient holds the text, the notice begins "Held for delivery" rather than "Delivered"; when it reached nobody, there is no notice. The CLI prints the route's warning as before.

Source +9/-4, tests +31/-0.
2026-11-02 00:19:58 -08:00
Mike Schwarz da9ef8273c feat(daemon): a runner's working beat keeps a quiet turn reading working for its live, open turn (#2370)
A harness runner may post `active` / `liveness` beats while a prompt is outstanding and the agent it started is alive, so a long tool or model call keeps the seat reading working. A beat counts only when it carries the seat's live generation, names the latest accepted turn that hasn't stopped, carries a sequence above every earlier counted post, and the seat has no attention outstanding; otherwise it is ignored, and it never starts or extends a turn or clears attention. After delivering an answer or permit, a runner posts `active` / `permission_resolved`, which clears attention. Section 4 of the runner contract documents both from 0.7.9; section 7 is unchanged. The conformance checker gains the beat cells. A runner that sends no beats behaves as before.

Source +181/-8, contract and conformance kit +100/-2, tests +313/-7.
2026-11-02 00:06:58 -08:00
Mike Schwarz 16ac9bfabd docs(daemon): set-runtime names the route for a seat that has no pane to switch (#2383)
`rig seat set-runtime`'s refusal for a seat with no bound tmux pane now says what to do: start a stopped seat first, then switch it; for a seat that never started, declare the other runtime for the member in its rig spec and bring the team up again from it, which makes a new generation without the seat's edges, settings or queued work. `rig seat set-runtime --help` says the same. The existing refusal passes one guidance line; nothing changes in how a seat switches or starts.

Source +11/-2 (text and help), tests +6.
2026-11-01 23:50:14 -08:00
NIKHIL 94f7b27ac1 fix(cli): normalize documented --priority values and note the rest (#2358)
`rig queue create`, `handoff` and `handoff-and-complete` lowercase a documented --priority (routine, urgent, critical) given in another case, so `--priority Critical` files as critical. Any other value is kept exactly as given, with one note on stderr that it is outside the documented choices. create still defaults to routine, and a handoff with no --priority still sends none, so it inherits. --tier is unchanged.

Command +14/-3, tests +56/-3.
2026-11-01 23:47:08 -08:00
NIKHIL 3c0fee3e2d fix(cli): warn on an unknown --state in rig queue list (#2357)
`rig queue list --state` warns on stderr about any name that isn't a queue state, naming the supported states, then sends the query exactly as before. A mixed filter still returns its known-state matches; an all-unknown filter still returns nothing, and its warning says an empty result doesn't mean an empty queue. Stdout, --json, the empty and default filters and the HTTP API are unchanged.

Command +13/-0, tests +55/-0.
2026-11-01 23:46:31 -08:00
NIKHIL 978e95fbff fix(cli): finish a default rig export beside an existing rig.yaml (#2356)
`rig export <rig>` with no -o no longer replaces an existing rig.yaml. It claims the next unused sibling (rig-export.yaml, then rig-export-2.yaml and so on) by creating it exclusively, writes the export there, and prints the path only after the write succeeds. An explicit -o keeps its replacement behaviour, including -o rig.yaml, and filesystem or export failures still exit nonzero.

Command +39/-5, tests +79/-1.
2026-11-01 23:45:49 -08:00
Mike Schwarz b78894736d docs: 0.7.7 is published (#2385)
0.7.7 is published, so main's CHANGELOG records it: the [0.7.7] section reads published 2026-11-02 as @openrig/[email protected], built from the commit tagged v0.7.7, and the 0.7.7 status paragraph inside [0.7.8] no longer says it isn't published. [0.7.8] itself stays not yet published. docs/reference/whats-new.md, versions and pins stay as main has them.

CHANGELOG.md +2/-1.
2026-11-01 23:42:34 -08:00
Mike Schwarz cca17f54d5 feat(daemon): a person's typed words on a permission ask reach the agent (#2363)
When a person answers a runner's permission ask in words that are neither a choice nor a plain yes or no, the read result (`GET /api/activity/permission-requests/<askId>`) now carries those words, whole, as `said`, beside `permitted` (closed without a choice) or `refused` / `no_allow_offered`. The words come from the row that holds the answer, so a colleague's words count when the ask was routed to them. A plain yes or no keeps today's mapping with no `said`, and words are never read as a no.

The harness runner contract's section 7 gains two passages describing `said` and how a runner delivers it: queued for the agent as an ordinary prompt, after the current turn and behind any prompt already waiting, never as the answer. A runner that drops them still keeps the contract. The dsh example runner and the minimal ACP conformance fixture deliver the outcome as before and queue the words; the conformance check covers it.

10 files: runtime +20/-4; the dsh example runner and the conformance fixture +18/-2; the contract, the dsh README, the conformance README and check +34/-6; tests +61/-7.
2026-11-01 22:29:21 -08:00
82 changed files with 3054 additions and 274 deletions

No files matched your search

+2 -1
View File
@@ -174,7 +174,8 @@ stand-in follows.
## [0.7.7]
**Not yet published.** 0.7.7 is a separate reliability patch for 0.7.6. Its three
Published 2026-11-02 as `@openrig/[email protected]`, built from the commit tagged
`v0.7.7` (`f3d8bac4`). 0.7.7 is a separate reliability patch for 0.7.6. Its three
fixes are also in 0.7.8.
OpenRig 0.7.7 is 0.7.6 with three reliability fixes for managed Claude seats: an
+26
View File
@@ -741,6 +741,32 @@ rig uses it, so it stays until then. To remove it sooner, by hand:
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**The kernel's own folder.** A newly created default kernel works in
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
and from every folder above it, so a kernel working in the workspace root put its
guidance into every Claude project seat below it. If that folder can't be made,
the new kernel works in the workspace root, as before.
A kernel that already exists keeps its folders, usually `workspace.root`.
Upgrading moves no seat, changes no restored conversation and removes no file.
To move an existing kernel yourself, if you want to:
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
the folder ([details](#change-an-existing-seats-working-directory)).
2. Choose when each seat adopts it. A seat keeps its current folder until it
starts again, and the new start doesn't continue its current native
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
durable context into a new one. Nothing does this for you.
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
running rig's seat still works in that folder: a seat there still reads those
blocks, and `rig down` checks the same before it removes them. Remove only
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
line through its `END` line, as in the steps above, and keep everything else,
including your own text. Don't move or delete the whole file.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
@@ -5,7 +5,7 @@ It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and doe
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks;
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
@@ -37,6 +37,11 @@ The runner submits each request to OpenRig, which asks one person:
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
@@ -4,9 +4,10 @@
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks, cancels on /abort or Ctrl-C, and sends permission requests to OpenRig: a person answers, or OpenRig
// permits when no one can be asked, and so does the runner when OpenRig can't answer (refusing and holding needs input
// only when the seat opted in to refusal). Copy it and maintain it as your own.
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
// own.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
@@ -19,6 +20,9 @@ const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
const WORK_LIVENESS_SECONDS = 30;
const WORK_BEAT_MS = 10_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
@@ -253,13 +257,17 @@ async function main() {
const child = spawn(env.OPENRIG_DSH || "dsh", ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0 };
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let workBeat;
let tty = false;
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
const restoreTty = () => {
if (!tty) return;
@@ -269,6 +277,7 @@ async function main() {
};
const finish = async (code) => {
clearInterval(heartbeat);
clearInterval(workBeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
@@ -299,7 +308,7 @@ async function main() {
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = state.running && !state.holding; // while needs input is held, nothing may read working
const reporting = working();
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
@@ -346,10 +355,17 @@ async function main() {
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = false;
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
const passOn = (said) => {
if (typeof said !== "string" || !said.trim()) return;
say(`[permission] the person also wrote: ${firstLine(said)}`);
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
@@ -360,7 +376,12 @@ async function main() {
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
state.waiting += 1;
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
const resolved = () => { state.resolvedAsk = { turn, askId }; };
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
@@ -371,7 +392,7 @@ async function main() {
withdraw(askId, reason);
if (done) return;
done = true;
if (!state.refused) state.holding = false; // the turn ended the person's ask: no one is still being asked
stopWaiting(); // the turn ended the person's ask
resolve(CANCELLED);
} };
state.asks.add(ask);
@@ -387,18 +408,23 @@ async function main() {
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
state.holding = false;
stopWaiting();
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
resolved();
passOn(answer.said);
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
stopWaiting();
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
} else {
state.asks.delete(ask);
state.holding = false; // a person said no, or the request is over: no one is still waiting
stopWaiting(); // a person said no, or the request is over
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
passOn(answer.said);
}
}
})();
@@ -411,9 +437,19 @@ async function main() {
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
return askOwner(requestId, toolCall, options);
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
});
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
// goes on. It goes out after the agent has its answer, and before the next beat.
const settle = () => {
const done = state.resolvedAsk;
if (!done || state.holding || state.asking > 0) return;
state.resolvedAsk = null;
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
};
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
@@ -429,6 +465,8 @@ async function main() {
state.turn += 1;
state.holding = false;
state.refused = false;
state.waiting = 0;
state.resolvedAsk = null;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
@@ -522,6 +560,12 @@ async function main() {
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
workBeat = setInterval(() => {
if (working()) {
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
}
}, WORK_BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
+2 -1
View File
@@ -55,7 +55,8 @@ together in that home; it needs its own working sign-in and doesn't relocate `~/
> - Codex: hooks, workspace trust and team-selected config fragments (including MCP) in the selected
> `CODEX_HOME/config.toml`, default `~/.codex/config.toml`. These affect other Codex sessions using that home.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> `.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks
> and status line back out once no Claude seat uses that directory.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - Each seat's `CLAUDE.md` or `AGENTS.md` gets OpenRig's managed blocks (culture, identity, onboarding). One more
> line, pointing an agent at its seat skills, is added only on a yes: saved at setup, or set where the daemon runs
+42 -1
View File
@@ -104,6 +104,8 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
|---|---|---|
| You gave the agent the prompt for a submitted message | `UserPromptSubmit` / `accepted`, `turnId` = your prompt number | working |
| The agent is working (message chunks, tool calls) | `active` / `agent_message`; `PreToolUse` / `<tool kind>` | working |
| Optional working beat, from 0.7.9: the turn is still outstanding and the agent alive, with nothing new to report (a long tool or model call) | `active` / `liveness`, with `turnId`, `sequence` and `livenessSeconds` | working while beats keep coming |
| From 0.7.9: you delivered an answer or permit to a request OpenRig had shown as needing input, and the agent continues the same turn (section 7) | `active` / `permission_resolved`, with `turnId` and `askId` | working |
| You refused a permission request (section 7) | `Notification` / `permission_prompt` | needs input |
| The turn ended | `Stop` / its stop reason (`end_turn`, `cancelled`, `max_tokens`, `refusal`) | idle. While you hold attention (section 7), don't post it: OpenRig reads any non-exit `Stop` as idle and clears needs input |
| Optional idle heartbeat | `Stop` / `idle_heartbeat`, with `livenessSeconds` | idle while beats keep coming |
@@ -114,6 +116,41 @@ Post each event to `POST <base>/api/activity/hooks` with `Authorization: Bearer
idle `Stop`, the turn's end as well as each beat. OpenRig stops reading the seat idle after 2 × `livenessSeconds` without
a newer idle report. Without it, an idle report holds until your next post, even if your runner has died. Never send a
non-exit `Stop` or a beat while holding permission attention.
- **Working evidence lasts 15 seconds.** A working post (`UserPromptSubmit`, `active`, `PreToolUse`) decides the seat's
activity for 15 s. After that the seat reads from its other evidence: the screen can still show work, but it never
reads a runner seat idle, so with nothing else the seat reads unknown.
- **The working beat (from 0.7.9)** keeps a long, quiet turn reading working. It says only that this turn's prompt is
outstanding and the agent process you started is alive. It isn't progress.
- **When to beat:** only while a prompt is outstanding and the agent is alive, more often than every
`livenessSeconds` ÷ 2.
- **When to stop:** stop at once on the turn's `Stop`, on a permission request that needs a person, or on the agent's
exit, and post that boundary first.
- **`livenessSeconds`** is how long one beat counts as working. OpenRig clamps it to 5-60 s, and uses 30 s when a beat
has none. After that the beat stops counting, and the seat reads from its other evidence, as a working post's does.
- **A beat counts only when all of these hold:**
- it carries `generation`, equal to the seat's live occupant generation, and so did the `UserPromptSubmit` /
`accepted` post that opened its turn;
- its `turnId` is your latest `UserPromptSubmit` / `accepted` turn, and you haven't posted that turn's `Stop` or an
exit;
- it carries a `sequence`, greater than every earlier post's in the same generation that OpenRig counted, beats
included;
- the seat has no attention outstanding, such as a permission request waiting on a person.
So number every post with one `sequence` that rises across your whole runner process, beats included. OpenRig
orders your posts by `occurredAt`, then `sequence`. A post older than one OpenRig already counted changes nothing:
a late `Stop` for an earlier turn doesn't end the current one, and an older beat doesn't count. A post without a
valid `occurredAt` is dated when it arrives, by OpenRig's clock, so it can read older than it is when your clock
runs ahead. Give every post its `occurredAt`.
- **After you deliver an answer or permit** to a request OpenRig had shown as needing input (section 7; it created an
ask, and a permit at submit opens none), post `active` / `permission_resolved` with the turn's `turnId` and the
`askId`, before your next beat. That clears the seat's attention. So does any other working or idle post OpenRig
counts, and an operator's `rig seat clear-attention`. OpenRig doesn't match `permission_resolved` to its `askId`.
Beats are ignored until attention clears.
- **A beat that fails any of these is ignored.** The seat reads as it would without it. A beat never starts or extends
a turn, never clears attention, and never stands in for a `Stop`.
- **OpenRig keeps what it needs to judge beats in memory.** After a daemon restart, a beat already counted keeps its
lifetime, but new beats are ignored until your next accepted prompt, and the seat reads as it would without them.
- **A runner that sends no beats** behaves as before.
- **Exit:** post `Stop` / `<preset name>_exited` only when you observe the agent's process exit.
- **The screen still counts,** but only as evidence of work. On its own it never reads a runner seat idle.
@@ -252,11 +289,15 @@ that runs without asking gains no approval step. Ordinary conversation and `rig
`refused` (with `reason`: `said_no` when the person said no and the agent offered no reject option, or
`no_allow_offered` when it was closed without a choice and the agent offered no allow option), `withdrawn` or
`stale`.
- **From 0.7.9, `said`,** with `permitted` or `refused` (`no_allow_offered`), is the person's own words, when they
answered in words that were neither a choice nor a plain yes or no. It never changes the outcome.
- **A seat grant reads and withdraws only its own launch's requests.** Once its occupant is replaced, the grant is
refused (`409 activity_grant_stale`), even during a read's wait.
3. **Deliver** `{"outcome": {"outcome": "selected", "optionId": "<optionId>"}}` to the agent once, and only while that
request is still its live one. For `refused`, `withdrawn` or `stale`, answer `cancelled` and hold no attention: no
person is still waiting. A refusal of the read itself follows the refusals below.
person is still waiting. A refusal of the read itself follows the refusals below. From 0.7.9, when the read carries
`said`, queue those words for the agent as an ordinary prompt: they reach it after the current turn, behind any prompt
already waiting. They're never the answer. A runner that drops them still keeps this contract.
4. **Withdraw** when the turn is cancelled or ends, or the agent exits:
`POST <base>/api/activity/permission-requests/<askId>/withdraw` with
`{"reason": "turn_cancelled" | "turn_ended" | "exited"}`.
+32
View File
@@ -107,6 +107,38 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
## Assistants connected through a grant service
With `rig mcp serve --http --grant-service <url>`, an outside service answers each connection's key with its grant
(`openrig.grant/v1`): the seat it acts as, its rigs, and the object/action pairs it may use. Each pair offers one tool,
and nothing else is offered:
| Pair | Tool |
|---|---|
| `queue`/`create`, `queue`/`show`, `queue`/`list` | `rig_queue_create`, `rig_queue_show`, `rig_queue_list` |
| `queue`/`claim`, `queue`/`update` | `rig_queue_claim`, `rig_queue_update` |
| `team`/`list` | `rig_team` |
| `artifact`/`read` | `rig_artifact_read` |
- **One seat per connected assistant:** the service gives each connection its own seat and keeps it across that
connection's reconnects. The seat must be one of the rig's nodes; a node created without a launch is enough. The caller
is exactly that seat.
- **What it reaches:**
- It hands work to its rigs' other seats, never to a person or another host's seat.
- It reads the items it sent and the items addressed to it from those seats, never the team's internal items.
- With `queue`/`claim` and `queue`/`update`, it claims and closes only items addressed to its own seat that it can read,
and waits (blocked) only on an item it names and can read. Its answers hide what reading the item hides.
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
- **Answered:** closed `done`, with the answer in `resolution`.
- Reading or listing an item records nothing, and nothing wakes an assistant, so an item stays queued until its receiver
claims it.
## Limits today
- **Webhook triggers only:** OpenRig rings a webhook. Email and other triggers aren't supported yet. A member without a trigger
+2 -1
View File
@@ -105,7 +105,8 @@ The daemon starts and boots the kernel with the coding agents you're signed in t
**What starting OpenRig changes, for the coding agents you use** (paths are the usual defaults):
- **For Codex:** hooks and trust in `~/.codex/config.toml`, when OpenRig or a Codex agent starts.
- **For Claude Code:** workspace trust in `~/.claude.json`, and activity hooks in the project's
`.claude/settings.local.json`, plus a status line unless you already have your own.
`.claude/settings.local.json`, plus a status line unless you already have your own. `rig down` takes the hooks and
status line back out once no Claude seat uses that directory.
- **For both:** small discovery skills in `~/.claude/skills` and `~/.agents/skills`.
- **Kernel access:** by default the kernel gets broader permissions than a project team gets by default. On Codex
that's full access with no approval prompts. On Claude Code, its file tools, web fetches and operational commands,
+5 -3
View File
@@ -18,7 +18,7 @@ node check-runner.mjs --runner 'my-runner --agent-command {agent}' --name my-har
- **`--wait-ms 20000`** waits longer for each step (default 8000), for a runner whose agent takes a while to start. The check's shorter settling times, and the fake agent's own delays and holds, grow in proportion.
- **`--pass-env NAME`** passes one more variable through. Otherwise the runner gets only `PATH`, `HOME`, `USER`,
`SHELL`, `LANG` and `TMPDIR`, plus the OpenRig variables.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (7c) never changes it.
- **The exit status** is 0 when nothing fails, 1 when a case fails, and 2 for a usage or setup problem. An advisory result (4c, 7c) never changes it.
In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/docs/reference/runner-conformance/`.
@@ -51,11 +51,13 @@ In an installed OpenRig, the check is at `$(npm root -g)/@openrig/cli/daemon/doc
| 1b | 3, 4 | A handshake that fails at `initialize` or `session/new` (an error or an exit) writes no conversation in a launch record, and posts no accepted prompt or idle report |
| 2 | 5, 6 | One pasted block becomes exactly one `session/prompt`, byte for byte. A line reading `/abort` inside a block isn't control. A block sent mid-turn waits for the next turn. A block that is exactly `/abort`, or Ctrl-C outside a paste, sends one `session/cancel`. `Stop/cancelled` is posted only when the agent itself returns `cancelled` |
| 3 | 4 | `UserPromptSubmit/accepted` with a `turnId`, then `Stop/end_turn`. Every post carries the contract's fields, the hook token, this seat's identity and a rising `sequence`. `livenessSeconds`, when given, is from 1 to 3600 |
| 3b | 4 | **Optional, from 0.7.9; skipped when the runner sends no beat.** In a long, quiet turn, each `active/liveness` beat names the accepted turn, `sequence` rises with the runner's own `occurredAt` across every post, beats come more often than every `livenessSeconds` ÷ 2, and none follows the turn's `Stop`. While a person is asked, no beat is posted; after the answer, `active/permission_resolved` names the turn and the ask, before any beat. A permit at submit opens no ask, so the quiet turn's beats go on |
| 4 | 7 | When no one can be asked, the request is permitted: an OpenRig without the answered path (404) at once, and one the runner can't reach, each get the agent's `allow_once` option, never its `allow_always` while `allow_once` is offered. The turn then reports as usual, with no `permission_prompt`. Only `denied_by_seat`, or a request with no allow option, is answered at once with a refusal, never an allow option. `Notification/permission_prompt` is then posted, and for `denied_by_seat` the pane shows what was asked (the tool call's title). No idle `Stop` or beat is posted while that attention is held, even when `/abort` then cancels the turn. The next prompt is accepted as usual |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4b | 7 | The live request is submitted with the hook token, its `requestId` as the agent's JSON-RPC id, passed unchanged, the accepted prompt's `turnId`, this conversation and generation, and the agent's options as offered. The runner doesn't post `permission_prompt` itself for a created ask, and each read waits at most 25 s. The chosen option, allow or deny, reaches the agent once, and the turn then reports as usual. OpenRig's `permitted`, at submit or at a read, reaches the agent the same way, and a read that carries the person's own words (`said`) still gives the agent the read's option. A refusal is read by its code, never its status: one section 7's table marks "permits" (`duplicate_request`, `activity_grant_pending`, `activity_grant_revoked`), a read that gets no answer, and a status or read state the section doesn't list (500, `deferred`) each give the agent its `allow_once` option. A refusal saying the request isn't the current occupant's (`stale_generation` at submit, `activity_grant_stale` on a read), and a `refused` (`said_no`), stale or withdrawn read, give the agent `cancelled`, and the turn then ends as usual, with nothing held. With no answer, the request stays pending with no idle report after the turn's accepted prompt, and a pasted message isn't an answer. `/abort` withdraws it with `turn_cancelled`, and an answer after that isn't delivered. A turn's end or the agent's exit while it waits withdraws it with `turn_ended` or `exited`. Once the ask is withdrawn no person is still waiting, so the turn's end is reported as usual. An ask the runner permitted after a read got no answer, or an unlisted one, may still be open, so it's withdrawn at the turn's end too |
| 4c | 7 | **Advisory.** A read's `said`, the words of a person who answered in words, is queued to the agent as a prompt, after the turn and after any prompt already waiting. Section 7 lets a runner drop them and still keep the contract, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status |
| 5 | 4 | Every post carries `OPENRIG_OCCUPANT_GENERATION` |
| 6 | 8 | A resume asks the agent to reopen `OPENRIG_RESUME_REF` in the launch's working folder, and records that conversation. The agent's own not-found becomes `error: "missing"`. Any other failure is neither missing nor resumed. No case starts a new conversation |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report after it |
| 7 | 4 | An agent that dies mid-turn gets `Stop/<preset>_exited`, and no idle report for that turn. Posts are taken in the runner's own order, as OpenRig orders them (`occurredAt` when it's a time string, otherwise the post's arrival, then `sequence` when both are positive whole numbers), so an idle report from before the turn began doesn't count, however late it arrives |
| 7b | 4 | A failed `session/prompt` posts `Notification/runtime_error`, not a permission request |
| 7c | 9 | **Advisory.** Closing the pane ends the agent process. Section 9 marks this duty as proposed, and lets a runner list in `limits` what may keep running, so this result is shown as met or not met and never counts as a pass or a fail, or toward the exit status. The fake agent outlives a hangup here, so the runner has to end it |
| 8 | 7 | An unknown request from the agent gets JSON-RPC `-32601`, and the next prompt still goes through |
@@ -133,8 +133,8 @@ function askState(h, entry) {
const script = h.asks.script;
if (entry.withdrawn) return script.late ?? { state: "withdrawn" };
if (script.answer && Date.now() >= entry.created + (script.answer.afterMs ?? 0)) {
const { state, optionId, reason } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}) };
const { state, optionId, reason, said } = script.answer;
return { state, ...(optionId ? { optionId } : {}), ...(reason ? { reason } : {}), ...(said ? { said } : {}) };
}
return { state: "pending" };
}
@@ -242,6 +242,19 @@ async function launch(label, { scenario = {}, env = {}, asks } = {}) {
// An ordinary send arrives inside an envelope, so a line in it that reads /abort is still just text.
const message = (h, body) => `From: checker@runner-check\nTo: ${h.session}\n---\n${body}`;
const nonExitStops = (h, after = 0) => h.hooks("Stop").filter((p) => p.at >= after && !String(p.body?.subtype ?? "").endsWith("_exited"));
// Whether post `a` comes before post `b` in the runner's own order, as OpenRig orders them: by `occurredAt` when it's a
// non-blank string that reads as a time, otherwise by its arrival; then by `sequence`, when both carry a positive whole number.
const postedAt = (p) => {
const value = p?.body?.occurredAt;
const t = typeof value === "string" && value.trim() ? Date.parse(value) : NaN;
return Number.isFinite(t) ? t : p?.at ?? 0;
};
const sequenceOf = (p) => { const value = p?.body?.sequence; return Number.isSafeInteger(value) && value > 0 ? value : null; };
const precedes = (a, b) => postedAt(a) !== postedAt(b) ? postedAt(a) < postedAt(b)
: sequenceOf(a) !== null && sequenceOf(b) !== null && sequenceOf(a) < sequenceOf(b);
// Non-exit Stops the runner posted after `post` in that order. An idle report from before the turn began (a runner may report
// idle at startup) is never one made during it, however late its POST arrives.
const stopsAfter = (h, post) => nonExitStops(h).filter((p) => !post || !precedes(p, post));
const ready = (h, ms) => waitFor(() => h.record()?.nativeRef && h.record(), ms);
function result(id, title, { proposed = false } = {}) {
@@ -364,6 +377,65 @@ const cases = {
return r;
},
// The optional working beat (section 4). When a runner posts active/liveness in a quiet turn, each beat names the open
// turn it belongs to, keeps the post order, comes often enough for its own lifetime, stops at the turn's end, and never
// runs while a person is asked; after an answer, active/permission_resolved comes before the next beat. Order is read
// from the runner's own sequence and times, so a slow delivery never reads as a broken runner.
async "3b"() {
const r = result("3b", "Working beat (optional): beats name their open turn, keep order, and stop at its end and while a person is asked");
const quiet = scale(22000);
const seqOf = (p) => Number(p?.body?.sequence);
const at = (p) => Date.parse(p.body.occurredAt);
const h = await launch("3b", { scenario: { turns: [{ silent: true, holdMs: quiet }, {}] } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "a long, quiet turn"));
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0]);
const end = await waitFor(() => h.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
await sleep(scale(11000)); // room for a beat a runner wrongly sends after the Stop
const beats = h.hooks("active", "liveness");
if (!beats.length) return r.skip("the runner sends no working beat in a quiet turn; the beat is optional (section 4)");
r.check("each beat names the turn it belongs to", accepted && beats.every((b) => b.body.turnId === accepted.body.turnId),
`turnIds: ${beats.map((b) => b.body.turnId).join(", ")}`);
// In the runner's own time order, not delivery's: a post delivered late still has to fit where the runner sent it.
const byTime = (a, b) => at(a) - at(b) || seqOf(a) - seqOf(b);
const timed = h.posts().slice().sort(byTime);
r.check("sequence rises with the runner's own time across every post, beats included",
timed.every((p, i) => Number.isFinite(seqOf(p)) && Number.isFinite(at(p)) && (i === 0 || seqOf(p) > seqOf(timed[i - 1]))),
`sequences in occurredAt order: ${timed.map(seqOf).join(", ")}`);
const life = (b) => (Number.isFinite(b.body.livenessSeconds) ? b.body.livenessSeconds : 30);
const inTurn = beats.filter((b) => !end || seqOf(b) < seqOf(end)).sort(byTime);
const gaps = inTurn.map((b, i) => at(b) - at(i === 0 ? accepted : inTurn[i - 1]));
r.check("beats come more often than every livenessSeconds / 2", inTurn.every((b, i) => gaps[i] <= life(b) * 500 + 1000),
`gaps by the runner's own times: ${gaps.join(", ")} ms`);
r.check("no beat after the turn's Stop", end && beats.every((b) => seqOf(b) < seqOf(end)),
`beats after Stop/end_turn: ${beats.filter((b) => end && seqOf(b) > seqOf(end)).length}`);
// A person is asked first, and answers after a while; the agent then carries on, quietly, in the same turn.
const p = await launch("3b-asked", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { answer: { afterMs: scale(12000), state: "answered", optionId: "allow-once" } } });
if (!r.check("asked: the runner opens its conversation", await ready(p), "no launch record")) return r;
p.paste(message(p, "edit the notes"));
const submitted = await waitFor(() => p.permissionCalls("POST").find((c) => c.path === "/api/activity/permission-requests"), scale(5000));
if (!r.check("asked: the request is submitted", submitted, "no submit")) return r;
await waitFor(() => p.hooks("Stop", "end_turn")[0], scale(12000) + quiet + opts.waitMs);
const resolvedPost = p.hooks("active", "permission_resolved")[0];
const before = p.hooks("active", "liveness").filter((b) => at(b) > submitted.at && (!resolvedPost || seqOf(b) < seqOf(resolvedPost)));
r.check("asked: no beat while the ask waits on a person", before.length === 0, `${before.length} beats while it waited`);
r.check("asked: after the answer, active/permission_resolved names the turn and the ask, before any beat",
resolvedPost && resolvedPost.body.askId && resolvedPost.body.turnId === p.hooks("UserPromptSubmit", "accepted")[0]?.body.turnId,
resolvedPost ? `permission_resolved: ${JSON.stringify({ turnId: resolvedPost.body.turnId, askId: resolvedPost.body.askId })}` : "no permission_resolved");
// OpenRig permits at submit when no one can be asked: no ask opens, so nothing waits and the beats go on.
const q = await launch("3b-permitted", { scenario: { turns: [{ permission: true, silent: true, holdMs: quiet }, {}] },
asks: { submit: { permitted: true } } });
if (!r.check("permitted at submit: the runner opens its conversation", await ready(q), "no launch record")) return r;
q.paste(message(q, "edit the notes"));
const qEnd = await waitFor(() => q.hooks("Stop", "end_turn")[0], quiet + opts.waitMs);
const qBeats = q.hooks("active", "liveness").filter((b) => !qEnd || seqOf(b) < seqOf(qEnd));
r.check("permitted at submit: the quiet turn's beats go on, with no ask to resolve", qBeats.length > 0, "no beat in the quiet turn after the permit");
return r;
},
// Section 7's default is permit: when no one can be asked, the agent gets its own allow option at once, allow_once before
// allow_always. Only the seat's opt-in refusal (denied_by_seat), or a request with no allow option, is refused at once,
// shown, and held as needs input until the next prompt, even when the turn is cancelled.
@@ -449,7 +521,8 @@ const cases = {
const submitted = await waitFor(() => submits(h)[0], scale(3000));
if (!r.check(`${label}: the request is submitted to /api/activity/permission-requests`, submitted, "no submit")) continue;
const asked = h.sent("session/request_permission")[0];
const accepted = h.hooks("UserPromptSubmit", "accepted")[0];
// The submit isn't one of the runner's ordered activity posts, so it can arrive before the accepted prompt does.
const accepted = await waitFor(() => h.hooks("UserPromptSubmit", "accepted")[0], scale(3000));
const sent = submitted.body ?? {};
if (label === "allow") {
r.check("the submit passes OpenRig's checks", h.asks.submitted[0]?.problems.length === 0, h.asks.submitted[0]?.problems.join("; "));
@@ -486,6 +559,8 @@ const cases = {
const outcomes = [
["permitted at submit", { submit: { permitted: true } }, "allow-once"],
["permitted at read", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once" } }, "allow-once"],
// A person's own words come back beside the outcome, and never change the option delivered.
["permitted at read, with the person's words", { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said: "only the notes file" } }, "allow-once"],
["duplicate_request", { submit: { status: 409, code: "duplicate_request" } }, "allow-once"],
["activity_grant_pending", { submit: { status: 409, code: "activity_grant_pending" } }, "allow-once"],
["activity_grant_revoked", { submit: { status: 401, code: "activity_grant_revoked" } }, "allow-once"],
@@ -530,7 +605,8 @@ const cases = {
await sleep(scale(1500));
r.check("pending: the runner keeps reading the ask", h.permissionCalls("GET").length >= 1, "no read of the ask");
r.check("pending: nothing is delivered while no one has answered", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
r.check("pending: no idle Stop while it waits", nonExitStops(h).length === 0, nonExitStops(h).map((p) => p.body.subtype).join(", "));
const turn = h.hooks("UserPromptSubmit", "accepted").at(-1);
r.check("pending: no idle Stop while it waits", stopsAfter(h, turn).length === 0, stopsAfter(h, turn).map((p) => p.body.subtype).join(", "));
h.paste(message(h, "allow it"));
await sleep(scale(800));
r.check("pending: a pasted message isn't an answer", answers(h).length === 0, `got ${JSON.stringify(outcome(h))}`);
@@ -573,6 +649,22 @@ const cases = {
return r;
},
// Section 7: a person who answers in their own words gets them passed on. OpenRig carries them as the read's `said`, and
// a runner queues them for the agent like any other prompt, after the turn, never as the answer. A runner that drops
// them still keeps the contract, so the result is advisory.
async "4c"() {
const r = result("4c", "The person's words: a read's said is queued to the agent as a prompt (advisory)", { proposed: true });
const said = "Only the notes file, please. Leave the rest alone.";
const h = await launch("4c", { scenario: { turns: [{ permission: true }, {}] },
asks: { answer: { afterMs: 300, state: "permitted", optionId: "allow-once", said } } });
if (!r.check("the runner opens its conversation", await ready(h), "no launch record")) return r;
h.paste(message(h, "edit the notes"));
if (!r.check("the first turn ends", await waitFor(() => h.hooks("Stop", "end_turn")[0], scale(6000)), "no Stop/end_turn")) return r;
const later = await waitFor(() => h.prompts().slice(1).find((prompt) => prompt?.includes(said)), scale(4000));
r.check("the person's words are queued to the agent as a prompt, after the turn", later, `prompts: ${JSON.stringify(h.prompts())}`);
return r;
},
async "5"() {
const r = result("5", "Generation: every post carries the launch's occupant generation");
const h = await launch("5");
@@ -630,7 +722,10 @@ const cases = {
if (!r.check("the agent exits mid-turn", died, "the agent didn't exit")) return r;
const exited = await waitFor(() => h.hooks("Stop", `${opts.name}_exited`)[0], scale(4000));
r.check(`Stop/${opts.name}_exited is posted`, exited, `posts: ${h.posts().map((p) => `${p.body?.hookEvent}/${p.body?.subtype}`).join(", ")}`);
r.check("no idle Stop after the exit", nonExitStops(h, died.at).length === 0, nonExitStops(h, died.at).map((p) => p.body.subtype).join(", "));
// Any idle report for the turn the agent died in counts, before or after the exit; one from before the turn doesn't.
const turn = h.hooks("UserPromptSubmit", "accepted")[0];
const idle = turn ? stopsAfter(h, turn) : nonExitStops(h, died.at);
r.check("no idle report for the turn it died in", idle.length === 0, idle.map((p) => p.body.subtype).join(", "));
return r;
},
@@ -686,7 +781,7 @@ const cases = {
},
};
const order = ["1", "1b", "2", "3", "4", "4b", "5", "6", "7", "7b", "7c", "8", "9"];
const order = ["1", "1b", "2", "3", "3b", "4", "4b", "4c", "5", "6", "7", "7b", "7c", "8", "9"];
const selected = opts.cases ?? order;
const unknown = selected.filter((id) => !order.includes(id));
if (unknown.length) { console.error(`unknown case ${unknown.join(", ")}; cases are ${order.join(", ")}`); process.exit(2); }
@@ -16,6 +16,7 @@
// stubborn keep running after a hangup or after input closes; only SIGTERM or SIGKILL ends it
// turns one entry per `session/prompt`, in order (the last one repeats):
// holdMs keep the turn open this long; `session/cancel` ends it early as "cancelled"
// silent send no message chunk for this turn, so a held turn is entirely quiet (a long tool or model call)
// ignoreCancel on `session/cancel`, keep the turn open and end it with stopReason (default end_turn) anyway
// permission ask `session/request_permission` first, then carry on whatever the answer. As an object:
// options the options offered (default: allow-once, allow_once; reject-once, reject_once)
@@ -65,7 +66,7 @@ async function prompt(id, params) {
turn += 1;
if (spec.exit) exit(3, `turn ${turn}`);
if (spec.error) { fail(id, -32603, "fake agent: this turn fails"); return; }
send({ jsonrpc: "2.0", method: "session/update",
if (!spec.silent) send({ jsonrpc: "2.0", method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `turn ${turn}` } } } });
let stopReason = spec.stopReason ?? "end_turn";
if (spec.permission) {
+1 -1
View File
@@ -207,7 +207,7 @@ rig slack channel-map remove pr@my-rig
their routing also keeps the conversations separate: new messages in a seat's own channel
still go to the inbound destination unless its entry names `--inbound`. A conversation shared
on purpose is fine.
- Missed-message recovery (below) covers the default channel only.
- Missed-message recovery (below) covers the default channel and every mapped channel, except a channel relayed to another machine.
Invite the app to every mapped channel (`/invite @OpenRig`). `rig slack verify` checks
membership in the default channel and every mapped channel, and reports NOT ready until the
+13
View File
@@ -62,3 +62,16 @@ export function writeTextAtomically(path: string, content: string, label = "file
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
}
}
/** Create a file, failing with EEXIST rather than replacing anything already at the path, even a dangling link. */
export function writeNewText(path: string, content: string): void {
const fd = openSync(path, "wx", 0o666);
try {
writeFileSync(fd, content, "utf-8");
} catch (error) {
try { unlinkSync(path); } catch { /* Report the write failure. */ }
throw error;
} finally {
closeSync(fd);
}
}
+2 -1
View File
@@ -227,7 +227,8 @@ function renderBroadcastResults(data: Record<string, unknown>): void {
if (r.ok) {
console.log(`${r.sessionName}: sent`);
} else {
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
// A target that resolved to no sessions has no name to report.
console.log(`${r.sessionName ? `${r.sessionName}: ` : ""}FAILED — ${r.error ?? "unknown error"}`);
}
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
}
+6
View File
@@ -88,6 +88,8 @@ interface TeardownResult {
keptSkills?: string[];
/** Managed guidance blocks a person edited, kept in their file as `<file>: <block id>`. */
keptGuidance?: string[];
/** Files in a Claude seat's cwd that keep OpenRig's additions because down couldn't safely change them, as `<file>: <why>`. */
keptFiles?: string[];
/** Seats joined with `rig attach` as an external CLI, which OpenRig doesn't stop. */
leftRunning?: string[];
paneOnly?: string[];
@@ -295,6 +297,10 @@ the command warns and exits without stopping; re-run with --yes to confirm expli
console.log(`Kept ${result.keptGuidance.length} edited OpenRig block(s) in guidance files; remove them by hand if you don't want them:`);
for (const block of result.keptGuidance) console.log(` ${block}`);
}
if (result.keptFiles && result.keptFiles.length > 0) {
console.log(`Kept ${result.keptFiles.length} file(s) that still hold OpenRig's additions; check them by hand:`);
for (const file of result.keptFiles) console.log(` ${file}`);
}
if (result.leftRunning && result.leftRunning.length > 0) {
console.log(`Not stopped: ${result.leftRunning.join(", ")}. Joined with rig attach as an external CLI, which OpenRig doesn't stop or check.`);
+26 -5
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import fs from "node:fs";
import { Command } from "commander";
import { writeTextAtomically } from "../atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../atomic-text-write.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
@@ -10,6 +10,8 @@ import { rigIdForHandle } from "../rig-handle.js";
export interface ExportDeps extends StatusDeps {
writeFile: (path: string, content: string) => void;
/** Must fail with EEXIST when the path is taken. */
writeNewFile?: (path: string, content: string) => void;
exists?: (path: string) => boolean;
}
@@ -37,18 +39,35 @@ function copyCulture(file: string, root: string, outDir: string): void {
console.log(`Copied culture to ${target}`);
}
/** rig.yaml is also the name of an authored spec, and an export doesn't carry all of one (comments, a summary, startup
* it couldn't read back). So a default export takes the first free name, created so that it can't replace a file that
* appeared after the daemon was asked. Only a path the person named with -o may replace a file. */
function writeDefaultExport(write: (path: string, content: string) => void, first: string, content: string): string {
for (let attempt = 1; ; attempt++) {
const path = attempt === 1 ? first : attempt === 2 ? "rig-export.yaml" : `rig-export-${attempt - 1}.yaml`;
try {
write(path, content);
if (attempt > 1) console.warn(`${first} already exists and was left unchanged. Use -o ${first} to replace it.`);
return path;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
}
export function exportCommand(depsOverride?: ExportDeps): Command {
const cmd = new Command("export").description("Export a rig spec as YAML");
const getDeps = (): ExportDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
writeNewFile: writeNewText,
exists: (p) => fs.existsSync(p),
};
cmd
.argument("<rig>", "Rig name or ID to export")
.option("-o, --output <path>", "Output file path", "rig.yaml")
.option("-o, --output <path>", "Output file path, replacing any file there. Without it, an existing rig.yaml is kept and the export goes to an unused rig-export*.yaml", "rig.yaml")
.action(async (rigId: string, opts: { output: string }) => {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
@@ -67,8 +86,10 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
console.error(`Export failed: ${res.data}`);
process.exitCode = 1;
} else {
deps.writeFile(opts.output, res.data);
console.log(`Exported to ${opts.output}`);
const written = cmd.getOptionValueSource("output") === "default"
? writeDefaultExport(deps.writeNewFile ?? deps.writeFile, opts.output, res.data)
: (deps.writeFile(opts.output, res.data), opts.output);
console.log(`Exported to ${written}`);
for (const line of String(res.data).split("\n")) {
if (line.startsWith("# Not carried: ")) console.log(line.slice(2));
if (line.startsWith("# Member startup not carried for ")) console.warn(`Warning: ${line.slice(2)}`);
@@ -92,7 +113,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
const parsed = parse(res.data) as Record<string, unknown> | null;
if (parsed && typeof parsed === "object") {
if (parsed["name"] === "starter") {
console.warn("Warning: this export still installs as 'starter'. Before sharing, rename the team's name in rig.yaml; changing the bundle name alone does not rename the installed rig.");
console.warn(`Warning: this export still installs as 'starter'. Before sharing, rename the team's name in ${written}; changing the bundle name alone does not rename the installed rig.`);
}
const pods = parsed["pods"] as Array<{
id?: string;
+27 -3
View File
@@ -356,6 +356,17 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Priority is matched exactly where it matters (the founder view, attention ranks), so "Critical" would quietly file
// the row as ordinary work. The daemon stores what it's given, so a documented value is normalized here. Other values
// are kept as given: `high`, `normal` and `background` are in real use, and the mission view ranks some of them.
const QUEUE_PRIORITIES = ["routine", "urgent", "critical"];
function queuePriorityArg(value: string): string {
const lower = value.toLowerCase();
if (QUEUE_PRIORITIES.includes(lower)) return lower;
console.error(`Note: --priority '${value}' is outside the documented choices (${QUEUE_PRIORITIES.join(", ")}); keeping it as given.`);
return value;
}
/** #2276: the daemon's checks of a new row's human-facing fields (missing summary or evidence for a person,
* notification, questions, reply-to). The queue repository raises them before the row's first write, inside the
* create's transaction. A remote host's refusal arrives as remote_queue_write_failed instead, so it isn't one of these. */
@@ -639,6 +650,18 @@ async function printQueueItemResult(
// IMPL-SPEC §2.3-2.4.
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
// A misspelled state ("in_progress") would otherwise read as an empty queue, not as a mistake: the list route matches
// names as given. Each name is checked and trimmed the way the route trims it. This warns rather than refuses, so the
// CLI's copy of the list doesn't stop it working with a newer daemon that knows more states.
const QUEUE_STATE_NAMES = ["pending", "in-progress", "done", "blocked", "failed", "denied", "canceled", "handed-off"];
function warnIfUnknownStates(value?: string): void {
const names = (value ?? "").split(",").map((name) => name.trim()).filter(Boolean);
const unknown = names.filter((name) => !QUEUE_STATE_NAMES.includes(name));
if (unknown.length === 0) return;
const effect = unknown.length === names.length ? " Nothing is filed under these names, so an empty result doesn't mean an empty queue." : "";
console.error(`Warning: unrecognized --state '${unknown.join("', '")}'. Supported states: ${QUEUE_STATE_NAMES.join(", ")}.${effect}`);
}
// Commander option parser. InvalidArgumentError (like positiveIntArg/enumArg) is what Commander
// renders as its usual "option ... argument ... is invalid" error; a plain Error would escape parse.
const WAKE_DURATION_FORMAT = "must be a positive integer with an optional s, m or h suffix (for example 90s, 15m, 168h)";
@@ -911,7 +934,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--priority <priority>", "Priority: routine | urgent | critical", queuePriorityArg, "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
@@ -1385,7 +1408,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1489,7 +1512,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
.option("--body <text>", "New qitem body inline (use - to read from stdin; mutually exclusive with --body-file). Omit both to keep the source body.")
.option("--body-file <path>", "Read the new qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class.")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--priority <priority>", "Override priority for the new qitem: routine | urgent | critical", queuePriorityArg)
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
@@ -1772,6 +1795,7 @@ Examples:
}
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
warnIfUnknownStates(opts.state);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", String(opts.limit));
+2
View File
@@ -998,6 +998,8 @@ effort, Codex profile and native permission choice are cleared, not mapped; two
processes run until cutover; messages sent to the seat during the switch are
held, not typed, and need sending again afterwards.
Codex and Claude Code, single-pane seats only. --dry-run lists the details.
A seat that has never started has no pane to switch: declare the other runtime for it in its rig spec and bring the team
up again from that spec. That's a new generation of the seat, without this seat's edges, settings or queued work.
--clear-account-home returns to configured member/rig/daemon/native selection; it does not restore historical credentials.
It uses a fresh successor and clears an account-home token reference only after confirmation. Do not combine it with
--account-home or a new token reference. Anthropic's global active/default profile is not isolated by CLAUDE_CONFIG_DIR.
+36 -7
View File
@@ -62,6 +62,31 @@ function bodyStartIndex(lines: string[]): number {
return 0; // unterminated frontmatter — treat as no frontmatter
}
/** Keep source indices while hiding code examples from the progress grammar. */
function progressLines(lines: string[]): string[] {
let fence: { marker: string; length: number } | null = null;
const start = bodyStartIndex(lines);
return lines.map((line, i) => {
if (i < start) return "";
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (match) {
const marker = match[1]![0]!;
const length = match[1]!.length;
const tail = match[2]!;
if (fence) {
if (marker === fence.marker && length >= fence.length && /^[ \t\r]*$/.test(tail)) {
fence = null;
return line; // A new row may be inserted after the closing fence.
}
} else if (marker !== "`" || !tail.includes("`")) {
fence = { marker, length };
}
return "";
}
return fence ? "" : line;
});
}
export interface ProgressEditResult {
content: string;
changed: boolean;
@@ -92,32 +117,35 @@ export function addProgressRow(
const newRow = `- [${statusIndicator(opts.status)}] ${text}`;
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
// Locate the target section heading.
let sectionIdx = -1;
for (let i = start; i < lines.length; i++) {
const h = lines[i]!.match(HEADING_RE);
const h = visible[i]!.match(HEADING_RE);
if (h && h[1] === "##" && h[2]!.trim() === section) { sectionIdx = i; break; }
}
if (sectionIdx === -1) {
// Create the section, appended after the last existing content.
let end = lines.length;
while (end > start && lines[end - 1]!.trim() === "") end--;
while (end > start && visible[end - 1]!.trim() === "") end--;
const head = lines.slice(0, end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, ""].join("\n");
const suffix = lines.slice(end);
const rebuilt = [...head, "", `## ${section}`, "", newRow, "",
...(suffix.some(line => line.trim() !== "") ? suffix : [])].join("\n");
return { content: rebuilt, changed: true };
}
// Section block = [sectionIdx+1, nextHeadingOrEOF).
let blockEnd = lines.length;
for (let i = sectionIdx + 1; i < lines.length; i++) {
if (HEADING_RE.test(lines[i]!)) { blockEnd = i; break; }
if (HEADING_RE.test(visible[i]!)) { blockEnd = i; break; }
}
// Idempotency / conflict scan within the section.
for (let i = sectionIdx + 1; i < blockEnd; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) {
const current = indicatorToStatus(cb[2]!);
if (current === opts.status) return { content, changed: false };
@@ -132,7 +160,7 @@ export function addProgressRow(
// Insert after the last non-blank line in the section block.
let insertAt = sectionIdx + 1;
for (let i = sectionIdx + 1; i < blockEnd; i++) {
if (lines[i]!.trim() !== "") insertAt = i + 1;
if (visible[i]!.trim() !== "") insertAt = i + 1;
}
lines.splice(insertAt, 0, newRow);
return { content: lines.join("\n"), changed: true };
@@ -155,10 +183,11 @@ export function setProgressRow(
const text = opts.text.trim();
const lines = content.split("\n");
const start = bodyStartIndex(lines);
const visible = progressLines(lines);
const matches: number[] = [];
for (let i = start; i < lines.length; i++) {
const cb = lines[i]!.match(ROW_RE);
const cb = visible[i]!.match(ROW_RE);
if (cb && cb[3]!.trim() === text) matches.push(i);
}
+51 -16
View File
@@ -336,10 +336,11 @@ function scopeFor(access: McpAccess, seatOf: () => string | undefined): Scope {
};
}
/** The grant service names an external caller's seat cus-<id>-caller@<rig>. v1 marks no seat as a caller, so an item
* from a seat of this shape is never the team's question to an external caller, and an external caller can't hand a
* task to one. The shape never grants anything. */
const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
/** The grant service names each connected assistant's seat cus-<id>-<connection>@<rig> (an existing connection's is
* cus-<id>-caller@<rig>). v1 marks no seat as a caller, so for a grant without team/list an item from a seat of this
* shape is never the team's question to it, and it can't hand a task to one. The shape only refuses; it never grants
* anything, and a grant with team/list doesn't read it. */
const CALLER_SHAPED = /^cus-[^@]+-[^@]+@[^@]+$/;
/** OpenRig's own tags that decide what a person asked about a row sees of its body: what Slack ingestion records about where
* the row came from and which parts of its body OpenRig wrote (the sender's channel, the attachments block, the body's
@@ -349,11 +350,15 @@ const CALLER_SHAPED = /^cus-.+-caller@[^@]+$/;
const OPENRIG_PROVENANCE_TAG = /^(?:founder-slack|inbound|shared-channel|gate)$|^participant:|^slack-/;
/** An external grant (openrig.grant/v1): a requester, narrower than a work grant. The grant's own seat is the
* only caller. It may hand tasks to its rigs' team seats (never a caller-shaped seat, a person or another host's
* seat), read the items it sent and the team's questions to it (never team-internal or another caller's items), follow
* a handed-on item only where it came back to it, and read the artifacts of its own items under its named roots. */
* only caller. It may hand tasks to its rigs' other seats (never a person or another host's seat, and without team/list
* never a caller-shaped seat), read the items it sent and those addressed to it from those seats (never team-internal
* items or other callers' items between themselves), change only the items addressed to its own seat (with the claim and
* update pairs), follow a handed-on item only where it came back to it, and read the artifacts of its own items under
* its named roots. */
function externalScope(access: McpAccess, seatOf: () => string | undefined): Scope {
const rigs = new Set(access.rigs);
// team/list makes the caller one of its rigs' participants: it may reach another connected assistant's seat.
const participant = access.external?.objects.team?.includes("list") === true;
const caller = (value: unknown): boolean => typeof value === "string" && value !== "" && value === seatOf();
// A seat of one of its rigs on this instance: member@rig, never a person or another host's seat.
const member = (value: unknown): boolean => {
@@ -361,17 +366,20 @@ function externalScope(access: McpAccess, seatOf: () => string | undefined): Sco
const parts = value.split("@");
return parts.length === 2 && parts[0] !== "" && parts[1] !== "external" && rigs.has(parts[1]!);
};
const team = (value: unknown): boolean => member(value) && !caller(value) && !CALLER_SHAPED.test(value as string);
const team = (value: unknown): boolean => member(value) && !caller(value) && (participant || !CALLER_SHAPED.test(value as string));
const field = (row: unknown, name: "sourceSession" | "destinationSession"): unknown => ((row ?? {}) as Record<string, unknown>)[name];
const item = (row: unknown): boolean => caller(field(row, "sourceSession"))
|| (caller(field(row, "destinationSession")) && team(field(row, "sourceSession")));
return {
session: (value) => caller(value) || team(value),
item,
owner: () => false,
// Only an item addressed to its exact seat, and one it may read, is its own to change; the daemon's claim also checks
// the claimant.
owner: (row) => caller(field(row, "destinationSession")) && item(row),
rig: (name) => typeof name === "string" && rigs.has(name),
root: (name) => typeof name === "string" && access.roots.includes(name),
// A task goes to the team: never to the caller itself or to another caller-shaped seat, which v1 can't tell apart.
// A task goes to another seat of its rigs: never to the caller itself, and without team/list never to another
// caller-shaped seat, which v1 can't tell apart.
destination: team,
successor: caller,
artifact: (row) => caller(field(row, "sourceSession")),
@@ -480,7 +488,8 @@ export type McpAccess = {
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
const EXTERNAL_TOOLS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list" },
queue: { create: "rig_queue_create", show: "rig_queue_show", list: "rig_queue_list", claim: "rig_queue_claim", update: "rig_queue_update" },
team: { list: "rig_team" },
artifact: { read: "rig_artifact_read" },
};
@@ -564,6 +573,16 @@ const SHARED_INSTRUCTIONS = [
];
const INSTRUCTIONS = [...SHARED_INSTRUCTIONS, MEMBER_WORKFLOW].join("\n");
const EXTERNAL_INSTRUCTIONS = SHARED_INSTRUCTIONS.join("\n");
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined): string {
if (!access?.external) return INSTRUCTIONS;
const tools = externalTools(access.external.objects);
return tools.has("rig_queue_claim") && tools.has("rig_queue_update") ? EXTERNAL_INBOX_INSTRUCTIONS : EXTERNAL_INSTRUCTIONS;
}
/**
* Maps a DaemonResponse to MCP tool result.
@@ -616,7 +635,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
const server = new McpServer({
name: "openrig",
version: CLI_VERSION,
}, { instructions: options.access?.external ? EXTERNAL_INSTRUCTIONS : INSTRUCTIONS });
}, { instructions: instructionsFor(options.access) });
const { access } = options;
const rostersFolder = options.rostersFolder ?? (() => nodePath.join(new ConfigStore().get("workspace.root") as string, "rosters"));
const presenceFile = options.presenceFile ?? (() => mcpPresenceFilePath());
@@ -1345,7 +1364,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId);
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {}), undefined, { structured: true });
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
// An external caller's claimed item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1376,9 +1398,18 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
try {
const denied = await itemOutOfScope(qitemId, "owner");
if (denied) return denied;
return mapResult(await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
// An external caller waits only on an item it names and can read: parking on a person would ask them, parking on
// another item would say whether it exists, and a park that names nothing would restore a blocker the team set.
if (access?.external && (blockedOn || state === "blocked")) {
const blocker = blockedOn?.startsWith("qitem-") ? await client.get(`/api/queue/${encodeURIComponent(blockedOn)}`) : null;
if (!blocker || blocker.status >= 400 || !scope?.item(blocker.data)) return outOfScope(`Blocker ${blockedOn ?? "(none named)"}`);
}
const res = await client.post(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
state, closureReason, closureTarget, summary, evidenceRef, blockedOn, wakeAfterSeconds, transitionNote: note, resolution, reopen,
}), undefined, { structured: true });
});
// An external caller's updated item goes through the same projection as show and list.
const view = externalView(scope);
return mapResult(view && res.status < 400 ? { ...res, data: projectForExternal(res.data, view) } : res, undefined, { structured: true });
} catch (err) {
return caught(err);
}
@@ -1491,6 +1522,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
},
async ({ rigId }) => {
try {
// An external caller names one of its grant's rigs, so a lookup never answers about another workspace's rig.
if (access?.external && !scope?.rig(rigId)) return outOfScope(`Rig ${rigId}`);
const rig = await rigIdFor(client, rigId);
if ("content" in rig) return rig;
const id = encodeURIComponent(rig.id);
@@ -1511,7 +1544,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
async (seat) => {
const rows = await client.get(`/api/queue/list?${new URLSearchParams({ destinationSession: seat, compact: "1", limit: String(PRESENCE_ROWS) }).toString()}`)
.then((res) => (res.status < 400 ? res.data : null), () => null);
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(rows, seat),
// An external caller sees a ring only on an item it may read, so another pair's item isn't named.
const visible = access?.external && Array.isArray(rows) ? rows.filter((row) => scope?.item(row)) : rows;
return { lastCallAt: readPresence(presenceFile())[seat]?.lastCallAt ?? null, lastWake: newestWake(visible, seat),
lastWakeScope: `the latest ring among its ${PRESENCE_ROWS} most recent rows` };
});
return mapResult({ status: 200, data: { ...view, hostedMembers: hosted.members, ...(hosted.note ? { hostedMembersNote: hosted.note } : {}) } }, undefined, { structured: true });
+18 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { execFileSync, spawn } from "node:child_process";
import http from "node:http";
import { Command } from "commander";
import { writeTextAtomically } from "../src/atomic-text-write.js";
import { writeNewText, writeTextAtomically } from "../src/atomic-text-write.js";
import { exportCommand } from "../src/commands/export.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
import { allowFetchTarget, resetFetchAllowlist } from "./fetch-guard.js";
@@ -35,6 +35,23 @@ it("preserves an existing export and removes staging after a partial failed writ
expect(fs.readFileSync(target, "utf8")).toBe("name: original\n");
expect(fs.readdirSync(dir)).toEqual(["rig.yaml"]);
});
it("writeNewText creates a file but never replaces one, even a dangling link", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "rig.yaml"), link = join(dir, "link.yaml");
writeNewText(target, "name: first\n");
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
expect(() => writeNewText(target, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.readFileSync(target, "utf8")).toBe("name: first\n");
fs.symlinkSync(join(dir, "missing.yaml"), link);
expect(() => writeNewText(link, "name: second\n")).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.existsSync(join(dir, "missing.yaml"))).toBe(false);
});
it("writeNewText leaves nothing behind after a failed write", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
failure.active = true;
expect(() => writeNewText(join(dir, "rig.yaml"), "name: new\n")).toThrow(/no space left/);
expect(fs.readdirSync(dir)).toEqual([]);
});
it("retains export symlinks and target permissions on successful replacement", () => {
dir = fs.mkdtempSync(join(tmpdir(), "export-atomic-"));
const target = join(dir, "saved.yaml"), link = join(dir, "rig.yaml");
+1
View File
@@ -326,6 +326,7 @@ describe("Broadcast CLI", () => {
const output = logs.join("\n");
expect(output).toContain("No running sessions found");
expect(output).toContain("0/0 delivered");
expect(logs).toContainEqual(expect.stringMatching(/^FAILED — No running sessions found/));
expect(exitCode).toBe(1);
});
});
+17
View File
@@ -344,6 +344,23 @@ describe("Down CLI", () => {
expect(logs).toContain(" /work/project/CLAUDE.md: openrig-seat-skills.md");
});
it("names each file in a Claude seat's cwd that still holds OpenRig's additions, and keeps the exit code", async () => {
const kept = "/work/project/.openrig/context-collector.cjs: not the shipped collector's bytes, so it was kept";
responseOverride = {
status: 200,
body: {
rigId: "rig-1", sessionsKilled: 1, snapshotId: null, deleted: false, deleteBlocked: false, alreadyStopped: false, errors: [],
keptFiles: [kept],
},
};
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
});
expect(logs).toContain("Kept 1 file(s) that still hold OpenRig's additions; check them by hand:");
expect(logs).toContain(` ${kept}`);
expect(exitCode).toBeUndefined();
});
it.each([
{ rigName: "workshop", isUniqueName: false },
{},
+61
View File
@@ -262,6 +262,67 @@ describe("rig export + import", () => {
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
});
describe("export: a default export never replaces a file", () => {
const occupied = (...taken: string[]) => vi.fn((p: string) => {
if (taken.includes(p)) throw Object.assign(new Error(`EEXIST: ${p}`), { code: "EEXIST" });
});
const run = async (deps: Partial<ExportDeps>, ...args: string[]) => {
const program = new Command();
program.addCommand(exportCommand(exportDeps(deps)));
return captureLogs(() => program.parseAsync(["node", "rig", "export", "rig-1", ...args]));
};
it("the first export writes rig.yaml", async () => {
const writeNewFile = occupied();
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml"]);
expect(logs).toContain("Exported to rig.yaml");
expect(logs.join("\n")).not.toContain("left unchanged");
});
it("an occupied rig.yaml sends the export to a sibling, and says which", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const exitCode = process.exitCode;
const logs = await run({ writeFile, writeNewFile });
expect(writeFile).not.toHaveBeenCalled();
expect(writeNewFile).toHaveBeenLastCalledWith("rig-export.yaml", expect.any(String));
expect(logs).toContain("Exported to rig-export.yaml");
expect(logs.join("\n")).toContain("rig.yaml already exists and was left unchanged");
expect(process.exitCode).toBe(exitCode);
});
it("occupied siblings are kept too", async () => {
const writeNewFile = occupied("rig.yaml", "rig-export.yaml", "rig-export-2.yaml");
const logs = await run({ writeNewFile });
expect(writeNewFile.mock.calls.map((c) => c[0])).toEqual(["rig.yaml", "rig-export.yaml", "rig-export-2.yaml", "rig-export-3.yaml"]);
expect(logs).toContain("Exported to rig-export-3.yaml");
});
it("-o replaces the file it names, including rig.yaml", async () => {
const writeFile = vi.fn();
const writeNewFile = occupied("rig.yaml");
const logs = await run({ writeFile, writeNewFile }, "-o", "rig.yaml");
expect(writeFile).toHaveBeenCalledWith("rig.yaml", expect.any(String));
expect(writeNewFile).not.toHaveBeenCalled();
expect(logs).toContain("Exported to rig.yaml");
});
it("a real write failure stays a failure, with no success reported", async () => {
const writeNewFile = vi.fn(() => { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); });
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const program = new Command();
program.addCommand(exportCommand(exportDeps({ writeNewFile })));
await expect(program.parseAsync(["node", "rig", "export", "rig-1"])).rejects.toThrow("no space");
expect(writeNewFile).toHaveBeenCalledTimes(1);
expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Exported to"));
} finally {
log.mockRestore();
}
});
});
it("export: warns when culture_file or startup files do not resolve relative to output directory", async () => {
const writeFile = vi.fn();
const exists = vi.fn((p: string) => !p.includes("missing-culture.md") && !p.includes("missing-start.md") && !p.includes("optional-start.md"));
@@ -0,0 +1,329 @@
// Agent network, increment A: two assistants connected through the grant service (openrig.grant/v1), each with its own
// seat, discover each other and the team, queue work to each other, claim it and return the result on the same item,
// and come back to it without duplicates. Driven through rig mcp serve --http --grant-service and the daemon's real
// routes; the fake grant service answers each connection's key with that connection's grant.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { serve, type ServerType } from "@hono/node-server";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type Database from "better-sqlite3";
import { createTestApp } from "../../daemon/test/helpers/test-app.js";
import { createDb } from "../../daemon/src/db/connection.js";
import { migrate } from "../../daemon/src/db/migrate.js";
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
import { EventBus } from "../../daemon/src/domain/event-bus.js";
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
import { setSelfHostId } from "../../daemon/src/domain/hosts/fanout-contract.js";
import { startMcpHttpServer } from "../src/mcp-http.js";
const SCHEMA = "openrig.grant/v1";
const INSTANCE = "core-test-01";
const RIG = "team-a";
const LEAD = `lead@${RIG}`;
/** Two connections of one account: each its own key, grant and seat. */
const A = { key: `${"8977f2dc0000aaaa1111bbbb"}.cus_VVMAexample01.${"ab".repeat(24)}`, grant: "8977f2dc0000aaaa1111bbbb", seat: `cus-vvmaexample01-chatgpt@${RIG}` };
const B = { key: `${"8977f2dc0000aaaa2222cccc"}.cus_VVMAexample01.${"cd".repeat(24)}`, grant: "8977f2dc0000aaaa2222cccc", seat: `cus-vvmaexample01-claude@${RIG}` };
const NETWORK = { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] };
const V1 = { queue: ["create", "show", "list"], artifact: ["read"] };
const active = (who: typeof A, objects: Record<string, string[]>) => ({
schema: SCHEMA, status: "active",
principal: { id: "cus_VVMAexample01", kind: "external_caller", label: "External caller …mple01" },
grant: { id: who.grant, version: 1, updated_at: "2026-11-02T05:00:00.000Z", expires_at: null, cache_seconds: 0, revocation: "per_request" },
environment: { id: RIG, core_instance: INSTANCE, studio: "https://studio.example", lead: LEAD },
seat: who.seat, rigs: [RIG], artifact_roots: [], access_class: "work", objects,
});
describe("agent network increment A: OAuth-connected assistants work with each other and the team", () => {
let db: Database.Database;
let home: string;
let repo: QueueRepository;
let daemon: ServerType;
let daemonUrl: string;
let setup: ReturnType<typeof createTestApp>;
let service: http.Server;
let serviceUrl: string;
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
let grants: Map<string, Record<string, string[]> | "revoked">;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
beforeEach(async () => {
db = createDb(":memory:");
migrate(db, ALL_MIGRATIONS);
home = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-network-"));
process.env.OPENRIG_HOME = home;
process.env.OPENRIG_SESSION_NAME = "env-seat@elsewhere";
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
setup = createTestApp(db, { appDeps: { queueRepo: repo } });
daemon = await new Promise<ServerType>((resolve) => { const s = serve({ fetch: setup.app.fetch, port: 0, hostname: "127.0.0.1" }, () => resolve(s)); });
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
setSelfHostId(INSTANCE);
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
service = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
const key = String(req.headers.authorization ?? "").replace(/^Bearer /, "");
const who = key === A.key ? A : key === B.key ? B : null;
const objects = who ? grants.get(who.key) : undefined;
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
});
});
await new Promise<void>((resolve) => service.listen(0, "127.0.0.1", resolve));
serviceUrl = `http://127.0.0.1:${(service.address() as AddressInfo).port}/api/connector/v1/grant`;
for (const member of ["cus-vvmaexample01-chatgpt", "cus-vvmaexample01-claude", "lead"]) seat(RIG, member);
});
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close();
await new Promise<void>((resolve) => service.close(() => resolve()));
await new Promise<void>((resolve) => daemon.close(() => resolve()));
setSelfHostId(null);
db.close();
fs.rmSync(home, { recursive: true, force: true });
if (saved.home === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = saved.home;
if (saved.session === undefined) delete process.env.OPENRIG_SESSION_NAME; else process.env.OPENRIG_SESSION_NAME = saved.session;
});
function seat(rigName: string, member: string) {
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
setup.sessionRegistry.registerSession(node.id, `${member}@${rigName}`);
}
async function listen() {
const served = await startMcpHttpServer({ daemonUrl, host: "127.0.0.1", port: 0, tokenFile: path.join(home, "mcp-http-tokens.json"),
grantService: { url: serviceUrl, timeoutMs: 1_000 } });
closers.push(() => served.close());
return served;
}
async function connect(url: string, who: typeof A) {
const client = new Client({ name: "network-test", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${who.key}` } } }));
closers.push(() => client.close());
const call = async (name: string, args: Record<string, unknown> = {}) => {
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
}
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
const row = (qitemId: string) => repo.getById(qitemId)!;
const createdId = (result: { structured: Record<string, any>; text: string }) => String(result.structured?.qitemId ?? JSON.parse(result.text).qitemId);
it("offers claim, update and rig_team only to a grant that names them; a v1 grant keeps exactly today's four tools", async () => {
grants.set(B.key, V1);
const served = await listen();
expect(await (await connect(served.url, A)).tools()).toEqual(
["rig_artifact_read", "rig_queue_claim", "rig_queue_create", "rig_queue_list", "rig_queue_show", "rig_queue_update", "rig_team"]);
expect(await (await connect(served.url, B)).tools()).toEqual(["rig_artifact_read", "rig_queue_create", "rig_queue_list", "rig_queue_show"]);
});
it("A discovers B and the team, queues work to B; B claims it and returns the result on the same item, which A reads", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const team = await a("rig_team", { rigId: RIG });
expect(team.isError).toBe(false);
expect(team.text).toContain(B.seat);
expect(team.text).toContain(LEAD);
const sent = await a("rig_queue_create", { destination: B.seat, body: "Critique this plan.", tags: ["review"] });
expect(sent.isError).toBe(false);
const qitemId = createdId(sent);
expect(row(qitemId)).toMatchObject({ sourceSession: A.seat, destinationSession: B.seat });
// What A reads at each step: queued (pending, unclaimed), picked up (B's claim), answered (done with a resolution).
// B reading or listing the item changes none of it.
const inbox = await b("rig_queue_list", { owned: true });
expect(inbox.text).toContain(qitemId);
expect((await b("rig_queue_show", { qitemId })).isError).toBe(false);
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "pending", claimedAt: null });
expect((await b("rig_queue_claim", { qitemId })).isError).toBe(false);
expect((await a("rig_queue_show", { qitemId })).structured.item).toMatchObject({ state: "in-progress", resolution: null });
expect((await b("rig_queue_update", { qitemId, state: "done", resolution: "Two risks; see the notes.", closureReason: "no-follow-on" })).isError).toBe(false);
expect(row(qitemId)).toMatchObject({ state: "done", resolution: "Two risks; see the notes." });
const followUp = await a("rig_queue_show", { qitemId });
expect(followUp.isError).toBe(false);
expect(followUp.text).toContain("Two risks; see the notes.");
expect(followUp.text).toContain(B.seat);
});
it("a message is an item the receiver reads and closes, and the reply is an item back, read by the first assistant", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "FYI: the plan changed", body: "We're shipping the smaller fix first.", tags: ["message"] }));
expect((await b("rig_queue_show", { qitemId: message })).text).toContain("We're shipping the smaller fix first.");
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Read." })).isError).toBe(false);
const reply = createdId(await b("rig_queue_create", { destination: A.seat, summary: "Re: the plan changed", body: "Agreed; I'll review the fix.", tags: ["message"] }));
expect(row(reply)).toMatchObject({ sourceSession: B.seat, destinationSession: A.seat });
expect((await a("rig_queue_list", { owned: true })).text).toContain(reply);
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
});
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
const served = await listen();
const b = await connect(served.url, B);
const delegated = createdId(await b("rig_queue_create", { destination: LEAD, body: "Implement the agreed change." }));
repo.update({ qitemId: delegated, actorSession: LEAD, state: "done", closureReason: "no-follow-on", resolution: "Merged as abc123." } as never);
expect((await b("rig_queue_show", { qitemId: delegated })).text).toContain("Merged as abc123.");
});
it("a create retried with the same qitemId after a lost reply is one item, and a new session with the same key finds the same work", async () => {
const served = await listen();
const a = await connect(served.url, A);
const first = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
const before = rows();
const again = createdId(await a("rig_queue_create", { destination: B.seat, body: "Once.", qitemId: "qitem-network-retry-1" }));
expect(again).toBe(first);
expect(rows()).toBe(before);
const reentered = await connect(served.url, A);
expect((await reentered("rig_queue_list", {})).text).toContain(first);
expect(rows()).toBe(before);
});
it("two assistants calling at once each act as their own seat", async () => {
const served = await listen();
const [a, b] = await Promise.all([connect(served.url, A), connect(served.url, B)]);
const [fromA, fromB] = await Promise.all([
a("rig_queue_create", { destination: LEAD, body: "from A" }),
b("rig_queue_create", { destination: LEAD, body: "from B" }),
]);
expect(row(createdId(fromA)).sourceSession).toBe(A.seat);
expect(row(createdId(fromB)).sourceSession).toBe(B.seat);
});
it("each changes only items addressed to its own seat, stays in its workspace, and a revoked connection changes nothing", async () => {
seat("team-b", "lead");
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
const toB = createdId(await a("rig_queue_create", { destination: B.seat, body: "for B" }));
const toTeam = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const toA = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "for A", nudge: false } as never)).qitemId;
const refused = { isError: true, structured: { code: "out_of_scope" } };
// A sent B's item and may read it; the daemon refuses its claim as someone else's.
expect((await a("rig_queue_claim", { qitemId: toB })).isError).toBe(true);
expect(await a("rig_queue_update", { qitemId: toB, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
expect(await b("rig_queue_claim", { qitemId: toTeam })).toMatchObject(refused);
expect(await b("rig_queue_claim", { qitemId: toA })).toMatchObject(refused);
expect(await b("rig_queue_show", { qitemId: toTeam })).toMatchObject(refused);
expect(await a("rig_queue_create", { destination: "lead@team-b", body: "other workspace" })).toMatchObject(refused);
expect(await a("rig_queue_create", { destination: "someone@external", body: "a person" })).toMatchObject(refused);
expect(await a("rig_team", { rigId: "team-b" })).toMatchObject(refused);
expect(row(toB).state).toBe("pending");
// B waits only on an item it can read: never on a person (who would be asked) or on the team's own item.
expect((await b("rig_queue_claim", { qitemId: toB })).isError).toBe(false);
for (const blockedOn of ["someone@external", toTeam, "qitem-network-missing"]) {
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn }))).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
}
expect(row(toB).state).toBe("in-progress");
const ask = createdId(await b("rig_queue_create", { destination: LEAD, body: "Which of the two plans?" }));
expect((await b("rig_queue_update", { qitemId: toB, state: "blocked", blockedOn: ask })).isError).toBe(false);
expect(row(toB)).toMatchObject({ state: "blocked", blockedOn: ask });
grants.set(B.key, "revoked");
const before = rows();
await expect(b("rig_queue_update", { qitemId: toB, state: "in-progress" })).rejects.toThrow();
expect(row(toB).state).toBe("blocked");
expect(rows()).toBe(before);
});
it("claim and update answer an assistant with what show shows: a blocker or closure target it can't read stays hidden", async () => {
const served = await listen();
const b = await connect(served.url, B);
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, closureReason: "blocked_on", closureTarget: internal, transitionNote: "waiting on the team" } as never);
const shown = await b("rig_queue_show", { qitemId: mine });
const noted = await b("rig_queue_update", { qitemId: mine, note: "seen it" });
const claimed = await b("rig_queue_claim", { qitemId: mine });
for (const result of [shown, noted, claimed]) {
expect(result.isError).toBe(false);
expect(result.text).not.toContain(internal);
}
expect(row(mine).state).toBe("in-progress");
});
it("an assistant can't change an item addressed to it that it can't read: another workspace's, or another caller's under a partial grant", async () => {
grants.set(B.key, { queue: ["create", "show", "list", "claim", "update"], artifact: ["read"] });
const served = await listen();
const b = await connect(served.url, B);
const refused = { isError: true, structured: { code: "out_of_scope" } };
const foreign = (await repo.create({ sourceSession: "lead@team-b", destinationSession: B.seat, body: "from another workspace", nudge: false } as never)).qitemId;
const fromCaller = (await repo.create({ sourceSession: "cus-vvmaexample02-caller@team-a", destinationSession: B.seat, body: "from another caller", nudge: false } as never)).qitemId;
for (const qitemId of [foreign, fromCaller]) {
const before = repo.listTransitions(qitemId).length;
expect(await b("rig_queue_update", { qitemId, note: "mine?" })).toMatchObject(refused);
expect(await b("rig_queue_update", { qitemId, state: "done", closureReason: "no-follow-on" })).toMatchObject(refused);
expect(row(qitemId).state).toBe("pending");
expect(repo.listTransitions(qitemId).length).toBe(before);
}
});
it("an assistant's park names a blocker it can read: a bare re-park after a claim is refused, whether the team's blocker is open or closed", async () => {
const served = await listen();
const b = await connect(served.url, B);
for (const closedAfterClaim of [false, true]) {
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: "worker@team-a", body: "team-internal", nudge: false } as never)).qitemId;
const mine = (await repo.create({ sourceSession: LEAD, destinationSession: B.seat, body: "for B", nudge: false } as never)).qitemId;
repo.update({ qitemId: mine, actorSession: LEAD, state: "blocked", blockedOn: internal, transitionNote: "waiting on the team" } as never);
expect((await b("rig_queue_claim", { qitemId: mine })).isError).toBe(false);
if (closedAfterClaim) repo.update({ qitemId: internal, actorSession: "worker@team-a", state: "done", closureReason: "no-follow-on" } as never);
const reparked = await b("rig_queue_update", { qitemId: mine, state: "blocked" });
expect(reparked).toMatchObject({ isError: true, structured: { code: "out_of_scope" } });
expect(reparked.text).not.toContain(internal);
expect(row(mine)).toMatchObject({ state: "in-progress", blockedOn: null });
}
});
it("team discovery names a hosted member's last ring only when it's on an item the assistant may read", async () => {
const workspace = path.join(home, "workspace");
const savedWorkspace = process.env.OPENRIG_WORKSPACE_ROOT;
process.env.OPENRIG_WORKSPACE_ROOT = workspace;
try {
fs.mkdirSync(path.join(workspace, "rosters"), { recursive: true });
fs.writeFileSync(path.join(workspace, "rosters", "team.json"), JSON.stringify({
version: 1, id: "team", name: "Team", purpose: "Ship the release", curator: { seat: LEAD, host: INSTANCE }, updated_at: "2026-11-02",
members: [{ seat: `grok@${RIG}`, host: INSTANCE, capabilities: ["web research"], engagement: ["delegate"], use_when: "Current sources", why: "Searches the web", hosted: true }],
}));
const served = await listen();
const a = await connect(served.url, A);
const ring = (qitemId: string, at: string) =>
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?").run(at, "wake-accepted:webhook answered 202", qitemId);
const fromA = (await repo.create({ sourceSession: A.seat, destinationSession: `grok@${RIG}`, body: "Find the release date.", nudge: false } as never)).qitemId;
const internal = (await repo.create({ sourceSession: LEAD, destinationSession: `grok@${RIG}`, body: "team-internal", nudge: false } as never)).qitemId;
ring(fromA, "2026-11-02T11:00:00.000Z");
ring(internal, "2026-11-02T12:00:00.000Z");
const team = await a("rig_team", { rigId: RIG });
expect(team.isError).toBe(false);
expect(team.structured.hostedMembers).toMatchObject([{ session: `grok@${RIG}`, presence: { lastWake: { qitemId: fromA, at: "2026-11-02T11:00:00.000Z" } } }]);
expect(team.text).not.toContain(internal);
} finally {
if (savedWorkspace === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT; else process.env.OPENRIG_WORKSPACE_ROOT = savedWorkspace;
}
});
it("a v1 grant keeps today's rules: no task to or from another caller's seat", async () => {
grants.set(A.key, V1);
grants.set(B.key, V1);
const served = await listen();
const a = await connect(served.url, A);
const refused = { isError: true, structured: { code: "out_of_scope" } };
expect(await a("rig_queue_create", { destination: B.seat, body: "v1 can't" })).toMatchObject(refused);
const fromB = (await repo.create({ sourceSession: B.seat, destinationSession: A.seat, body: "from another caller", nudge: false } as never)).qitemId;
expect(await a("rig_queue_show", { qitemId: fromB })).toMatchObject(refused);
});
});
+30
View File
@@ -127,3 +127,33 @@ it("does not mistake a deeper subsection for the requested H2 section", () => {
expect(addProgressRow(source, { section: "Rail", text: "Release verified", status: "done" }).content)
.toBe(source + "\n## Rail\n\n- [x] Release verified\n");
});
describe("progress edits preserve fenced examples", () => {
it.each(["```md", "~~~md", "````md", " ```md"])("sets only the real row after %s", (opening) => {
const marker = opening.trimStart().startsWith("~") ? "~" : "`";
const closing = marker.repeat(5);
const example = [opening, "## Rail", "- [ ] Ship it", marker.repeat(2), "- [ ] Still an example", closing].join("\n");
const content = `# Progress\n${example}\n## Rail\n- [ ] Ship it\n`;
const result = setProgressRow(content, { text: "Ship it", status: "done" });
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n- [x] Ship it\n`);
});
it("does not update a row that exists only in a code block", () => {
const content = "# Progress\n## Rail\n```md\n- [ ] Example\n```\n";
expect(() => setProgressRow(content, { text: "Example", status: "done" })).toThrow(/No progress row/);
});
it("ignores example headings and duplicates when adding a row", () => {
const example = "```md\n## Rail\n- [x] Ship it\n```";
const content = `# Progress\n${example}\n## Rail\n${example}\n\n## Notes\nKeep this.\n`;
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
expect(result.content).toBe(`# Progress\n${example}\n## Rail\n${example}\n- [ ] Ship it\n\n## Notes\nKeep this.\n`);
});
it("inserts outside an unclosed example at the end of the section", () => {
const content = "# Progress\n## Rail\n- [ ] Existing\n\n```md\n## Example\n- [ ] Ship it\n";
const result = addProgressRow(content, { section: "Rail", text: "Ship it", status: "active" });
expect(result.content).toBe(content.replace("- [ ] Existing", "- [ ] Existing\n- [ ] Ship it"));
});
});
@@ -77,3 +77,15 @@ describe("FR-4 — verb output parses into the expected UI tree", () => {
expect(file.counts.blocked).toBe(1);
});
});
it.each(["```md\n## Rail\n- [ ] Ship it\n```\n", "```md\n## Rail\n- [ ] Ship it\n"])("adds and updates a real row without editing example %j", (example) => {
const original = `# Progress\n\n${example}`;
const added = addProgressRow(original, { section: "Rail", text: "Ship it", status: "active" }).content;
const updated = setProgressRow(added, { text: "Ship it", status: "done" }).content;
expect(updated).toContain(example);
fs.writeFileSync(path.join(root, "PROGRESS.md"), updated);
const file = indexerFor().scan().files[0]!;
expect(file.counts).toMatchObject({ total: 1, done: 1 });
expect(file.rows.filter(r => r.kind === "checkbox")).toMatchObject([{ text: "Ship it", status: "done" }]);
});
+111 -3
View File
@@ -18,8 +18,8 @@ vi.mock("../src/daemon-lifecycle.js", async () => {
};
});
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string }> } {
const calls: Array<{ method: string; path: string }> = [];
function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> } {
const calls: Array<{ method: string; path: string; body?: Record<string, unknown> }> = [];
return {
calls,
deps: {
@@ -27,7 +27,7 @@ function makeQueueDeps(): { deps: QueueDeps; calls: Array<{ method: string; path
clientFactory: () => ({
get: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: [] }; }),
getText: vi.fn(async () => ({ status: 200, data: "" })),
post: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 201, data: {} }; }),
post: vi.fn(async (path: string, body?: Record<string, unknown>) => { calls.push({ method: "POST", path, body }); return { status: 201, data: {} }; }),
delete: vi.fn(async () => ({ status: 204, data: null })),
postText: vi.fn(async () => ({ status: 200, data: "" })),
postExpectText: vi.fn(async () => ({ status: 200, data: "" })),
@@ -64,6 +64,59 @@ describe("Slice 15 — CLI contract honesty", () => {
expect(parsed.error.message).toMatch(/positive integer/);
});
const PRIORITY_VERBS: Array<[string, string[], string]> = [
["create", ["--source", "a@r", "--destination", "b@r", "--summary", "s", "--body", "x"], "/api/queue/create"],
["handoff", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff"],
["handoff-and-complete", ["qitem-x", "--to", "b@r"], "/api/queue/qitem-x/handoff-and-complete"],
];
async function sentPriority(verb: string, args: string[], path: string, flag: string[]) {
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig"); // handoff verbs act as the calling seat
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const run = await runCli(["queue", verb, ...args, ...flag, "--json"], { queueDeps: deps });
const call = calls.find((c) => c.path === path);
expect(call, JSON.stringify({ calls, run })).toBeDefined();
return { body: call!.body!, notes: stderr.mock.calls.map((c) => c.join(" ")).filter((l) => l.includes("--priority")) };
} finally {
stderr.mockRestore();
stdout.mockRestore();
vi.unstubAllEnvs();
}
}
describe.each(PRIORITY_VERBS)("queue %s --priority", (verb, args, path) => {
it.each([["Critical", "critical"], ["URGENT", "urgent"], ["Routine", "routine"], ["critical", "critical"]])(
"normalizes the documented value %s without a note", async (given, expected) => {
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
expect(body.priority).toBe(expected);
expect(notes).toEqual([]);
});
it.each(["high", "normal", "background", "P1-Hot"])("keeps %s as given, with one note", async (given) => {
const { body, notes } = await sentPriority(verb, args, path, ["--priority", given]);
expect(body.priority).toBe(given);
expect(notes).toHaveLength(1);
expect(notes[0]).toContain("outside the documented choices");
expect(notes[0]).not.toContain("invalid");
});
});
it("omitting --priority keeps create's routine default, without a note", async () => {
const [verb, args, path] = PRIORITY_VERBS[0]!;
const { body, notes } = await sentPriority(verb, args, path, []);
expect(body.priority).toBe("routine");
expect(notes).toEqual([]);
});
it.each(PRIORITY_VERBS.slice(1))("omitting --priority on %s sends none, so the priority is inherited", async (verb, args, path) => {
const { body, notes } = await sentPriority(verb, args, path, []);
expect(body.priority).toBeUndefined();
expect(notes).toEqual([]);
});
it("without --json, the same failure is plain text on stderr (no JSON on stdout) + nonzero exit", async () => {
const { deps } = makeQueueDeps();
const { out, err, exitCode } = await runCli(["queue", "list", "--limit", "-1"], { queueDeps: deps });
@@ -72,6 +125,61 @@ describe("Slice 15 — CLI contract honesty", () => {
expect(err).toMatch(/positive integer/);
});
it.each([
["pending, in_progress", ["in_progress"], "pending, in_progress"],
["bogus,also_bogus", ["bogus", "also_bogus"], "bogus,also_bogus"],
])("queue list --state %j warns on stderr and still runs the query as given", async (state, unknown, sent) => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
expect(exitCode).toBe(0);
const warning = stderr.mock.calls.map((c) => c.join(" ")).join("\n");
for (const name of unknown) expect(warning).toContain(`'${name}'`);
expect(warning).toContain("Supported states: pending, in-progress, done");
if (state.includes("pending")) expect(warning).not.toContain("empty queue");
else expect(warning).toContain("doesn't mean an empty queue");
const listCalls = calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"));
expect(listCalls).toHaveLength(1);
expect(new URLSearchParams(listCalls[0]!.path.split("?")[1]).get("state")).toBe(sent);
expect(JSON.parse(stdout.mock.calls.map((c) => c.join(" ")).join("\n"))).toEqual([]);
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it.each(["pending", " pending , in-progress ", "done,handed-off,failed,denied,canceled,blocked", "pending,", " "])(
"queue list --state %j runs without a warning", async (state) => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
const { exitCode } = await runCli(["queue", "list", "--state", state, "--json"], { queueDeps: deps });
expect(exitCode).toBe(0);
expect(stderr).not.toHaveBeenCalled();
expect(calls.filter((c: { path: string }) => c.path.startsWith("/api/queue/list"))).toHaveLength(1);
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it("queue list without --state sends no state filter and no warning", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const { deps, calls } = makeQueueDeps();
await runCli(["queue", "list", "--json"], { queueDeps: deps });
expect(stderr).not.toHaveBeenCalled();
expect(calls.find((c: { path: string }) => c.path.startsWith("/api/queue/list"))!.path).not.toContain("state=");
} finally {
stderr.mockRestore();
stdout.mockRestore();
}
});
it("the path is SHARED across families — a send-family error also emits JSON under --json", async () => {
const { out, exitCode } = await runCli(["send", "--json", "--no-such-flag"]);
expect(exitCode).toBe(1);
@@ -109,7 +109,8 @@ configurations, as below; do not take the pin from a cached page summary.
handoff carries, such as a trust or hook-review prompt they declined.
2. **Where it works.** If the folder is missing, ask which folder the team should
work in (usually a clone of their repository) and use its absolute path. Your
own working directory is OpenRig's workspace, not their project, so never launch with
own working directory is OpenRig's: the workspace's `kernel` folder (on an older
install, the workspace itself), not their project, so never launch with
`--cwd .` from here.
3. **Three teams, one recommendation.** Present starter, workshop and factory,
recommend one with a short reason tied to their goal, and show each graph
@@ -22,7 +22,8 @@ import { subtreeNamesClaudeExecutable, claudeCommandSessionToken, listNativeProc
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE, type ClaudeManagedBlockFile } from "../domain/managed-blocks.js";
import { claudeResumeLaunchEnv, claudeResumeLaunchPrefix } from "./claude-resume-launch.js";
import { shellQuote } from "./shell-quote.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
import { validateClaudeActivityHookDelivery, claudeActivityRelayPath, parseManagedClaudeNodeCommand } from "../domain/claude-activity-hooks.js";
import { isOwnedCollectorCommand, stripOwnedRelayHooks } from "../domain/claude-owned-settings.js";
import { isFilesystemRoot } from "../domain/cwd-resolution.js";
import { observeClaudePermission } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
@@ -1248,22 +1249,7 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
// 1. Strip OpenRig-owned relay entries (EXACT node-command shape) from all events;
// prune emptied groups + events. User-authored hooks are untouched.
let changed = false;
for (const event of Object.keys(hooks)) {
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
if (!groups) continue;
const keptGroups: unknown[] = [];
for (const group of groups) {
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
const groupHooks = group["hooks"] as unknown[];
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
if (keptHooks.length !== groupHooks.length) changed = true;
if (keptHooks.length === 0) continue; // prune emptied group
keptGroups.push({ ...group, hooks: keptHooks });
}
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
else hooks[event] = keptGroups;
}
let changed = stripOwnedRelayHooks(hooks);
// 2. Deliverable enable: copy the relay asset + upsert the owned entry for each
// PREVALIDATED relay event (derived from the canonical manifest above).
@@ -1309,11 +1295,6 @@ interface ActivityHookOutcome {
settingsUnparseable: boolean;
}
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
// user command that merely contains the path (echo, or node with extra args) does NOT.
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
function managedClaudeNodeCommand(executable: string, ...args: string[]): string {
const quotedArgs = args.map(shellQuote).join(" ");
const pinned = `${shellQuote(executable)} ${quotedArgs}`;
@@ -1321,37 +1302,6 @@ function managedClaudeNodeCommand(executable: string, ...args: string[]): string
return parseManagedClaudeNodeCommand(pinned) ? pinned : `node ${quotedArgs}`;
}
function hookCommand(hook: unknown): string | undefined {
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
}
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
// three/four-token commands too, but preserve commands composed with shell operators or
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
function isOwnedCollectorCommand(cmd: string): boolean {
if (/[\r\n]/.test(cmd)) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
const tokens = cmd.trim().split(/\s+/);
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
function isOwnedRelayCommand(cmd: string | undefined): boolean {
if (!cmd) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (!parsed || parsed.args.length !== 1) return false;
const relay = parsed.args[0]!;
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
}
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
+30 -4
View File
@@ -384,6 +384,8 @@ export async function readPiSessionEntryIds(
export class RunnerCore {
private streaming = false;
/** Pi runs this runner has opened, each posted as an accepted turn (contract section 4): the working beat names it. */
private turn = 0;
private sessionFile: string | undefined;
private sessionId: string | undefined;
private lastEntryId: string | undefined;
@@ -536,6 +538,7 @@ export class RunnerCore {
const message = block.slice("/followup ".length);
this.io.sendRpc({ type: "follow_up", message });
this.io.mirrorLine(`you (follow-up) ▸ ${message}`);
this.acceptIntoOpenRun();
return;
}
// The runtime decides, not the runner: with streamingBehavior "steer", Pi and OMP run the prompt when idle and,
@@ -544,6 +547,17 @@ export class RunnerCore {
// when a prompt was consumed locally and started none. It labels the mirror line only.
this.io.sendRpc({ type: "prompt", message: block, streamingBehavior: "steer" });
this.io.mirrorLine(this.streaming ? `you (steer) ▸ ${block}` : `you ▸ ${block}`);
this.acceptIntoOpenRun();
}
/** A working beat counts only for the latest accepted turn, and each prompt the runner gives the agent is accepted with
* its own number (contract section 4). One sent while a Pi run is open is posted as it's sent, and the run's end
* closes it. One sent while idle is posted when its run opens (handleEvent), so a prompt Pi handles without a run opens
* no turn. */
private acceptIntoOpenRun(): void {
if (this.runtime !== "pi" || !this.streaming) return;
this.turn += 1;
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
}
/** Child process exit — honest, loud, durable. */
@@ -722,8 +736,9 @@ export class RunnerCore {
* idle seat read `unknown` five minutes after its last turn, and a seat mid-turn read `unknown`
* through a long silent LLM request (the seat-activity oracle stops a bare working hook from
* deciding after 15s, HOOK_AUTHORITY_WINDOW_MS). Beat while ready and not waiting on the
* operator: idle (Stop) when no turn is in flight, streaming (active) while `streaming` says
* one is, each at most once per its own interval behind the newest real event. Same honesty
* operator: idle (Stop) when no turn is in flight, and while `streaming` says one is, the
* contract's working beat (`active` / `liveness`, section 4) for the run's accepted turn, each
* at most once per its own interval behind the newest real event. Same honesty
* rule: a runner that dies stops beating and the seat goes back to `unknown` (handlePiExit
* clears `ready`, so no beat outlives the process it vouches for). Pi only; the OMP identity
* handshake has its own retry.
@@ -742,7 +757,7 @@ export class RunnerCore {
const now = Date.parse(this.io.now());
if (this.streaming) {
if (this.lastActivityPostMs !== null && now - this.lastActivityPostMs < STREAM_HEARTBEAT_MS) return;
this.io.postActivity(this.activityPayload("active", "streaming"));
this.io.postActivity(this.activityPayload("active", "liveness", { turnId: String(this.turn), livenessSeconds: WORK_LIVENESS_SECONDS }));
return;
}
// `streaming` follows only agent_start/agent_end; turn_start, compaction and auto-retry also report the agent
@@ -809,7 +824,15 @@ export class RunnerCore {
if (event.type === "turn_start") this.assistantErrorShown = false;
}
const mapped = mapPiEvent(event, this.runtime);
// A working beat counts only for the latest accepted turn. Pi's runs start from this runner's prompts, so the run
// that opens is posted as one, before its first event; a prompt Pi handles without a run opens none. A prompt sent
// into an open run is accepted as it's sent (acceptIntoOpenRun).
const opening = this.runtime === "pi" && mapped.streaming === true && !this.streaming;
if (mapped.streaming !== undefined) this.streaming = mapped.streaming;
if (opening) {
this.turn += 1;
this.io.postActivity(this.activityPayload("UserPromptSubmit", "accepted", { turnId: String(this.turn) }));
}
// OMP writes its JSONL lazily; re-announce identity after each turn until
// the daemon confirms the token. Pi's get_state identity is final.
if (event.type === "agent_end" && this.runtime === "omp") this.postSessionIdentity();
@@ -902,7 +925,7 @@ export class RunnerCore {
/** Each activity post's place in this runner's event order: posts aren't awaited, so they can arrive out of order. */
private activitySequence = 0;
private activityPayload(hookEvent: string, subtype: string | null): Record<string, unknown> {
private activityPayload(hookEvent: string, subtype: string | null, extra: Record<string, unknown> = {}): Record<string, unknown> {
this.lastPostedHook = hookEvent;
this.lastActivityPostMs = Date.parse(this.io.now());
// Any posted Stop is an idle report, so the next beat is due a full interval after it, not just after a beat.
@@ -917,6 +940,7 @@ export class RunnerCore {
subtype,
// A Pi runner's idle report holds only while it keeps beating; the daemon expires it after two missed beats.
...(this.runtime === "pi" && hookEvent === "Stop" && subtype !== "pi_exited" ? { livenessSeconds: IDLE_HEARTBEAT_MS / 1000 } : {}),
...extra,
occurredAt: this.io.now(),
};
}
@@ -1074,6 +1098,8 @@ const IDLE_HEARTBEAT_MS = 3 * 60_000;
const PI_HEARTBEAT_TICK_MS = 5_000;
/** Minimum age of the newest activity post before the streaming beat may re-assert it. */
const STREAM_HEARTBEAT_MS = 5_000;
/** How long one working beat counts (contract section 4). Beats land 5–10 s apart, well inside half of it. */
const WORK_LIVENESS_SECONDS = 30;
/** The Pi heartbeat scheduler — one 5s tick drives both beats (see PI_HEARTBEAT_TICK_MS).
* Exported so tests drive the real interval with fake timers instead of calling heartbeat()
@@ -132,6 +132,9 @@ export interface ActivityEvidence {
/** How often the source promises to report while idle. A durable idle boundary holds for two of these, then lapses to
* unknown: a runner that died without saying so stops reading idle. Absent: the boundary holds until the next. */
livenessSeconds?: number;
/** A counted working beat from a runner (the runner contract §4): how long it counts as working, 5-60 s, in place of
* the hook window a bare working post has. */
workingLivenessSeconds?: number;
activity?: ActivityValue;
needsInput?: NeedsInput;
permissionRequest?: { id: string; hookAgentId?: string | null };
@@ -0,0 +1,135 @@
// OpenRig's own additions in a Claude seat's cwd: the context collector `.openrig/context-collector.cjs`, its
// `statusLine`, and the activity relay hooks in `.claude/settings.local.json`. Ownership is the exact generated
// Node-command shape. The adapter's install and reconcile and rig down's cleanup (#875) share these matchers, so what
// down takes back is exactly what a launch would recognise as its own.
import fs from "node:fs";
import nodePath from "node:path";
import { CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH, parseManagedClaudeNodeCommand } from "./claude-activity-hooks.js";
// OpenRig-owned relay path suffix. Ownership is the generated Node command whose single
// argument ends with this path — a changed prefix still matches (replace, not duplicate); a
// user command that merely contains the path (echo, or node with extra args) does NOT.
const LEGACY_OWNED_RELAY_SUFFIX = "/.openrig/hooks/scripts/activity-relay.cjs";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hookCommand(hook: unknown): string | undefined {
return isPlainObject(hook) && typeof hook["command"] === "string" ? (hook["command"] as string) : undefined;
}
// OpenRig-owned collectors use canonical POSIX-quoted paths. Recognise older unquoted
// three/four-token commands too, but preserve commands composed with shell operators or
// extra arguments. Old unquoted paths containing spaces cannot be identified unambiguously.
const OWNED_COLLECTOR_SUFFIX = nodePath.sep + nodePath.join(".openrig", "context-collector.cjs");
export function isOwnedCollectorCommand(cmd: string): boolean {
if (/[\r\n]/.test(cmd)) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (parsed && (parsed.args.length === 2 || parsed.args.length === 3)) {
return parsed.args[0]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
const tokens = cmd.trim().split(/\s+/);
if ((tokens.length !== 3 && tokens.length !== 4) || tokens[0] !== "node") return false;
if (tokens.some((token) => /[;&|<>`$()'"\\]/.test(token))) return false;
return tokens[1]!.endsWith(OWNED_COLLECTOR_SUFFIX);
}
export function isOwnedRelayCommand(cmd: string | undefined): boolean {
if (!cmd) return false;
const parsed = parseManagedClaudeNodeCommand(cmd);
if (!parsed || parsed.args.length !== 1) return false;
const relay = parsed.args[0]!;
return relay.endsWith("/plugins/openrig-core/hooks/scripts/activity-relay.cjs")
|| relay.endsWith(LEGACY_OWNED_RELAY_SUFFIX)
|| relay.endsWith(`/${CLAUDE_ACTIVITY_RELAY_RELATIVE_PATH}`);
}
/** Strip OpenRig-owned relay entries (EXACT node-command shape) from all events, hook by hook: a group keeps its other
* hooks and fields, and an emptied group or event is pruned. User-authored hooks are untouched. Returns whether an
* owned entry was removed. */
export function stripOwnedRelayHooks(hooks: Record<string, unknown>): boolean {
let changed = false;
for (const event of Object.keys(hooks)) {
const groups = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : null;
if (!groups) continue;
const keptGroups: unknown[] = [];
for (const group of groups) {
if (!isPlainObject(group) || !Array.isArray(group["hooks"])) { keptGroups.push(group); continue; }
const groupHooks = group["hooks"] as unknown[];
const keptHooks = groupHooks.filter((h) => !isOwnedRelayCommand(hookCommand(h)));
if (keptHooks.length !== groupHooks.length) changed = true;
if (keptHooks.length === 0) continue; // prune emptied group
keptGroups.push({ ...group, hooks: keptHooks });
}
if (keptGroups.length === 0) delete hooks[event]; // prune emptied event
else hooks[event] = keptGroups;
}
return changed;
}
/** Take OpenRig's own entries out of parsed Claude settings: its relay hooks, and the `type` and `command` its collector
* install wrote into `statusLine`. The person's other `statusLine` fields and every other setting stay. Returns
* whether anything changed. */
export function removeOwnedClaudeSettings(settings: Record<string, unknown>): boolean {
let changed = false;
const hooks = settings["hooks"];
if (isPlainObject(hooks) && stripOwnedRelayHooks(hooks)) {
changed = true;
if (Object.keys(hooks).length === 0) delete settings["hooks"];
}
const statusLine = settings["statusLine"];
if (isPlainObject(statusLine) && typeof statusLine["command"] === "string" && isOwnedCollectorCommand(statusLine["command"])) {
const rest = { ...statusLine };
delete rest["command"];
if (rest["type"] === "command") delete rest["type"];
if (Object.keys(rest).length > 0) settings["statusLine"] = rest;
else delete settings["statusLine"];
changed = true;
}
return changed;
}
function removeIfEmpty(dir: string, isShared: (path: string) => boolean): void {
try {
if (!isShared(dir) && fs.lstatSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
} catch {
// Missing, or not ours to judge: leave it.
}
}
const SHARED = "another seat's directory still uses it through a link, so it was kept";
/** rig down (#875), for a cwd no Claude seat still uses: take back what OpenRig added there for Claude.
* - Its entries leave `.claude/settings.local.json`, which is kept even when that leaves `{}`, since nothing records
* whether OpenRig created it. Settings that don't parse as an object are left as they are.
* - The collector goes only while its bytes are the shipped collector's.
* - `.openrig` and `.claude` go only if they're then empty, never recursively.
* - A target `isShared` names (one another seat's directory reaches through a symlink or a hardlink) is left as it is.
* Returns what was kept for the person to look at. */
export function cleanupOwnedClaudeCwd(cwd: string, collectorAssetPath: string | null | undefined,
isShared: (path: string) => boolean = () => false): string[] {
const kept: string[] = [];
const settingsPath = nodePath.join(cwd, ".claude", "settings.local.json");
if (fs.existsSync(settingsPath) && isShared(settingsPath)) kept.push(`${settingsPath}: ${SHARED}`);
else if (fs.existsSync(settingsPath)) {
const text = fs.readFileSync(settingsPath, "utf-8");
let parsed: unknown;
try { parsed = text.trim() === "" ? {} : JSON.parse(text); } catch { parsed = undefined; }
if (!isPlainObject(parsed)) kept.push(`${settingsPath}: not a JSON settings object, so it was left as it is`);
else if (removeOwnedClaudeSettings(parsed)) fs.writeFileSync(settingsPath, JSON.stringify(parsed, null, 2), "utf-8");
}
const collectorPath = nodePath.join(cwd, ".openrig", "context-collector.cjs");
if (fs.existsSync(collectorPath) && isShared(collectorPath)) kept.push(`${collectorPath}: ${SHARED}`);
else if (fs.existsSync(collectorPath)) {
let shipped: Buffer | null = null;
try { shipped = collectorAssetPath ? fs.readFileSync(collectorAssetPath) : null; } catch { shipped = null; }
if (!shipped) kept.push(`${collectorPath}: couldn't be compared with the shipped collector, so it was kept`);
else if (fs.lstatSync(collectorPath).isFile() && fs.readFileSync(collectorPath).equals(shipped)) fs.unlinkSync(collectorPath);
else kept.push(`${collectorPath}: not the shipped collector's bytes, so it was kept`);
}
for (const dir of [".openrig", ".claude"]) removeIfEmpty(nodePath.join(cwd, dir), isShared);
return kept;
}
+20 -8
View File
@@ -19,7 +19,7 @@
import nodePath from "node:path";
import os from "node:os";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { parse as parseToml } from "smol-toml";
import type { RigRepository } from "./rig-repository.js";
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
@@ -45,12 +45,11 @@ export interface KernelBootDeps {
* inject a fixture root). Production callsite resolves to the
* packaged `packages/daemon/specs/` location. */
specsDir: string;
/** cwdOverride passed to BootstrapOrchestrator.bootstrap. Kernel
* members run against the operator's environment, not the daemon
* installation tree — without this override, the bootstrap path
* refuses with `cwd is inside the OpenRig installation`. Production
* callsite passes the resolved workspace.root setting; tests inject
* a fixture path. */
/** The workspace root (the resolved workspace.root setting; tests inject a fixture path). A new kernel's members run
* in its `kernel` folder, or here when that can't be prepared (`prepareNewKernelCwd`), and that's passed to
* BootstrapOrchestrator.bootstrap as its cwdOverride. Kernel members run against the operator's environment, not the
* daemon installation tree: without an override, the bootstrap path refuses with `cwd is inside the OpenRig
* installation`. */
cwdOverride: string;
/** Runtime auth probe — defaults to live shellouts; tests inject. */
probeRuntimes?: () => Promise<RuntimeProbeResult>;
@@ -151,7 +150,7 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
sourceRef: specPath,
sourceKind: "rig_spec",
autoApprove: true,
cwdOverride: deps.cwdOverride,
cwdOverride: prepareNewKernelCwd(deps.cwdOverride, log),
});
tracker.startBooting(variant, bootstrapPromise, expectedKernelSeats(specPath));
return tracker;
@@ -227,6 +226,19 @@ export function classifyManagedKernel(rigRepo: RigRepository, sessionRegistry: S
}
}
/** The folder a newly created kernel works in: `<workspace.root>/kernel`, beside the projects (`<workspace.root>/projects`
* by default) rather than above them, so the guidance a kernel seat writes in its folder (a Claude seat's CLAUDE.md)
* isn't read by the project seats below. It's made here, inside an existing workspace root. When it can't be (a file is
* in the way, or there's no workspace root), the kernel works in the workspace root as before, and nothing is refused.
* An existing kernel keeps the folders it was created with. */
export function prepareNewKernelCwd(workspaceRoot: string, log: (level: "warn", message: string) => void = defaultLog): string {
const cwd = nodePath.join(workspaceRoot, "kernel");
try { mkdirSync(cwd); } catch { /* it's already there, or the workspace root isn't */ }
try { if (statSync(cwd).isDirectory()) return cwd; } catch { /* nothing there */ }
log("warn", `kernel-boot: couldn't prepare ${cwd} for the new kernel; it works in ${workspaceRoot}`);
return workspaceRoot;
}
/** True when a rig named `kernel` already exists in the DB. */
export function kernelAlreadyManaged(rigRepo: RigRepository): boolean {
const rigs = rigRepo.listRigs();
@@ -539,6 +539,9 @@ export interface QueueListOptions {
handedOffFrom?: string;
/** Items updated at or after this time: an ISO-8601 UTC timestamp in the stored form (toISOString). */
updatedSince?: string;
/** Pick and order rows by their latest update instead of their creation, so a bounded window of closed items holds
* the ones closed most recently. */
sortByUpdated?: boolean;
}
/** Adds the handoff-successor and updated-since filters both list paths share. */
@@ -4135,9 +4138,10 @@ export class QueueRepository {
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const columns = opts?.compact ? this.compactColumns() : "*";
const useActiveFirst = !!(opts?.rig || opts?.asSession || opts?.activeOnly);
const newest = opts?.sortByUpdated ? "ts_updated DESC" : "ts_created DESC";
const orderBy = useActiveFirst
? "CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC"
: "ts_created DESC";
? `CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ${newest}`
: newest;
params.push(limit);
const rows = this.db
@@ -47,7 +47,8 @@ export interface SeatGuidance {
}
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the rig's
* Claude file for Claude, `AGENTS.md` for Codex, or null for a runtime this doesn't know. */
* Claude file for Claude, `AGENTS.md` for Codex, Pi and omp, or null for a runtime this doesn't know. Teardown, its
* live-file protection, exact resume and node inventory all read the target from here. */
export function seatGuidanceFile(
runtime: string | null | undefined,
cwd: string,
@@ -55,7 +56,7 @@ export function seatGuidanceFile(
): string | null {
const fileName = runtime === "claude-code"
? (claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
: runtime === "codex" ? "AGENTS.md" : null;
: runtime === "codex" || runtime === "pi" || runtime === "omp" ? "AGENTS.md" : null;
return fileName ? nodePath.join(cwd, fileName) : null;
}
@@ -1424,6 +1424,9 @@ export class RestoreOrchestrator {
launchResult: { session: import("./types.js").Session; binding: import("./types.js").Binding },
warnings: string[] | undefined,
activityHookGaps: string[] | undefined,
// The directory the seat was launched in (snapshot cwd, else live node
// cwd, else "." — resolved by the caller, which owns the live lookup).
launchCwd: string,
): Promise<void> {
let desired: DesiredClaudeActivityHooks;
try {
@@ -1458,9 +1461,9 @@ export class RestoreOrchestrator {
};
try {
const result = await activityAdapter.project({
runtime: "claude-code", cwd: node.cwd ?? ".", entries: [marker],
runtime: "claude-code", cwd: launchCwd, entries: [marker],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
}, { ...launchResult.binding, cwd: launchCwd });
warnings?.push(...(result.warnings ?? []));
if (result.projected.includes(marker.effectiveId)) {
const detail = "saved startup context had no Claude activity-hook selection; the current seat specification selects it, so the current marker was re-applied without replaying startup content.";
@@ -1505,7 +1508,15 @@ export class RestoreOrchestrator {
if (!["resumed", "fresh-primed", "fresh", "rebuilt", "attention_required"].includes(result.status)) return result;
let guidance: ReturnType<typeof seatGuidance>;
try {
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, node.cwd,
// The seat was launched in the snapshot node's cwd when set, else the
// live node's cwd — the same resolved launch directory the hook
// projection and startup replay use. Passing node.cwd alone returns
// no guidance at all when the snapshot node has no cwd. No "." fallback:
// when neither cwd exists the old skip is kept, so no guidance file is
// written into the daemon's own working directory.
const guidanceCwd = node.cwd
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId)?.cwd;
guidance = seatGuidance(data.nodeStartupContext?.[node.id] ?? null, guidanceCwd,
this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
} catch (err) {
warnings?.push(`Restore: ${node.logicalId}: could not check its guidance: ${(err as Error).message}`);
@@ -1675,6 +1686,16 @@ export class RestoreOrchestrator {
// deliberately skips the resume attempt; its launch reports `fresh-primed`.
const liveRuntime = this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
&& (!selected || current.id === node.id))?.runtime ?? node.runtime;
// The seat was launched in the snapshot node's cwd when set; NodeLauncher
// otherwise falls back to the live node's cwd, and to the daemon's working
// directory when neither is set (tmux new-session without -c inherits it;
// "." is the codebase-wide convention for that case). Projecting hooks
// with the snapshot cwd alone would write another project's settings when
// the live node moved the seat.
const launchCwd = node.cwd
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
&& (!selected || current.id === node.id))?.cwd
?? ".";
const restorePolicy = effectiveRestorePolicy(seatFacts(data, node.id, node.runtime, liveRuntime), session?.restorePolicy);
const noConversation = isOrdinaryTerminalSeat(seatFacts(data, node.id, node.runtime, liveRuntime));
// A terminal's restore is its shell and its saved startup: one whose startup failed wasn't restored whole.
@@ -1704,17 +1725,26 @@ export class RestoreOrchestrator {
// re-apply the CURRENT marker, marker-only: no profile-wide resolution,
// no guidance, skills or startup replay, and the saved context is never
// rewritten. A seat whose current spec does not select hooks stays silent.
// The no-context fallback is scoped: it applies only to a legacy
// (non-pod) agent whose live runtime still matches the snapshot's. A
// runtime changed after the snapshot would otherwise read the snapshot's
// runtime and write Claude hooks into another agent's working folder
// (with a saved context, the compatibility gate already stops that
// mismatch), and a pod-aware agent must get no write from this recovery
// at all — it still ends at the "Harness not started" gate, unchanged.
const activityAdapter = opts?.adapters?.["claude-code"];
if (resumeRequested && resumeToken && launchResult && startupCtx?.runtime === "claude-code") {
const entries = startupCtx.projectionEntries.filter(e =>
const recoveryRuntime = startupCtx?.runtime
?? (!isPodAware && liveRuntime === node.runtime ? node.runtime : null);
if (resumeRequested && resumeToken && launchResult && recoveryRuntime === "claude-code") {
const entries = (startupCtx?.projectionEntries ?? []).filter(e =>
e.category === "runtime_resource" && e.resourceType === "claude_activity_hooks");
if (entries.length > 0 && activityAdapter) try {
const result = await activityAdapter.project({
runtime: "claude-code", cwd: node.cwd ?? ".",
runtime: "claude-code", cwd: launchCwd,
entries: entries.map(e => ({ ...e, category: "runtime_resource" as const, classification: "safe_projection" as const,
mergeStrategy: e.mergeStrategy as import("./projection-planner.js").ProjectionEntry["mergeStrategy"] })),
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
}, { ...launchResult.binding, cwd: node.cwd ?? "." });
}, { ...launchResult.binding, cwd: launchCwd });
warnings?.push(...(result.warnings ?? []));
for (const skipped of result.skipped) warnings?.push(`Restore activity hooks: skipped ${skipped}; saved hooks could not be reapplied.`);
for (const failure of result.failed) warnings?.push(`Restore activity hooks: ${failure.error}`);
@@ -1722,7 +1752,7 @@ export class RestoreOrchestrator {
// Activity delivery remains best-effort, as on an ordinary fresh launch.
warnings?.push(`Restore activity hooks: ${(error as Error).message}`);
} else if (entries.length === 0) {
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps);
await this.restoreCurrentActivityHooks(node, activityAdapter, launchResult, warnings, activityHookGaps, launchCwd);
}
}
@@ -1734,7 +1764,13 @@ export class RestoreOrchestrator {
if (resumeRequested && resumeToken && launchResult) {
let guidance: ReturnType<typeof seatGuidance> = null;
try {
guidance = seatGuidance(startupCtx, node.cwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
// Same resolved directory as launchCwd, without its "." fallback:
// when neither the snapshot nor the live node has a cwd, keep the
// old skip instead of writing guidance into the daemon's folder.
const guidanceCwd = node.cwd
?? this.rigRepo.getRig(rigId)?.nodes.find(current => current.logicalId === node.logicalId
&& (!selected || current.id === node.id))?.cwd;
guidance = seatGuidance(startupCtx, guidanceCwd, this.rigRepo.getRigClaudeManagedBlockFile(rigId), existsSync, (path) => readFileSync(path, "utf-8"));
} catch (err) {
// Guidance never blocks the exact resume; the post-launch check discloses the gap.
warnings?.push(`Restore guidance: ${node.logicalId}: could not read its saved guidance selection: ${(err as Error).message}`);
@@ -1776,7 +1812,7 @@ export class RestoreOrchestrator {
} catch { /* best-effort: without it a rotation stays unproved, as before */ }
}
if (selected && !selected.beforeNative(launchedSessionId, launchResult?.binding.id)) return { nodeId: node.id, logicalId: node.logicalId, status: "attention_required", error: "The selected seat, settings or history changed after shell creation; native resume was not dispatched." };
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, node.cwd ?? "/", this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
const resumeOutcome = await this.attemptResume(node.id, sessionName, resumeType, resumeToken, launchCwd, this.liveCodexProfile(rigId, node, selected?.nodeId), modelForRestore(), this.resolveRestorePosture(node.id, rigId), effortForRestore(), warnings, node.advisor,
selected ? () => selected.beforeNative(launchedSessionId, launchResult?.binding.id) : undefined, opts?.claudeExecutable);
if (resumeOutcome.kind === "resumed") {
baseStatus = "resumed";
@@ -1985,7 +2021,7 @@ export class RestoreOrchestrator {
// Build fresh projection plan (all safe_projection; replaySkip says which seat skills are left alone)
const plan: import("./projection-planner.js").ProjectionPlan = {
runtime: startupCtx.runtime,
cwd: node.cwd ?? ".",
cwd: launchCwd,
entries: filteredEntries.map((e) => ({
...e,
classification: "safe_projection" as const,
@@ -2002,7 +2038,7 @@ export class RestoreOrchestrator {
...launchResult.binding,
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
cwd: node.cwd ?? ".",
cwd: launchCwd,
codexConfigProfile: this.liveCodexProfile(rigId, node, selected?.nodeId) ?? undefined,
// OPR.0.4.8.3 Seam B: the pod-aware restore path binds the restored posture too
// (both restore paths consume persisted provenance — preflight surface 3).
+59 -6
View File
@@ -8,7 +8,9 @@ import { RigNotFoundError } from "./errors.js";
import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
import fs from "node:fs";
import nodePath from "node:path";
import { removeManagedBlocksFromFile, DEFAULT_CLAUDE_MANAGED_BLOCK_FILE } from "./managed-blocks.js";
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
import { seatGuidanceFile } from "./restore-guidance.js";
import { cleanupOwnedClaudeCwd } from "./claude-owned-settings.js";
import { stopTranscriptRotation } from "./transcript-rotation.js";
import { findOtherSessionOwner } from "./session-owner.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
@@ -29,6 +31,10 @@ export interface TeardownResult {
/** Managed guidance blocks left in a guidance file because a person edited them, as `<file>: <block id>`. Present
* only when something was kept. */
keptGuidance?: string[];
/** #875: files in a Claude seat's cwd that still hold OpenRig's additions because down couldn't safely change them
* (settings that don't parse, a collector that isn't the shipped one), as `<file>: <why>`. Present only when
* something was kept. */
keptFiles?: string[];
/** Sessions of seats joined with `rig attach` as an external CLI, which down doesn't stop or check. Without --delete
* each keeps its claim as it was, whether or not the app still runs. Present only when there was one. */
leftRunning?: string[];
@@ -54,6 +60,8 @@ interface TeardownDeps {
eventBus: EventBus;
resumeMetadataRefresher?: ResumeMetadataRefresher;
serviceOrchestrator?: import("./service-orchestrator.js").ServiceOrchestrator;
/** The shipped Claude context collector. A cwd's copy is removed only while its bytes match this file. */
claudeCollectorAssetPath?: string;
}
interface LatestNodeSession {
@@ -126,6 +134,7 @@ export class RigTeardownOrchestrator {
// Additional intent is best-effort; keep down's existing result if it cannot be recorded.
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
this.cleanupClaudeCwds(rigId, result);
result.alreadyStopped = true;
// Still tear down services even if no agent sessions are running
if (this.deps.serviceOrchestrator) {
@@ -230,6 +239,7 @@ export class RigTeardownOrchestrator {
// Stop live sessions before probing historical rows; bookkeeping must not fail down.
await this.recordAlreadyStoppedIntent(rigId).catch(() => {});
this.cleanupManagedGuidanceFiles(rigId, currentLiveGuidanceTargets(), result);
this.cleanupClaudeCwds(rigId, result);
// 5b. Tear down services if they exist
if (this.deps.serviceOrchestrator) {
@@ -417,16 +427,59 @@ export class RigTeardownOrchestrator {
}
}
/** #875: take OpenRig's Claude collector, status line and relay hooks back out of each of this rig's Claude cwds that
* no seat still uses, and report what had to be kept. */
private cleanupClaudeCwds(rigId: string, result: TeardownResult): void {
const rows = this.db.prepare(`
SELECT DISTINCT cwd FROM nodes WHERE rig_id = ? AND runtime = 'claude-code' AND cwd IS NOT NULL
`).all(rigId) as Array<{ cwd: string }>;
if (rows.length === 0) return;
const inUse = this.claudeTargetsInUse(rigId);
const done = new Set<string>();
for (const { cwd } of rows) {
const key = this.guidancePathKey(cwd);
if (inUse.has(key) || done.has(key)) continue;
done.add(key); // one cleanup per directory, however many of this rig's seats name it
try {
const isShared = (path: string) => inUse.has(this.guidancePathKey(path));
for (const entry of cleanupOwnedClaudeCwd(cwd, this.deps.claudeCollectorAssetPath, isShared)) {
if (!result.keptFiles?.includes(entry)) (result.keptFiles ??= []).push(entry);
}
} catch (err) {
result.errors.push(`Claude settings cleanup in ${cwd}: ${(err as Error).message}`);
}
}
}
/** What a Claude seat may still use, by the same rule as guidance files: the cwd of a Claude seat of any other rig that
* isn't archived, or of one of this rig's Claude seats still live (a failed stop, an inconclusive probe, an attached
* CLI). Each cwd's settings file, collector, `.claude` and `.openrig` are keyed by their own identity too, so a target
* another directory reaches through a symlink or a hardlink counts as in use. */
private claudeTargetsInUse(rigId: string): Set<string> {
const rows = this.db.prepare(`
SELECT n.cwd FROM nodes n
JOIN rigs r ON r.id = n.rig_id
WHERE r.archived_at IS NULL AND r.id <> ? AND n.runtime = 'claude-code' AND n.cwd IS NOT NULL
`).all(rigId) as Array<{ cwd: string }>;
const cwds = rows.map((row) => row.cwd);
for (const session of this.getLatestLiveSessions(rigId)) {
if (session.runtime === "claude-code" && session.cwd) cwds.push(session.cwd);
}
const keys = new Set<string>();
for (const cwd of cwds) {
for (const target of [".", ".claude", nodePath.join(".claude", "settings.local.json"), ".openrig", nodePath.join(".openrig", "context-collector.cjs")]) {
keys.add(this.guidancePathKey(nodePath.join(cwd, target)));
}
}
return keys;
}
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null): string | null {
if (!runtime || !cwd) {
return null;
}
// #25: clean only the rig's selected Claude file; the other file is never touched.
return runtime === "claude-code"
? nodePath.join(cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId) ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
: runtime === "codex"
? nodePath.join(cwd, "AGENTS.md")
: null;
return seatGuidanceFile(runtime, cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId));
}
private guidancePathKey(path: string): string {
@@ -50,6 +50,13 @@ function typedYesOrNo(answer: unknown): "yes" | "no" | null {
return SAID_YES.has(words) ? "yes" : SAID_NO.has(words) ? "no" : null;
}
/** A person's typed reply that says more than a plain yes or no: their own words, whole. */
function typedWords(answer: unknown): string | null {
if (!answer || typeof answer !== "object" || (answer as { kind?: unknown }).kind !== "typed-reply" || typedYesOrNo(answer)) return null;
const words = String((answer as { text?: unknown }).text ?? "").trim();
return words || null;
}
export type PermissionOwnerRefusal = { ok: false; code: "permissions_to_not_a_person" | "permissions_to_unavailable"; message: string };
/** The registered person `ref` names, as their canonical address. Only a person answers in this version: a seat address
@@ -102,8 +109,8 @@ export type SubmitResult =
| { ok: false; code: SubmitRefusalCode; message: string };
export type ReadResult =
| { ok: true; state: "pending" | "withdrawn" | "stale" }
| { ok: true; state: "answered" | "permitted"; optionId: string }
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no" }
| { ok: true; state: "answered" | "permitted"; optionId: string; said?: string }
| { ok: true; state: "refused"; reason: "no_allow_offered" | "said_no"; said?: string }
| { ok: false; code: "not_found"; message: string };
export type WithdrawResult = ReadResult | { ok: false; code: "invalid_permission_request"; message: string };
@@ -261,14 +268,14 @@ export class RunnerPermissions {
if (changedId === askId || changedId === liveId) release();
});
try {
const now = this.read(seat, askId);
const now = this.withWords(askId, this.read(seat, askId));
if (!now.ok || now.state !== "pending" || waitMs <= 0) return now;
const asked = this.deps.queueRepo.getById(askId)!;
liveId = this.liveEnd(asked)?.qitemId ?? askId;
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([changed, new Promise<void>((resolve) => { timer = setTimeout(resolve, waitMs); })]);
if (timer) clearTimeout(timer);
return this.read(seat, askId);
return this.withWords(askId, this.read(seat, askId));
} finally {
unsubscribe();
}
@@ -287,6 +294,15 @@ export class RunnerPermissions {
return { ok: true, state: "withdrawn" };
}
/** A person who closed the ask with their own words, not a choice or a plain yes or no: the read passes them back as
* `said`, from the row that holds the answer, for the runner to give the agent. The outcome and option are unchanged. */
private withWords(askId: string, result: ReadResult): ReadResult {
if (!result.ok || !(result.state === "permitted" || (result.state === "refused" && result.reason === "no_allow_offered"))) return result;
const asked = this.deps.queueRepo.getById(askId);
const said = typedWords((asked ? this.liveEnd(asked) : null)?.humanAnswers?.[QUESTION_ID]);
return said ? { ...result, said } : result;
}
/** A person who routes or hands the ask to a colleague means the colleague decides: follow each handoff (a handed-off
* row, or one completed with closure handed_off_to) along its own edge, to the row handed from it at the handed-to
* address. Null when that can't be followed on this host. */
@@ -672,9 +672,11 @@ export class SeatActivityService {
if (trust === "positive-only" && evd.activity !== "working") continue;
// A durable rung's idle or attention boundary holds; a bare working boundary still expires, then falls to the
// rungs below, so a turn whose end was lost can't read working forever.
// A runner's counted working beat lasts its own livenessSeconds instead (the runner contract §4).
if (rung === "lifecycle-hooks" && !(this.durableBoundaries(seat, rung) && (evd.activity !== "working"
|| (this.latestByRung(seat, rung)?.needsInput?.count ?? 0) > 0))
&& nowMs - Date.parse(evd.observedAt) > HOOK_AUTHORITY_WINDOW_MS) continue;
&& nowMs - Date.parse(evd.observedAt) > (evd.activity === "working" && evd.workingLivenessSeconds
? evd.workingLivenessSeconds * 1000 : HOOK_AUTHORITY_WINDOW_MS)) continue;
// An idle boundary whose source promised to keep reporting holds for two reports. A source that goes quiet (a runner
// killed or out of memory) stops reading idle, but only when nothing is waiting on a person: attention holds.
if (rung === "lifecycle-hooks" && evd.activity === "idle-at-prompt" && evd.livenessSeconds
@@ -135,6 +135,12 @@ interface SwitchRow {
committed_at: string | null; outcome: string | null; attention: string | null; created_at: string;
}
/** What to do about a seat with no pane to switch. */
const NO_PANE = "it has no bound tmux pane";
const NO_PANE_GUIDANCE = "If the seat is stopped, start it first, then switch it. If it has never started, there's nothing to switch: "
+ "declare the other runtime for this member in its rig spec and bring the team up again from that spec. That makes a new "
+ "generation of the seat, and it doesn't carry this seat's edges, settings or queued work the way a switch does.";
const COSTS = [
"A new conversation: native history isn't converted; only the handover carries over.",
"Same address, different terminal: the seat's session and address stay, its pane and scrollback change.",
@@ -245,13 +251,14 @@ export class SeatRuntimeSwitchService {
}
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
const unsupported = !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE
: binding.attachmentType && binding.attachmentType !== "tmux" ? `it's attached through ${binding.attachmentType}, not tmux`
: !predecessorGeneration ? "its current occupant generation isn't recorded"
: running.length !== 1 || running[0]!.sessionName !== sessionName ? "it doesn't have exactly one running session at its address"
: !node.cwd || !nodePath.isAbsolute(node.cwd) ? "its working directory isn't an absolute path"
: null;
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`);
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`,
unsupported === NO_PANE ? NO_PANE_GUIDANCE : undefined);
try {
const [panes, windows] = await Promise.all([tmux.listPanes(sessionName!), tmux.listWindows(sessionName!)]);
if (windows.length !== 1 || panes.length !== 1 || panes[0]!.id !== binding!.tmuxPane) {
@@ -724,8 +724,8 @@ export interface SkillRouterFootprint {
* Slice 20 seam: the exact preview of the bootstrap line's footprint, before any write: the line verbatim, the exact text
* written (the line between OpenRig's markers) and where it goes, and each file
* it would go into (a Claude seat's rig file, `CLAUDE.md` or `CLAUDE.local.md`; a Codex seat's `AGENTS.md`), one entry
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is main's
* `seatGuidanceFile` (restore-guidance.ts), repeated here until this branch carries it.
* per file with every seat sharing it. Pure: no filesystem or settings read. The file rule is `seatGuidanceFile`'s
* (restore-guidance.ts) for the two runtimes that get the line, Claude and Codex, repeated here.
*/
export function describeSkillRouterFootprint(seats: FootprintSeat[]): SkillRouterFootprint {
const byPath = new Map<string, { path: string; runtime: "claude-code" | "codex"; seats: string[] }>();
+11 -5
View File
@@ -410,15 +410,21 @@ export class TranscriptStore {
try {
const stat = fstatSync(fd);
const CHUNK_SIZE = 64 * 1024;
let remainder = "";
let remainder: string[] = [];
for (let offset = 0; offset < stat.size; offset += CHUNK_SIZE) {
const readSize = Math.min(CHUNK_SIZE, stat.size - offset);
const buf = readTranscriptChunk(fd, readSize, offset);
// StringDecoder handles incomplete multibyte sequences at chunk boundaries
const chunk = remainder + decoder.write(buf);
const lines = chunk.split("\n");
remainder = lines.pop() ?? "";
const lines = decoder.write(buf).split("\n");
// Scan only the new chunk. Joining and splitting the growing remainder
// on every read makes a long captured line quadratic.
if (lines.length === 1) {
remainder.push(lines[0]!);
continue;
}
lines[0] = remainder.join("") + lines[0];
remainder = [lines.pop() ?? ""];
for (const rawLine of lines) {
const stripped = this.stripAnsi(rawLine);
@@ -432,7 +438,7 @@ export class TranscriptStore {
}
// Flush any remaining bytes from the decoder
const finalChunk = remainder + decoder.end();
const finalChunk = remainder.join("") + decoder.end();
if (finalChunk) {
const stripped = this.stripAnsi(finalChunk);
for (const subLine of stripped.split("\n")) {
@@ -0,0 +1,116 @@
/**
* The runner contract's working beat (section 4): `active` / `liveness` says only that a runner's accepted prompt is
* still outstanding and its agent alive, so a long, quiet turn keeps reading working past the 15 s a bare working post
* decides for. It isn't progress, and it never starts or extends a turn, clears attention or stands in for a `Stop`.
*
* A beat counts only when it carries the seat's live occupant generation, for that generation's latest accepted turn
* that hasn't ended, with a sequence above every earlier counted post's, and while the activity service shows no
* attention outstanding. Attention is read from the service itself, so any post that clears it there (the runner's
* `active` / `permission_resolved` after delivering an answer, another counted working or idle post, an operator's
* clear) lets that turn's beats count again. The gate orders posts as the service orders a runner's durable
* boundaries, so an older post arriving late changes neither. Anything else is ignored, which is how the seat reads
* without beats.
*
* The gate keeps what it saw in memory. After a daemon restart beats are ignored until the runner's next accepted
* prompt, so the seat reads as it would without them.
*/
import { hookPrecedes } from "./agent-activity-store.js";
export const WORKING_LIVENESS_DEFAULT_SECONDS = 30;
export const WORKING_LIVENESS_MIN_SECONDS = 5;
export const WORKING_LIVENESS_MAX_SECONDS = 60;
export type WorkingLivenessIgnored =
| "no_turn" | "stale_generation" | "turn_ended" | "wrong_turn" | "no_sequence" | "out_of_order" | "attention_outstanding";
export function isWorkingLivenessBeat(hookEvent: unknown, subtype: unknown): boolean {
return hookEvent === "active" && subtype === "liveness";
}
/** How long one beat counts as working: the beat's own livenessSeconds clamped to 5-60 s, or 30 s without one. A value
* out of range still gives bounded evidence; it's never a refusal. */
export function workingLivenessSeconds(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) return WORKING_LIVENESS_DEFAULT_SECONDS;
return Math.min(WORKING_LIVENESS_MAX_SECONDS, Math.max(WORKING_LIVENESS_MIN_SECONDS, Math.round(raw)));
}
/** A counted post that isn't a beat, as the gate needs it. */
export interface WorkingLivenessPost {
generation: string | null;
hookEvent: string;
subtype: string | null;
turnId: string | null;
sequence: number | null;
/** When it happened, as the activity service orders it. */
eventAt: string | null;
/** The runner's report that its agent process ended. */
exit: boolean;
}
interface SeatTurn {
generation: string | null;
/** The latest accepted turn, with the generation its prompt was accepted under. */
turn: { id: string; generation: string | null } | null;
open: boolean;
sequence: number | null;
latest: { eventAt: string | null; sourceSequence: number | null } | null;
}
export class WorkingLivenessGate {
private readonly seats = new Map<string, SeatTurn>();
/** Every post the activity service counts for a seat, other than beats. A new generation starts the seat over; a post
* older than the latest one changes nothing, as the service drops it. */
observe(nodeId: string, post: WorkingLivenessPost): void {
let seat = this.seats.get(nodeId);
if (!seat || (post.generation && seat.generation && post.generation !== seat.generation)) {
seat = { generation: post.generation, turn: null, open: false, sequence: null, latest: null };
this.seats.set(nodeId, seat);
}
const at = { eventAt: post.eventAt, sourceSequence: post.sequence };
if (seat.latest && hookPrecedes(at, seat.latest)) return;
seat.latest = at;
if (post.generation) seat.generation = post.generation;
if (post.sequence !== null) seat.sequence = Math.max(seat.sequence ?? 0, post.sequence);
if (post.hookEvent === "UserPromptSubmit" && post.subtype === "accepted" && post.turnId) {
seat.turn = { id: post.turnId, generation: post.generation };
seat.open = true;
}
if (post.hookEvent === "Stop" || post.exit) seat.open = false;
}
/** Whether a beat counts. `liveGeneration` is the seat's current occupant's, and `attention` whether the activity
* service shows the seat waiting on a person. It changes nothing: `count` does, once the beat is recorded. */
admit(nodeId: string, beat: { generation: string | null; liveGeneration: string | null; turnId: string | null;
sequence: number | null; eventAt: string | null; attention: boolean }):
{ ok: true } | { ok: false; reason: WorkingLivenessIgnored } {
const seat = this.seats.get(nodeId);
if (!seat?.turn) return { ok: false, reason: "no_turn" };
if (!beat.generation || beat.generation !== beat.liveGeneration || beat.generation !== seat.turn.generation) {
return { ok: false, reason: "stale_generation" };
}
if (!seat.open) return { ok: false, reason: "turn_ended" };
if (beat.turnId !== seat.turn.id) return { ok: false, reason: "wrong_turn" };
if (beat.sequence === null) return { ok: false, reason: "no_sequence" };
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
if (seat.sequence === null || beat.sequence <= seat.sequence || (seat.latest && hookPrecedes(at, seat.latest))) {
return { ok: false, reason: "out_of_order" };
}
if (beat.attention) return { ok: false, reason: "attention_outstanding" };
return { ok: true };
}
/** A beat that was admitted and recorded, at the time the store recorded for it (its `occurredAt`, or when it arrived).
* One older than the latest counted post is dropped, as `observe` and the activity service drop it. Otherwise it
* raises the seat's sequence, so a delayed or repeated beat doesn't count again, and later posts are ordered against
* its time. */
count(nodeId: string, beat: { sequence: number | null; eventAt: string | null }): void {
const seat = this.seats.get(nodeId);
if (!seat || beat.sequence === null) return;
const at = { eventAt: beat.eventAt, sourceSequence: beat.sequence };
if (seat.latest && hookPrecedes(at, seat.latest)) return;
seat.sequence = Math.max(seat.sequence ?? 0, beat.sequence);
seat.latest = at;
}
}
@@ -174,6 +174,7 @@ function validateFile(
let parsed: Record<string, unknown> | null;
try {
if (fm.body === null) throw new Error("unterminated frontmatter: missing closing --- delimiter");
parsed = parseYaml(fm.body) as Record<string, unknown> | null;
} catch (err) {
out.gaps.push({
@@ -186,7 +187,7 @@ function validateFile(
});
return out;
}
if (!parsed || typeof parsed !== "object") {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
out.gaps.push({
filePath,
relativePath,
@@ -229,7 +230,7 @@ function validateFile(
interface FrontmatterExtraction {
found: boolean;
body: string;
body: string | null;
}
/** Extract YAML frontmatter delimited by `---` lines. The opening `---`
@@ -245,5 +246,5 @@ function extractFrontmatter(raw: string): FrontmatterExtraction {
return { found: true, body: lines.slice(1, i).join("\n") };
}
}
return { found: false, body: "" };
return { found: true, body: null };
}
+59 -7
View File
@@ -5,6 +5,7 @@ import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import type { AgentActivityStore } from "../domain/agent-activity-store.js";
import { hookEventState, isRunnerExit } from "../domain/agent-activity-store.js";
import { WorkingLivenessGate, isWorkingLivenessBeat, workingLivenessSeconds, type WorkingLivenessIgnored } from "../domain/working-liveness.js";
import type { EventBus } from "../domain/event-bus.js";
import { verifyStartupProof } from "../domain/startup-proof.js";
import { ActivityGrantStore, grantGeneration, type ActivityGrant } from "../domain/activity-grants.js";
@@ -34,6 +35,8 @@ import {
* exclusion); the turn's working/idle stays whatever other evidence says. */
export function evidenceFromHookActivity(input: {
seatNodeId: string;
/** A counted working beat: how long it counts as working, in place of the hook window. */
workingLivenessSeconds?: number;
sessionName: string;
runtime: string | null;
activity: AgentActivity;
@@ -49,6 +52,7 @@ export function evidenceFromHookActivity(input: {
observedAt: input.activity.eventAt ?? input.activity.sampledAt,
...(input.activity.sourceSequence ? { sourceSequence: input.activity.sourceSequence } : {}),
...(input.activity.livenessSeconds ? { livenessSeconds: input.activity.livenessSeconds } : {}),
...(input.workingLivenessSeconds ? { workingLivenessSeconds: input.workingLivenessSeconds } : {}),
...(input.route ? { route: input.route } : {}),
};
if (input.activity.reason === "permission_request_pending") {
@@ -92,7 +96,10 @@ export function restoredRunnerHookEvidence(
const state = !latest.stale ? latest.state
: latest.reason === "stale_runtime_hook"
? hookEventState(latest.rawEvent, latest.rawSubtype, runtime, latest.communityRunner === true) : "unknown";
// A working beat keeps the lifetime it was admitted with, as when it was posted.
return evidenceFromHookActivity({ seatNodeId: seat.seatNodeId, sessionName: seat.sessionName, runtime,
...(isWorkingLivenessBeat(latest.rawEvent, latest.rawSubtype)
? { workingLivenessSeconds: workingLivenessSeconds(latest.livenessSeconds) } : {}),
activity: { ...latest, state }, seq: 0 });
}
@@ -105,6 +112,20 @@ export function hookRelayRoute(value: unknown): EvidenceRoute | undefined {
// Per-source monotonic seq for ingested hook evidence (the relay does not mint one).
const hookEvidenceSeq = new Map<string, number>();
// What each seat's runner has said about its turns, for deciding whether a working beat counts (the runner contract §4).
const workingLiveness = new WorkingLivenessGate();
function sourceSequence(body: Record<string, unknown>): number | null {
return typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null;
}
/** The seat's current occupant generation, as a runner's permission request checks it; null when none resolves. */
function liveOccupantGeneration(c: Context, store: AgentActivityStore, nodeId: string): string | null {
const registry = (c.get("sessionRegistry" as never) as SessionRegistry | undefined) ?? new SessionRegistry(store.db);
try {
return registry.currentOccupantTenure(nodeId)?.generationUuid ?? null;
} catch {
return null;
}
}
function nextHookSeq(key: string): number {
const next = (hookEvidenceSeq.get(key) ?? 0) + 1;
hookEvidenceSeq.set(key, next);
@@ -600,13 +621,30 @@ async function postHook(c: Context, installTokenAccepted: boolean): Promise<Resp
}
const recorded = recordHookActivity(c, store, body);
if ("ignored" in recorded) return c.json({ ok: true, ignored: recorded.ignored });
return "refused" in recorded ? recorded.refused : c.json({ ok: true, activity: recorded.activity });
}
/** Record one hook event for its seat and feed it to the activity oracle: the tail of the hook ingress, also used when a
* runner's permission request is submitted (its permission prompt). */
function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<string, unknown>):
{ activity: AgentActivity } | { refused: Response } {
{ activity: AgentActivity } | { refused: Response } | { ignored: WorkingLivenessIgnored } {
const oracle = c.get("seatActivityService" as never) as
| import("../domain/seat-activity-service.js").SeatActivityService
| undefined;
// A working beat counts only for the live occupant's open turn, in order and outside attention. One that doesn't is
// ignored before anything is recorded, so it can't change what the seat or its send gate reads.
const beat = isWorkingLivenessBeat(body.hookEvent, body.subtype);
if (beat) {
const sessionName = stringOrNull(body.sessionName);
const nodeId = stringOrNull(body.nodeId) ?? (sessionName ? store.resolveSession({ sessionName })?.nodeId ?? null : null);
const admitted = nodeId
? workingLiveness.admit(nodeId, { generation: stringOrNull(body.generation), liveGeneration: liveOccupantGeneration(c, store, nodeId),
turnId: stringOrNull(body.turnId), sequence: sourceSequence(body), eventAt: stringOrNull(body.occurredAt),
attention: (oracle?.getSeatState(nodeId)?.needsInput.count ?? 0) > 0 })
: { ok: false as const, reason: "no_turn" as const };
if (!admitted.ok) return { ignored: admitted.reason };
}
const result = store.recordHookEvent({
runtime: stringOrNull(body.runtime),
sessionName: stringOrNull(body.sessionName),
@@ -621,9 +659,11 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
turnId: stringOrNull(body.turnId),
...(body.hookAgentId === null || (typeof body.hookAgentId === "string" && body.hookAgentId.trim().length > 0)
? { hookAgentId: body.hookAgentId } : {}),
sourceSequence: typeof body.sequence === "number" && Number.isSafeInteger(body.sequence) && body.sequence > 0 ? body.sequence : null,
livenessSeconds: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
sourceSequence: sourceSequence(body),
// A counted beat is recorded with the lifetime it was admitted with, so a restore reads it back the same.
livenessSeconds: beat ? workingLivenessSeconds(body.livenessSeconds)
: typeof body.livenessSeconds === "number" && Number.isSafeInteger(body.livenessSeconds)
&& body.livenessSeconds > 0 && body.livenessSeconds <= 3600 ? body.livenessSeconds : null,
});
if (!result.ok) {
@@ -635,9 +675,6 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
// normalized) event becomes ladder evidence on the lifecycle-hooks rung. The store
// remains the raw-event recorder (startup-proof, delivery verification); arbitration
// happens only in SeatActivityService.
const oracle = c.get("seatActivityService" as never) as
| import("../domain/seat-activity-service.js").SeatActivityService
| undefined;
const emitted = result.event as { nodeId?: string; sessionName?: string; runtime?: string } | undefined;
// Recording a historical hook is valid, but its raw activity cannot staff the
// current oracle. The store may resolve nodeId to a newer session even when
@@ -670,10 +707,25 @@ function recordHookActivity(c: Context, store: AgentActivityStore, body: Record<
runtimeRungInventory(runtime, currentSession?.community_harness ?? null, currentSession?.community_runner === 1),
);
}
if (beat) {
workingLiveness.count(emitted.nodeId, { sequence: result.activity.sourceSequence ?? null,
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null });
} else {
workingLiveness.observe(emitted.nodeId, {
generation: result.activity.generation ?? null,
hookEvent: result.activity.rawEvent ?? (typeof body.hookEvent === "string" ? body.hookEvent : ""),
subtype: result.activity.rawSubtype ?? stringOrNull(body.subtype),
turnId: result.activity.turnId ?? null,
sequence: result.activity.sourceSequence ?? null,
eventAt: result.activity.eventAt ?? result.activity.sampledAt ?? null,
exit: result.activity.rawEvent === "Stop" && isRunnerExit(result.activity.rawSubtype, runtime, result.activity.communityRunner === true),
});
}
const evidence = evidenceFromHookActivity({
seatNodeId: emitted.nodeId,
sessionName: emitted.sessionName,
runtime,
...(beat ? { workingLivenessSeconds: workingLivenessSeconds(body.livenessSeconds) } : {}),
activity: result.activity,
seq: nextHookSeq(`${emitted.nodeId}:${runtime ?? "unknown-runtime"}:hooks`),
route: hookRelayRoute(body.relayRoute),
+1
View File
@@ -1179,6 +1179,7 @@ export function queueRoutes(): Hono {
activeOnly,
handedOffFrom,
updatedSince,
sortByUpdated: c.req.query("sort") === "updated",
});
return c.json(items);
}
+2 -2
View File
@@ -15,7 +15,7 @@ import type { PodRigInstantiator } from "../domain/rigspec-instantiator.js";
import type { ResumeMetadataRefresher } from "../domain/resume-metadata-refresher.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { Node, RigWithRelations, Snapshot, SnapshotData } from "../domain/types.js";
import { defaultProbeRuntimes } from "../domain/kernel-boot.js";
import { defaultProbeRuntimes, prepareNewKernelCwd } from "../domain/kernel-boot.js";
import { SettingsStore } from "../domain/user-settings/settings-store.js";
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
import { assessCurrentStateRehydrateEligibility } from "../domain/rehydrate-eligibility.js";
@@ -215,7 +215,7 @@ startupRoutes.post("/kernel", (c) => exclusive(c, "kernel", async () => {
const root = kernelRoot();
const source = readFileSync(root + (body.runtime === "codex" ? "rig-codex-only.yaml" : "rig-claude-only.yaml"), "utf8");
const result = await dep<PodRigInstantiator>(c, "podInstantiator").materialize(source, root, {
cwdOverride: new SettingsStore().resolveConfig().workspaceRoot,
cwdOverride: prepareNewKernelCwd(new SettingsStore().resolveConfig().workspaceRoot),
});
return c.json(result.ok ? { ok: true, rigId: result.result.rigId, message: "Kernel prepared. Choose the seats to start." } : result, result.ok ? 200 : 409);
}));
+7 -3
View File
@@ -16,6 +16,9 @@ import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
const UNKNOWN_SENDER_NOTICE =
"Delivered without sender identity: this request carried no X-OpenRig-Session header, so your recipient has no way of knowing who sent it. Follow up and sign it: from a seat shell the header stamps automatically; from anywhere else, send it as a declared sender, `rig queue create --source <you>@external --destination <seat> --summary \"<one line>\" --body-file <file>` (recorded as declared; a registered human gets a reply route), or join this shell to a seat, `eval \"$(rig attach --self --rig <rigId> --node <logicalId> --print-env)\"`, so its sends carry that seat's name (`rig attach --help` covers adding a new member).";
// A fan-out where every recipient holds the text hasn't delivered yet, but the text still arrives unsigned.
const UNKNOWN_SENDER_HELD_NOTICE = UNKNOWN_SENDER_NOTICE.replace(/^Delivered/, "Held for delivery");
// The unknown-sender From: marker, DERIVED from the canonical envelope wrapper
// (pane-envelope.ts SENDER_FALLBACK is unexported and its literal is
// canonicity-guarded to exactly two twin sites) — deriving at module load keeps
@@ -428,9 +431,10 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
}
// S2 (OPR.0.5.4.3) sender-side honesty: an unattributed fan-out's response carries the
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged.
if (!derivedActor) {
return c.json({ ...result, warning: UNKNOWN_SENDER_NOTICE });
// sign-it notice for the CLI renderers to surface. Attributed fan-outs are unchanged. A fan-out that reached nobody
// (an unknown rig, every send refused) doesn't carry it, but a retained send is typed later and is still unsigned.
if (!derivedActor && result.sent + (result.retained ?? 0) > 0) {
return c.json({ ...result, warning: result.sent > 0 ? UNKNOWN_SENDER_NOTICE : UNKNOWN_SENDER_HELD_NOTICE });
}
return c.json(result);
});
+5 -2
View File
@@ -956,7 +956,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const startupOrchestrator = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter, readFile: (p: string) => fs.readFileSync(p, "utf-8") });
const runtimeSettings = new ContextPackSettingsStore().resolveConfig();
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs"), autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const claudeCollectorAssetPath = nodePath.resolve(import.meta.dirname, "../assets/claude-statusline-context.cjs");
const claudeAdapter = new ClaudeCodeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, claudeManagedLaunch, restoreReadPolicy, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), copyFile: replaceFile, listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, readdir: (dir: string) => fs.readdirSync(dir), statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: os.homedir() }, stateDir: OPENRIG_HOME, collectorAssetPath: claudeCollectorAssetPath, autoDriveProviderPrompts: runtimeSettings.recoveryAutoDriveProviderPrompts, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), claudeHooksManifestPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/claude.json"), recordProjection: (targetPath: string, content: string) => projectionManifestStore.record({ targetPath, lastHash: hashContent(content), writtenAt: new Date().toISOString() }) });
const codexAdapter = new CodexRuntimeAdapter({ prepareTeamWorkspace, prepareSeatFolder, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), homedir: daemonHome }, codexHome: configuredCodexHome, seatHome: (nodeId: string, ignoreAccountHome?: boolean) => seatRuntimeHome(db, nodeId, "codex", ignoreAccountHome).home, launchPath: process.env.PATH, detectDaemonSupport: codexDaemonSupportProbe(process.env.PATH, undefined, configuredCodexHome), readNetworkDefault: readCodexNetworkDefault, activityRelayPath: nodePath.resolve(import.meta.dirname, "../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs"), resolveCorePluginDir: () => vendoredCorePluginDir(), resolveCorePluginSelected: (nodeId: string) => Boolean(seatCodexCorePluginDir({ id: nodeId, runtime: "codex" }, db)), resolveHooksEnabled: async () => {
const { SettingsStore } = await import("./domain/user-settings/settings-store.js");
return new SettingsStore().resolveOne("runtime.codex.hooks_enabled").value as boolean;
@@ -1267,7 +1268,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// workspace, not the daemon installation tree. Without this
// cwdOverride, BootstrapOrchestrator refuses with
// "cwd is inside the OpenRig installation". Use the resolved
// workspace.root setting as the per-operator default.
// workspace.root setting as the per-operator default; a new kernel
// works in its `kernel` folder (prepareNewKernelCwd).
cwdOverride: runtimeSettings.workspaceRoot,
degradedTimeoutMs,
// A kernel a reboot left down comes back the way `rig up kernel --existing` brings it back.
@@ -1539,6 +1541,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
}),
teardownOrchestrator: new RigTeardownOrchestrator({
db, rigRepo, sessionRegistry, tmuxAdapter, snapshotCapture, eventBus, resumeMetadataRefresher, serviceOrchestrator,
claudeCollectorAssetPath,
}),
podInstantiator,
podBundleSourceResolver,
@@ -55,6 +55,30 @@ createInterface({ input: process.stdin }).on("line", (line) => {
const text = message.params.prompt.map((part) => part.text).join("");
const end = (stopReason) => reply({ stopReason });
if (text.includes("EXIT")) process.exit(0);
const two = ["TWOASK", "TWOTALK", "TWOQUIT"].find((word) => text.includes(word));
if (two) {
// Two permission requests at once; the turn ends once both are answered. After the first answer, TWOTALK says
// something and starts a tool, and TWOQUIT exits.
let left = 2;
const answered = () => {
left -= 1;
if (left === 1 && two === "TWOTALK") { say("first answered"); update({ sessionUpdate: "tool_call", toolCallId: "t6", title: "Read e.txt", kind: "read", status: "pending" }); }
if (left === 1 && two === "TWOQUIT") setTimeout(() => process.exit(0), 200);
if (left === 0) { say("both answered"); end("end_turn"); }
};
for (const [id, file] of [["perm-5", "e.txt"], ["perm-6", "f.txt"]]) {
waiting.set(id, answered);
send({ id, method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: id, title: "Edit " + file, kind: "edit" },
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
}
return;
}
if (text.includes("QUIETASK")) {
// Asks, then works on quietly after the answer until it's cancelled.
waiting.set("perm-4", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); held = () => end("cancelled"); });
return send({ id: "perm-4", method: "session/request_permission", params: { sessionId: session, toolCall: { toolCallId: "t5", title: "Edit d.txt", kind: "edit" },
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "reject", name: "Reject", kind: "reject_once" }] } });
}
if (text.includes("PERMIT")) {
say("checking");
waiting.set("perm-1", (response) => { say("outcome " + JSON.stringify(response.result?.outcome ?? null)); end(cancelled ? "cancelled" : "end_turn"); });
@@ -84,15 +108,15 @@ createInterface({ input: process.stdin }).on("line", (line) => {
});
`;
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "saidno" | "unknown" | "readfail" | "pending" | "lost";
interface Hook { auth: string | undefined; body: Record<string, unknown> }
type Permissions = "absent" | "deny" | "stale" | "revoked" | "broken" | "permitted" | "answer" | "words" | "saidno" | "unknown" | "readfail" | "pending" | "lost" | "late" | "two" | "twoslow";
interface Hook { auth: string | undefined; body: Record<string, unknown>; at: number }
const cleanups: Array<() => void> = [];
afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup(); });
async function fakeOpenRig(permissions: Permissions) {
const hooks: Hook[] = [];
const submits: Array<{ auth: string | undefined; body: Record<string, unknown> }> = [];
const submits: Array<{ auth: string | undefined; body: Record<string, unknown>; at: number }> = [];
const withdraws: Array<{ askId: string; body: Record<string, unknown> }> = [];
const server: Server = createServer((req, res) => {
let raw = "";
@@ -101,25 +125,47 @@ async function fakeOpenRig(permissions: Permissions) {
const json = (status: number, body: unknown) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
const url = new URL(req.url ?? "/", "http://openrig.test");
const body = raw ? JSON.parse(raw) as Record<string, unknown> : {};
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body }); return json(200, { ok: true }); }
if (req.method === "POST" && url.pathname === "/api/activity/hooks") { hooks.push({ auth: req.headers.authorization, body, at: Date.now() }); return json(200, { ok: true }); }
if (permissions === "absent") return json(404, { error: "not found" });
if (req.method === "POST" && url.pathname === "/api/activity/permission-requests") {
submits.push({ auth: req.headers.authorization, body });
submits.push({ auth: req.headers.authorization, body, at: Date.now() });
if (permissions === "deny") return json(409, { ok: false, code: "denied_by_seat", error: "This seat's operator chose to refuse its runner's permission requests." });
if (permissions === "stale") return json(409, { ok: false, code: "stale_generation", error: "This request isn't from the seat's current occupant." });
if (permissions === "broken") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
if (permissions === "revoked") return json(401, { ok: false, code: "activity_grant_revoked", error: "This activity grant was revoked; nothing was asked." });
if (permissions === "permitted") return json(200, { ok: true, state: "permitted", optionId: "allow", reason: "no one can be asked: no person is registered" });
if (permissions === "two") return json(200, { ok: true, askId: `ask-${submits.length}`, created: true });
// Two asks, and OpenRig takes 1.5 s to answer the second one's submit.
if (permissions === "twoslow") {
const askId = `ask-${submits.length}`;
if (askId === "ask-2") { setTimeout(() => json(200, { ok: true, askId, created: true }), 1_500); return; }
return json(200, { ok: true, askId, created: true });
}
return json(200, { ok: true, askId: "ask-1", created: true });
}
const withdraw = url.pathname.match(/^\/api\/activity\/permission-requests\/([^/]+)\/withdraw$/);
if (req.method === "POST" && withdraw) { withdraws.push({ askId: withdraw[1]!, body }); return json(200, { ok: true, state: "withdrawn" }); }
if (req.method === "POST" && withdraw) {
withdraws.push({ askId: withdraw[1]!, body });
// With two asks, a withdraw is answered after 500 ms, so a runner that's exiting is still there to post after it.
if (permissions === "two") { setTimeout(() => json(200, { ok: true, state: "withdrawn" }), 500); return; }
return json(200, { ok: true, state: "withdrawn" });
}
// Two asks: the first is answered at once, the second 2 s after it was submitted.
const two = url.pathname.match(/^\/api\/activity\/permission-requests\/(ask-[12])$/);
if ((permissions === "two" || permissions === "twoslow") && req.method === "GET" && two) {
if (two[1] === "ask-1" || Date.now() - submits[1]!.at >= 2_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
return;
}
if (req.method === "GET" && url.pathname === "/api/activity/permission-requests/ask-1") {
if (permissions === "answer") return json(200, { ok: true, state: "answered", optionId: "allow" });
if (permissions === "words") return json(200, { ok: true, state: "permitted", optionId: "allow", said: "Only a.txt, please." });
if (permissions === "saidno") return json(200, { ok: true, state: "refused", reason: "said_no" });
if (permissions === "unknown") return json(200, { ok: true, state: "deferred" });
if (permissions === "readfail") return json(500, { ok: false, code: "internal_error", error: "Something went wrong." });
if (permissions === "lost") return json(404, { ok: false, code: "not_found", error: "No permission request ask-1 for this seat." });
// A person who answers after 11 s, longer than one beat interval.
if (permissions === "late" && Date.now() - submits[0]!.at >= 11_000) return json(200, { ok: true, state: "answered", optionId: "allow" });
setTimeout(() => json(200, { ok: true, state: "pending" }), 100);
return;
}
@@ -245,6 +291,45 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
expect(requests(seat.log(), "session/prompt")).toHaveLength(1);
});
// The working beat (contract section 4) runs on its own 10 s interval, so these two cells take that long.
it("beats through a long, quiet turn, naming the accepted turn, and stops at the turn's end", async () => {
const openrig = await fakeOpenRig("absent");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("HOLD this turn");
const accepted = await waitFor(() => has(openrig.hooks, "UserPromptSubmit/accepted"), "the accepted prompt");
const beat = await waitFor(() => has(openrig.hooks, "active/liveness"), "a working beat", 12_000);
expect(beat.body).toMatchObject({ turnId: "1", livenessSeconds: 30, generation: "gen-1", runtime: "dsh" });
expect(Number(beat.body.sequence)).toBeGreaterThan(Number(accepted.body.sequence));
expect(typeof beat.body.occurredAt).toBe("string");
seat.type("/abort");
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
const stopAt = seen(openrig.hooks).indexOf("Stop/cancelled");
await new Promise((done) => setTimeout(done, 10_500)); // one more beat interval
expect(seen(openrig.hooks).slice(stopAt + 1)).not.toContain("active/liveness");
}, 35_000);
it("holds the beat while a person's ask is open, then posts permission_resolved before beating again", async () => {
const openrig = await fakeOpenRig("late");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("QUIETASK an edit");
const answer = await waitFor(() => responseTo(seat.log(), "perm-4"), "the person's answer, given to the agent", 16_000);
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
// The ask was open for more than a beat interval, and no beat went out while it was.
const asked = openrig.submits[0]!.at;
expect(resolved.at - asked).toBeGreaterThanOrEqual(10_000);
expect(openrig.hooks.filter((hook) => hook.body.subtype === "liveness" && hook.at > asked && hook.at < resolved.at)).toEqual([]);
// The same turn works on quietly after the answer, and beats again.
const after = await waitFor(() => openrig.hooks.find((hook) => hook.body.subtype === "liveness" && hook.at > resolved.at), "a beat after the answer", 12_000);
expect(after.body.turnId).toBe("1");
expect(Number(after.body.sequence)).toBeGreaterThan(Number(resolved.body.sequence));
seat.type("/abort");
await waitFor(() => has(openrig.hooks, "Stop/cancelled"), "the cancelled turn end");
}, 45_000);
it.each(["absent", "permitted"] as const)("permits with the agent's own allow when no one can be asked (OpenRig %s)", async (permissions) => {
const openrig = await fakeOpenRig(permissions);
const seat = start(openrig);
@@ -254,16 +339,18 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
expect(seat.out()).toMatch(/\[permission\] permitted/);
});
// An ask OpenRig opened (the last two) showed the seat needing input, so the permit is followed by permission_resolved.
it.each([
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }],
["the submit is answered with a status the contract doesn't list", "broken", {}],
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}],
["reading the answer gets a state the contract doesn't list", "unknown", {}],
["reading the answer gets an unexpected reply", "lost", {}],
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env) => {
["OpenRig can't be reached", "absent", { OPENRIG_URL: "http://127.0.0.1:1" }, false],
["the submit is answered with a status the contract doesn't list", "broken", {}, false],
["the submit's grant was revoked, which can happen to the live occupant", "revoked", {}, false],
["reading the answer gets a state the contract doesn't list", "unknown", {}, true],
["reading the answer gets an unexpected reply", "lost", {}, true],
] as const)("permits with the agent's own allow when %s, since nobody can be asked", async (_case, permissions, env, opened) => {
const openrig = await fakeOpenRig(permissions);
const seat = start(openrig, env);
await waitFor(seat.launch, "the launch record");
@@ -271,6 +358,13 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
if (opened) {
const resolved = await waitFor(() => has(openrig.hooks, "active/permission_resolved"), "permission_resolved");
expect(resolved.body).toMatchObject({ turnId: "1", askId: "ask-1" });
} else {
await waitFor(() => /turn end/.test(seat.out()), "the turn's end in the pane");
expect(has(openrig.hooks, "active/permission_resolved"), "OpenRig opened no ask").toBeUndefined();
}
});
it.each([
@@ -285,6 +379,7 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
expect(answer.result).toEqual({ outcome: { outcome: "cancelled" } });
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
expect(has(openrig.hooks, "active/permission_resolved"), "nothing was answered or permitted").toBeUndefined();
});
it("keeps the seat's own refusal held through a cancel, so the cancelled turn reads needs input, not idle", async () => {
@@ -365,6 +460,84 @@ process.stdout.write(JSON.stringify(chunks.map((chunk) => framer.push(chunk).fil
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's idle report");
expect(has(openrig.hooks, "Notification/permission_prompt")).toBeUndefined();
expect(openrig.withdraws).toEqual([]);
// The person's answer reached the agent, so the seat's needs input clears for the rest of the turn, before its end.
const events = seen(openrig.hooks);
expect(has(openrig.hooks, "active/permission_resolved")!.body).toMatchObject({ turnId: "1", askId: "ask-1" });
expect(events.indexOf("active/permission_resolved")).toBeLessThan(events.indexOf("Stop/end_turn"));
});
it("gives the agent its option, then queues the person's own words as a prompt, after the turn", async () => {
const openrig = await fakeOpenRig("words");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("PERMIT an edit");
const answer = await waitFor(() => responseTo(seat.log(), "perm-1"), "the runner's answer to the permission request");
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
const prompts = () => requests(seat.log(), "session/prompt")
.map((entry) => (entry.params as { prompt: Array<{ text: string }> }).prompt.map((part) => part.text).join(""));
const next = await waitFor(() => prompts()[1], "the person's words as a queued prompt");
expect(next).toContain("Only a.txt, please.");
expect(next).toContain("Edit a.txt");
expect(prompts()).toHaveLength(2);
expect(seat.out()).toContain("Only a.txt, please.");
});
// Two asks open at once: the first answer reaches the agent at once, but the seat still waits on a person for the second,
// so nothing that clears needs input is posted until that one is answered too; then permission_resolved goes out once.
it("keeps the seat needing input while a second ask waits, and resolves once, after the last answer", async () => {
const openrig = await fakeOpenRig("two");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("TWOASK two edits");
for (const id of ["perm-5", "perm-6"]) {
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
}
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
expect(openrig.submits).toHaveLength(2);
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
expect(resolved[0]!.at - openrig.submits[1]!.at).toBeGreaterThanOrEqual(2_000); // after the later ask's answer
// From the first ask on (the submit isn't one of the runner's ordered posts, so it can arrive before the accepted prompt).
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
const between = openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at);
expect(between.map((hook) => `${String(hook.body.hookEvent)}/${String(hook.body.subtype)}`)).toEqual([]);
});
// The first answer reaches the agent before OpenRig has answered the second ask's submit, though it has already recorded
// that prompt, so what the agent says and does in between mustn't read working.
it("reports nothing that reads working while a second ask is still being submitted", async () => {
const openrig = await fakeOpenRig("twoslow");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("TWOTALK two edits");
for (const id of ["perm-5", "perm-6"]) {
const answer = await waitFor(() => responseTo(seat.log(), id), `the answer to ${id}`, 8_000);
expect(answer.result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
}
await waitFor(() => has(openrig.hooks, "Stop/end_turn"), "the turn's end");
expect(seat.out()).toContain("first answered");
expect(seat.out()).toContain("OpenRig asked a person (ask-2)");
expect(seat.out().indexOf("first answered")).toBeLessThan(seat.out().indexOf("OpenRig asked a person (ask-2)"));
const resolved = openrig.hooks.filter((hook) => hook.body.subtype === "permission_resolved");
expect(resolved.map((hook) => hook.body.turnId)).toEqual(["1"]);
const from = Math.max(openrig.submits[0]!.at, has(openrig.hooks, "UserPromptSubmit/accepted")!.at);
expect(seen(openrig.hooks.filter((hook) => hook.at > from && hook.at < resolved[0]!.at))).toEqual([]);
});
// The agent exits after the first answer while the second ask still waits on a person: nothing after the exit says the
// turn goes on.
it("posts no permission_resolved after the agent's exit", async () => {
const openrig = await fakeOpenRig("two");
const seat = start(openrig);
await waitFor(seat.launch, "the launch record");
seat.type("TWOQUIT two edits");
await waitFor(() => responseTo(seat.log(), "perm-5") ?? responseTo(seat.log(), "perm-6"), "the first answer", 8_000);
await seat.exited;
expect(openrig.withdraws.map((entry) => entry.body)).toEqual([{ reason: "exited" }]);
const events = seen(openrig.hooks);
expect(events.at(-1)).toBe("Stop/dsh_exited");
expect(events).not.toContain("active/permission_resolved");
});
it("withdraws a pending ask when the turn is cancelled, and answers the agent cancelled", async () => {
@@ -3,10 +3,15 @@
// catches it.
// node minimal-acp-runner.mjs --agent <command> [--break <name>]... [--read-wait <seconds>|none] [--withdraw-after-stop]
// --read-wait keeps the contract another way: it reads with that waitSeconds (or none) and sleeps between pending reads.
// --read-retries <n> keeps it another way too: a read OpenRig answers with a status other than 200 is retried n times, 1.5 s apart, before the
// runner acts on it; section 7 sets no time limit for that.
// --read-retries <n> keeps it another way too: a read refusal the runner would permit on (a code section 7 marks "permits",
// or one it doesn't list) is retried n times, 1.5 s apart, before the runner permits; section 7 sets no time limit for that.
// A refusal section 7 cancels or holds on (a request that isn't the current occupant's, or the seat's opt-in to hold) is
// acted on at once.
// --withdraw-after-stop keeps it another way too: a cancelled or ended turn's ask is withdrawn after the turn's Stop is
// posted, not before; the contract doesn't order the two posts.
// --startup-idle <ms> keeps it another way too: with no first input, it reports idle once it's ready, as a runner may, and
// that first POST goes out <ms> late, holding the posts behind it, as a slow first request does. The agent gets each
// prompt without waiting on its accepted post, so it can die before the late report arrives.
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { request } from "node:http";
@@ -18,22 +23,40 @@ const readWait = argv.includes("--read-wait") ? argv[argv.indexOf("--read-wait")
const readQuery = readWait === "none" ? "" : `?waitSeconds=${readWait}`;
const withdrawAfterStop = argv.includes("--withdraw-after-stop");
const readRetries = argv.includes("--read-retries") ? Number(argv[argv.indexOf("--read-retries") + 1]) : 0;
const startupIdleMs = argv.includes("--startup-idle") ? Number(argv[argv.indexOf("--startup-idle") + 1]) : null;
// --beat: the optional working beat (section 4). While a turn is open and nothing waits on a person, post active/liveness
// every 10 s with livenessSeconds 30; after delivering an answer to an ask OpenRig opened, post active/permission_resolved
// first. Breaks: beat-after-stop, beat-during-ask, no-resolve, and beat-time-backwards (each beat says it happened 15 s
// before it did, so its sequence no longer rises with the runner's own time).
const beatMode = argv.includes("--beat");
let beatTurn = null;
// With --withdraw-after-stop: the ask a cancelled or ended turn withdraws once its Stop is posted.
let deferredWithdraw = null;
// --break stop-before-end: a non-exit Stop posted while the ask waits stands in for the turn's end, which is then not posted.
let stoppedEarly = false;
// The current turn's accepted prompt's occurredAt.
let acceptedAt = null;
if (beatMode) setInterval(() => {
if (beatTurn !== null && (!attention || breaks.has("beat-during-ask"))) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30,
...(breaks.has("beat-time-backwards") ? { occurredAt: new Date(Date.now() - 15_000).toISOString() } : {}) });
}, 10_000).unref();
const resolved = (ask) => { if (beatMode && !breaks.has("no-resolve")) void post("active", "permission_resolved", { turnId: String(turn), askId: ask.askId }); };
const env = process.env;
const name = env.OPENRIG_RUNTIME ?? "runner";
// Activity posts go one at a time, in order, and never hold up the agent.
let sequence = 0;
// With --break idle-string-sequence-on-exit the first post is numbered 2, so a "1" falls below the accepted prompt.
let sequence = breaks.has("idle-string-sequence-on-exit") ? 1 : 0;
let posting = Promise.resolve();
let holdNextPostMs = 0;
function post(hookEvent, subtype, extra = {}) {
const hold = holdNextPostMs;
holdNextPostMs = 0;
const body = JSON.stringify({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, runtime: name,
generation: breaks.has("wrong-generation") ? "gen-other" : env.OPENRIG_OCCUPANT_GENERATION,
...(breaks.has("no-sequence") ? {} : { sequence: ++sequence }), occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra });
const headers = { "content-type": "application/json", ...(breaks.has("no-token") ? {} : { authorization: `Bearer ${env.OPENRIG_ACTIVITY_HOOK_TOKEN}` }) };
posting = posting.then(() => new Promise((resolve) => {
posting = posting.then(() => hold && new Promise((resolve) => setTimeout(resolve, hold))).then(() => new Promise((resolve) => {
const req = request(`${env.OPENRIG_URL}/api/activity/hooks`, { method: "POST", headers, timeout: 2000 }, (res) => { res.resume(); res.on("end", resolve); });
req.on("error", resolve);
req.on("timeout", () => { req.destroy(); resolve(); });
@@ -98,7 +121,13 @@ agent.stdout.on("data", (chunk) => {
let exiting = false;
agent.on("exit", async () => {
if (exiting) return;
beatTurn = null;
await withdrawLive("exited");
// --break idle-low-sequence-on-exit: an idle Stop for the dead turn, numbered below its accepted prompt but later in time.
if (breaks.has("idle-low-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
// --break idle-string-sequence-on-exit: an idle Stop for the dead turn at its accepted prompt's time, numbered below it but
// as a string, which OpenRig doesn't read as a sequence.
if (breaks.has("idle-string-sequence-on-exit")) await post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: acceptedAt, sequence: "1" });
await post("Stop", breaks.has("idle-on-exit") ? "end_turn" : `${name}_exited`);
process.exit(1);
});
@@ -118,7 +147,8 @@ let cancelSent = false;
const queue = [];
// Permissions. Every request goes to OpenRig's answered path, and the runner delivers the option that comes back: the
// person's, or OpenRig's permit. A refusal, at submit or while reading, is read by its code: the seat's opt-in refuses and
// person's, or OpenRig's permit. A person's own words (`said`) join the prompt queue, after the turn. A refusal,
// at submit or while reading, is read by its code: the seat's opt-in refuses and
// holds attention; one that says the request isn't the current occupant's answers cancelled and holds nothing, as a
// refused, stale or withdrawn read does; anything else (404, unreachable, a "permits" code or one the contract doesn't
// list) permits with the agent's allow_once, else allow_always, or refuses and holds when there's no allow option. An
@@ -160,7 +190,13 @@ async function onPermission(msg) {
// A person is asked. OpenRig records the seat's permission prompt, so the runner doesn't post it.
attention = true;
if (breaks.has("self-prompt")) void post("Notification", "permission_prompt");
// --break beat-during-ask beats as soon as the ask waits too, so the check sees it whatever the 10 s timer's phase.
if (breaks.has("beat-during-ask") && beatTurn !== null) void post("active", "liveness", { turnId: String(beatTurn), livenessSeconds: 30 });
if (breaks.has("stop-before-end")) { stoppedEarly = true; void post("Stop", "idle_heartbeat", { livenessSeconds: 30 }); }
// --break stop-low-sequence: an idle Stop while the ask waits, numbered below the accepted prompt but later in time.
if (breaks.has("stop-low-sequence")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, sequence: 1 });
// --break stop-numeric-time: an idle Stop while the ask waits, its occurredAt a number, which OpenRig dates by arrival.
if (breaks.has("stop-numeric-time")) void post("Stop", "idle_heartbeat", { livenessSeconds: 30, occurredAt: 0 });
// done: the agent has its answer. open: OpenRig's ask may still be open, so it's withdrawn at the turn's end.
const ask = { id: msg.id, askId: submitted.body.askId, offered: options.map((o) => o.optionId), done: false, open: true };
live = ask;
@@ -169,7 +205,8 @@ async function onPermission(msg) {
if (!busy || turn !== askedInTurn) { await withdrawLive("turn_ended"); return; }
for (;;) {
let read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
for (let tries = 0; read.status >= 300 && tries < readRetries && !ask.done; tries++) {
const permitsOn = (r) => r.status >= 300 && !NOT_CURRENT.has(r.body?.code) && !HOLDS.has(r.body?.code);
for (let tries = 0; permitsOn(read) && tries < readRetries && !ask.done; tries++) {
await new Promise((done) => setTimeout(done, 1500));
read = await api("GET", `/api/activity/permission-requests/${encodeURIComponent(ask.askId)}${readQuery}`);
}
@@ -178,10 +215,17 @@ async function onPermission(msg) {
if (state === "pending") { if (readWait !== "25") await new Promise((done) => setTimeout(done, 200)); continue; }
// A listed answer means OpenRig closed its ask; anything else leaves it open, to withdraw at the turn's end.
if (["answered", "permitted", "refused", "withdrawn", "stale"].includes(state)) ask.open = false;
if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
// A person who answered in words: their words join the prompt queue in order, after this turn, never as the answer.
const said = state && typeof read.body?.said === "string" && read.body.said.trim() ? read.body.said : null;
if (said && !breaks.has("drop-words")) queue.push(`The person who answered your permission request (${toolCall?.title}) also wrote:\n\n${said}`);
if (said && breaks.has("said-as-answer") && !ask.done) {
refuse(ask.id);
attention = false;
} else if ((state === "answered" || state === "permitted") && ask.offered.includes(read.body.optionId)) {
select(ask.id, read.body.optionId);
if (breaks.has("deliver-twice")) select(ask.id, read.body.optionId);
attention = false;
resolved(ask);
} else if (!ask.done) {
// A refused, withdrawn or stale read, or a refusal saying the request isn't the current occupant's: it can't take an
// answer, and no person is still waiting, so it's cancelled and nothing is held. The seat's opt-in refuses and holds.
@@ -194,7 +238,7 @@ async function onPermission(msg) {
else if (cancelled || (!state && breaks.has("cancel-on-read-error")) || (state && breaks.has("cancel-on-unlisted-state"))) {
refuse(ask.id); attention = breaks.has("hold-on-cancel");
}
else { permit(); attention = !allowOf(options); }
else { permit(); attention = !allowOf(options); if (!attention) resolved(ask); }
}
ask.done = true;
if (live === ask && (!ask.open || breaks.has("forget-permitted-ask"))) live = null;
@@ -227,11 +271,15 @@ async function prompt(text) {
cancelSent = false;
turn += 1;
stoppedEarly = false;
beatTurn = turn;
show(`you ▸ ${text.split("\n")[0]}`);
attention = false;
await post("UserPromptSubmit", "accepted", { turnId: String(turn) });
acceptedAt = new Date().toISOString();
const accepted = post("UserPromptSubmit", "accepted", { turnId: String(turn), occurredAt: acceptedAt });
if (startupIdleMs === null) await accepted; // with --startup-idle, the agent doesn't wait on the post, as in dsh's runner
const res = await call("session/prompt", { sessionId, prompt: [{ type: "text", text }] });
await withdrawLive("turn_ended");
if (!breaks.has("beat-after-stop")) beatTurn = null;
if (res.error) {
if (!breaks.has("no-error-post")) await post("Notification", "runtime_error");
} else {
@@ -313,6 +361,10 @@ async function main() {
process.stdout.write("\x1b[?2004h");
process.stdin.on("data", onInput);
show("ready");
if (startupIdleMs !== null && !env.OPENRIG_FIRST_INPUT) {
holdNextPostMs = startupIdleMs;
void post("Stop", "idle_heartbeat", { livenessSeconds: 30 });
}
if (env.OPENRIG_FIRST_INPUT) {
const text = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8");
void prompt(breaks.has("first-input-trim") ? text.trim() : text);
+23 -2
View File
@@ -8,7 +8,7 @@
// post-bootstrap tracker state.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@@ -209,11 +209,32 @@ describe("bootKernelIfNeeded — fire-and-forget bootstrap", () => {
expect(opts.sourceRef).toBe(join(tmpSpecsDir, "rigs/launch/kernel", "rig.yaml"));
expect(opts.sourceKind).toBe("rig_spec");
expect(opts.autoApprove).toBe(true);
expect(opts.cwdOverride).toBe(tmpSpecsDir);
// A new kernel works in the workspace's own `kernel` folder, prepared here, not in the workspace above its projects.
expect(opts.cwdOverride).toBe(join(tmpSpecsDir, "kernel"));
expect(statSync(join(tmpSpecsDir, "kernel")).isDirectory()).toBe(true);
release();
tracker.stop();
});
it("starts a new kernel in the workspace root, as before, when its folder can't be prepared, and refuses nothing", async () => {
// A file where the folder would go, and a workspace root that doesn't exist.
writeFileSync(join(tmpSpecsDir, "kernel"), "a user's file\n");
const missing = join(tmpSpecsDir, "no-workspace");
for (const workspaceRoot of [tmpSpecsDir, missing]) {
const bootstrap = makeBootstrapMock();
const warnings: string[] = [];
const tracker = await bootKernelIfNeeded(makeBaseDeps({ bootstrapOrchestrator: bootstrap, cwdOverride: workspaceRoot,
log: (level, message) => { if (level === "warn") warnings.push(message); } }, tmpSpecsDir));
expect(tracker.getStatus().kernelState).toBe("booting");
const opts = (bootstrap as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap.mock.calls[0]![0];
expect(opts.cwdOverride).toBe(workspaceRoot);
expect(warnings).toEqual([expect.stringContaining(join(workspaceRoot, "kernel"))]);
tracker.stop();
}
expect(readFileSync(join(tmpSpecsDir, "kernel"), "utf8")).toBe("a user's file\n");
expect(existsSync(missing)).toBe(false);
});
it("transitions to bootstrap_failed when orchestrator returns errors (post-flush)", async () => {
const tracker = await bootKernelIfNeeded(makeBaseDeps({
bootstrapOrchestrator: makeBootstrapMock({ errors: ["preflight: tmux missing"] }),
@@ -7,7 +7,7 @@ import { Hono } from "hono";
import { noServerTerminal } from "./helpers/no-server-terminal.js";
import { kernelStatusRoutes } from "../src/routes/kernel-status.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type Database from "better-sqlite3";
@@ -58,7 +58,7 @@ function host(claimed = false) {
const rig = rigRepo.createRig("kernel");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("kernel-pod", rig.id, "Kernel");
const nodes = SEATS.map((logicalId) => {
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "claude-code", podId: "kernel-pod" });
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "claude-code", podId: "kernel-pod", cwd: "/workspace/stored" });
const name = `${logicalId.replace(".", "-")}@kernel`;
const session = claimed ? sessionRegistry.registerClaimedSession(node.id, name) : sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(session.id, "running");
@@ -102,7 +102,7 @@ function host(claimed = false) {
const statuses = () => (db.prepare(
"SELECT status FROM sessions WHERE node_id IN (SELECT id FROM nodes WHERE rig_id = ?) ORDER BY created_at, id",
).all(rig.id) as Array<{ status: string }>).map((row) => row.status);
return { db, rigRepo, sessionRegistry, eventBus, tmux, snapshotRepo, snapshotCapture, rig, nodes, live, reboot, down, reconcile, daemonStart, statuses, bootstrap };
return { db, rigRepo, sessionRegistry, eventBus, tmux, snapshotRepo, snapshotCapture, rig, nodes, live, reboot, down, reconcile, daemonStart, statuses, bootstrap, specsDir };
}
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -118,6 +118,9 @@ describe("daemon start with an existing kernel rig", () => {
expect(restore).toHaveBeenCalledWith(h.rig.id);
expect(tracker.getStatus().kernelState).toBe("booting");
expect(h.bootstrap.bootstrap).not.toHaveBeenCalled();
// An existing kernel comes back where it was created: no new kernel folder, and each seat's directory unchanged.
expect(h.rigRepo.getRig(h.rig.id)!.nodes.map((node) => node.cwd)).toEqual(["/workspace/stored", "/workspace/stored"]);
expect(existsSync(join(h.specsDir, "kernel"))).toBe(false);
});
it("doesn't bring back a kernel that was already down before this start, or its old conversations", async () => {
+5 -2
View File
@@ -1158,7 +1158,7 @@ describe("node detail reports a guidance block's installed file only from eviden
declaredTarget: "AGENTS.md", effectiveTarget: path.join(cwd, "CLAUDE.md") }]);
});
it("follows the rig's Claude managed-blocks file, Codex's AGENTS.md, and the older block markers", () => {
it("follows the rig's Claude managed-blocks file, Codex's and Pi's AGENTS.md, and the older block markers", () => {
new RigRepository(db).setRigClaudeManagedBlockFile("rig-1", "CLAUDE.local.md");
fs.writeFileSync(path.join(cwd, "CLAUDE.local.md"), block("role", "RIGGED"));
expect(installed()[0]).toMatchObject({ targetPath: path.join(cwd, "CLAUDE.local.md"), effectiveTarget: path.join(cwd, "CLAUDE.local.md") });
@@ -1166,11 +1166,14 @@ describe("node detail reports a guidance block's installed file only from eviden
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role"));
expect(installed("codex", [guidance("CLAUDE.md")])[0]).toMatchObject({ targetPath: path.join(cwd, "AGENTS.md"),
declaredTarget: "CLAUDE.md", effectiveTarget: path.join(cwd, "AGENTS.md") });
db.prepare("DELETE FROM node_startup_context WHERE node_id = 'node-1'").run();
expect(installed("pi", [guidance("CLAUDE.md")])[0]).toMatchObject({ targetPath: path.join(cwd, "AGENTS.md"),
declaredTarget: "CLAUDE.md", effectiveTarget: path.join(cwd, "AGENTS.md") });
});
it("claims nothing for a runtime with no known guidance file, a merge that writes nothing, or a half-written block", () => {
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role"));
expect(installed("pi")[0]).toEqual({ id: "role", category: "guidance", targetPath: "", declaredTarget: "AGENTS.md" });
expect(installed("synth")[0]).toEqual({ id: "role", category: "guidance", targetPath: "", declaredTarget: "AGENTS.md" });
db.prepare("DELETE FROM node_startup_context WHERE node_id = 'node-1'").run();
fs.writeFileSync(path.join(cwd, "CLAUDE.md"), block("role"));
expect(installed("claude-code", [guidance(undefined, "append")])[0]).toMatchObject({ targetPath: "" });
@@ -75,7 +75,7 @@ describe("Pi occupant-bound activity (#29)", () => {
}
f.core.handlePiExit(0);
const responses = await f.flush();
expect(f.payloads).toHaveLength(7);
expect(f.payloads).toHaveLength(8); // the run's accepted turn, its five events' hooks, the identity post and the exit
for (const payload of f.payloads) expect(payload).toMatchObject({
nodeId: NODE, sessionName: SESSION, runtime: "pi", generation: GENERATION,
});
+57 -5
View File
@@ -206,7 +206,7 @@ describe("idle heartbeat", () => {
});
});
// ── Streaming heartbeat ─────────────────────────────────────────────────────
// ── Streaming heartbeat: the runner contract's working beat (section 4), `active` / `liveness` ──
describe("streaming heartbeat", () => {
function clocked(runtime?: "pi" | "omp") {
@@ -215,7 +215,7 @@ describe("streaming heartbeat", () => {
f.io.now = () => new Date(t).toISOString();
const core = new RunnerCore(f.io, { sessionName: SESSION, nodeId: "node-1", launchId: "launch-77" }, runtime ? { runtime } : undefined);
core.start();
const beats = () => f.activity.filter(a => a.subtype === "streaming");
const beats = () => f.activity.filter(a => a.subtype === "liveness");
return { core, f, beats, advance: (ms: number) => { t += ms; } };
}
const ready = (core: RunnerCore) => core.handlePiLine(JSON.stringify({
@@ -228,7 +228,8 @@ describe("streaming heartbeat", () => {
ready(c.core);
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
c.advance(10_000); c.core.heartbeat();
expect(c.beats()).toEqual([expect.objectContaining({ sessionName: SESSION, hookEvent: "active", subtype: "streaming", runtime: "pi" })]);
expect(c.beats()).toEqual([expect.objectContaining({ sessionName: SESSION, hookEvent: "active", subtype: "liveness", runtime: "pi",
turnId: "1", livenessSeconds: 30 })]);
c.advance(10_000); c.core.heartbeat();
expect(c.beats()).toHaveLength(2);
});
@@ -239,7 +240,7 @@ describe("streaming heartbeat", () => {
ready(c.core);
c.core.handlePiLine(JSON.stringify({ type: "turn_start" }));
c.advance(10_000); c.core.heartbeat();
expect(c.beats()).toEqual([expect.objectContaining({ subtype: "streaming" })]);
expect(c.beats()).toEqual([expect.objectContaining({ subtype: "liveness", turnId: "1" })]);
c.core.handlePiLine(JSON.stringify({ type: "agent_end" }));
c.advance(10_000); c.core.heartbeat();
expect(c.beats()).toHaveLength(1); // closed: idle branch resumes
@@ -283,6 +284,57 @@ describe("streaming heartbeat", () => {
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
c.advance(20_000); c.core.heartbeat();
expect(c.beats()).toEqual([]);
expect(c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit")).toEqual([]);
});
// A beat counts only for the latest accepted turn (contract section 4). Pi's runs start from the runner's prompts, so
// each run that opens posts its accepted turn first; a prompt Pi handles without a run opens none.
it("posts one accepted turn per run, before the run's first post, and its beats name that turn", () => {
const c = clocked();
ready(c.core);
const turns = () => c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit");
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
c.core.handlePiLine(JSON.stringify({ type: "turn_start" })); // the same run's next model call opens no turn
expect(turns()).toEqual([expect.objectContaining({ subtype: "accepted", turnId: "1", runtime: "pi" })]);
const opened = c.f.activity.findIndex(a => a.hookEvent === "UserPromptSubmit");
expect(c.f.activity[opened + 1]).toMatchObject({ hookEvent: "active", subtype: "agent_start" });
c.advance(10_000); c.core.heartbeat();
c.core.handlePiLine(JSON.stringify({ type: "agent_end" }));
c.core.handlePiLine(JSON.stringify({ type: "turn_start" })); // a run opened by turn_start alone
c.advance(10_000); c.core.heartbeat();
expect(turns().map(a => a.turnId)).toEqual(["1", "2"]);
expect(c.beats().map(a => a.turnId)).toEqual(["1", "2"]);
const sequences = c.f.activity.map(a => Number(a.sequence)).filter(Number.isFinite);
expect(sequences).toEqual([...sequences].sort((a, b) => a - b));
});
// Section 4: each prompt the runner submits is accepted with its own number. One submitted while a run is open (a
// steer, or a follow-up) is posted as it's sent, and the open run's agent_end ends it.
it("posts an accepted turn for each prompt or follow-up submitted while a run is open, and its beats name it", () => {
const c = clocked();
ready(c.core);
const turns = () => c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit").map(a => a.turnId);
c.core.handlePiLine(JSON.stringify({ type: "agent_start" }));
c.core.handleUserBlock("look at b.txt too");
expect(turns()).toEqual(["1", "2"]);
c.advance(10_000); c.core.heartbeat();
c.core.handleUserBlock("/followup then c.txt");
c.core.handleUserBlock("/abort");
expect(turns()).toEqual(["1", "2", "3"]);
c.advance(10_000); c.core.heartbeat();
expect(c.beats().map(a => a.turnId)).toEqual(["2", "3"]);
const sequences = c.f.activity.map(a => Number(a.sequence)).filter(Number.isFinite);
expect(sequences).toEqual([...sequences].sort((a, b) => a - b));
});
it("opens no turn for a prompt Pi handles without a run, so the idle beat goes on", () => {
const c = clocked();
ready(c.core);
c.core.handlePiLine(JSON.stringify({ type: "agent_end" })); // idle, with a Stop posted
c.core.handleUserBlock("/model some-model");
c.advance(180_000); c.core.heartbeat();
expect(c.f.activity.filter(a => a.hookEvent === "UserPromptSubmit")).toEqual([]);
expect(c.f.activity.at(-1)).toMatchObject({ hookEvent: "Stop", subtype: "idle_heartbeat" });
});
});
@@ -303,7 +355,7 @@ describe("heartbeat scheduler cadence", () => {
data: { sessionFile: SESSION_FILE, sessionId: "0197a2f0" },
}));
const timer = startHeartbeatTimer(core, "pi");
const beats = () => f.activity.filter(a => a.subtype === "streaming");
const beats = () => f.activity.filter(a => a.subtype === "liveness");
return { f, core, timer, beats, advance: (ms: number) => vi.advanceTimersByTime(ms) };
}
const after = (c: ReturnType<typeof scheduledCore>) => { c.timer.stop(); vi.useRealTimers(); };
@@ -70,4 +70,10 @@ describe("inferCatalogProject working-directory depth", () => {
catalog({ id: "alpha", root: "/a" }, { id: "beta", root: "/a/b" });
expect(infer("/a/b/c")).toEqual({ id: "beta", root: "/a/b", selectedBy: "cwd" });
});
it("finds the workspace's project from a new kernel's own folder, as from the workspace root", () => {
catalog({ id: "default", root: "/ws" }, { id: "bundle", root: "/ws/projects/bundle" });
expect(infer("/ws/kernel")).toEqual({ id: "default", root: "/ws", selectedBy: "cwd" });
expect(infer("/ws")).toEqual(infer("/ws/kernel"));
});
});
@@ -14,6 +14,7 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
import { tmuxAttachCommand } from "../src/adapters/tmux-server.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js";
import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
import { makeOperatorDeliveryEngine } from "../src/domain/gateway/operator-delivery-engine.js";
import { buildSlackGatewayWire } from "../src/domain/gateway/slack/slack-subsystem.js";
@@ -528,6 +529,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
let pane = "%17";
let changeBinding = false;
const tmux = {
getPanePid: vi.fn(async () => 123),
listPanes: vi.fn(async () => [{ id: pane, index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]),
readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: 123, command: "codex", dead }]])),
capturePaneScreen: vi.fn(async () => {
@@ -560,6 +562,68 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
expect(posts).toHaveLength(1);
});
function shellHookReaderFixture() {
const rigs = new RigRepository(db); const sessions = new SessionRegistry(db);
const rig = rigs.createRig("kernel"); const node = rigs.addNode(rig.id, "operator", { runtime: "codex" });
const destination = "operator@kernel";
const session = sessions.registerSession(node.id, destination); sessions.updateStatus(session.id, "running");
sessions.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: destination, tmuxPane: "%17" });
let screen = "OpenAI Codex (v0.164.0)\nHooks need review\nTrust all and continue";
let panePid = 123, nativePid = 126;
const processes = (): NativeProcessRow[] => [
{ pid: panePid, ppid: 1, pgid: panePid, tpgid: 124, executableName: "zsh", command: "-zsh", startedAt: "start" },
{ pid: 124, ppid: panePid, pgid: 124, tpgid: 124, executableName: "sh", command: "/bin/sh /fixture/start.sh", startedAt: "start" },
{ pid: 125, ppid: 124, pgid: 124, tpgid: 124, executableName: "node", command: "node /opt/bin/codex", startedAt: "start" },
{ pid: nativePid, ppid: 125, pgid: 124, tpgid: 124, executableName: "codex", command: "/opt/native/codex",
argv: ["/opt/native/codex"], startedAt: "start" },
];
const tmux = {
getPanePid: vi.fn(async () => panePid),
listPanes: vi.fn(async () => [{ id: "%17", index: 0, cwd: "/fixture", width: 80, height: 24, active: true }]),
readAllPaneProcesses: vi.fn(async () => new Map([["%17", { pid: panePid, command: "sh", dead: false }]])),
capturePaneScreen: vi.fn(async () => screen),
};
const listProcesses = vi.fn(async () => processes()); const now = new Date("2030-01-01T00:00:00Z");
const deps = { db, tmux, listProcesses, now: () => now };
return { tmux, listProcesses, processes, now, destination, read: makeKernelHookReviewReader(deps),
screen: (value: string) => { screen = value; }, changeNative: () => { nativePid++; },
changePanePid: () => { panePid += 1000; }, changeBinding: () => sessions.updateBinding(node.id, { tmuxPane: "%18" }) };
}
it("escalates a verified shell-backed kernel Codex and leaves its alert unresolved while lineage is unknown", async () => {
const f = shellHookReaderFixture();
expect(await verifyCodexPaneProcess({ target: "%17", tmux: f.tmux, listProcesses: f.listProcesses })).not.toBeNull();
expect((await f.read())[0]?.state).toBe("blocked");
const posts: unknown[] = []; const port = engine(posts);
await tick(port, { now: f.now, readHookReviews: f.read });
await vi.waitFor(() => expect(posts).toHaveLength(1));
expect(alerts()[0]!.body).toContain(f.destination);
f.screen("OpenAI Codex (v0.164.0)\nmodel: gpt-5\n› ");
f.listProcesses.mockResolvedValue([]);
expect((await f.read())[0]?.state).toBe("unknown");
await tick(port, { now: f.now, readHookReviews: f.read });
expect(alerts()).toHaveLength(1); expect(posts).toHaveLength(1);
f.listProcesses.mockImplementation(async () => f.processes());
expect((await f.read())[0]?.state).toBe("clear");
await tick(port, { now: f.now, readHookReviews: f.read });
expect(alerts()).toHaveLength(0);
});
it.each(["missing", "unavailable", "changing", "capture-change", "pane-change", "binding-change"])(
"a shell-backed kernel observation stays unknown without a stable chain: %s", async kind => {
const f = shellHookReaderFixture();
if (kind === "missing") f.listProcesses.mockResolvedValue([]);
if (kind === "unavailable") f.listProcesses.mockRejectedValue(new Error("unavailable process table"));
if (kind === "changing") f.listProcesses.mockImplementation(async () => { f.changeNative(); return f.processes(); });
if (kind === "capture-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeNative(); return "Hooks need review"; });
if (kind === "pane-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changePanePid(); return "Hooks need review"; });
if (kind === "binding-change") f.tmux.capturePaneScreen.mockImplementation(async () => { f.changeBinding(); return "Hooks need review"; });
expect((await f.read())[0]?.state).toBe("unknown");
const posts: unknown[] = [];
await tick(engine(posts), { now: f.now, readHookReviews: f.read });
expect(alerts()).toHaveLength(0); expect(posts).toHaveLength(0);
});
it("several rows share one alert, persisted acceptance and receipt across reconstructed ticks", async () => {
await refusal(3); const posts: unknown[] = []; const port = engine(posts);
expect((await tick(port)).outcome).not.toBe("failed");
+25
View File
@@ -1667,6 +1667,31 @@ describe("queue routes", () => {
expect(await ids(`attention=1&handedOffFrom=${source}`)).toEqual([]);
});
it("GET /api/queue/list?sort=updated picks a bounded window by latest update, so an old item closed last is in it", async () => {
const post = (path: string, session: string, body: Record<string, unknown>) => app.request(path, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
});
const create = async (body: string) => ((await (await post("/api/queue/create", "a@r", { destinationSession: "b@r", body, nudge: false })).json()) as { qitemId: string }).qitemId;
const close = (qitemId: string, ts: string) => {
db.prepare("UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?").run(ts, qitemId);
};
const oldest = await create("started first, finished last");
db.prepare("UPDATE queue_items SET ts_created = '2026-01-01T00:00:00.000Z' WHERE qitem_id = ?").run(oldest);
const middle = await create("middle");
db.prepare("UPDATE queue_items SET ts_created = '2026-01-02T00:00:00.000Z' WHERE qitem_id = ?").run(middle);
const newest = await create("started last");
db.prepare("UPDATE queue_items SET ts_created = '2026-01-03T00:00:00.000Z' WHERE qitem_id = ?").run(newest);
close(middle, "2026-01-04T00:00:00.000Z");
close(newest, "2026-01-05T00:00:00.000Z");
close(oldest, "2026-01-06T00:00:00.000Z");
const ids = async (query: string) => ((await (await app.request(`/api/queue/list?${query}`)).json()) as Array<{ qitemId: string }>).map((q) => q.qitemId);
expect(await ids("state=done,handed-off&sort=updated&limit=2")).toEqual([oldest, newest]);
// Without it the window stays newest created, and a sort it doesn't know changes nothing.
expect(await ids("state=done,handed-off&limit=2")).toEqual([newest, middle]);
expect(await ids("state=done,handed-off&sort=other&limit=2")).toEqual([newest, middle]);
});
it("GET /api/queue/list trims each state name and otherwise keeps today's matching", async () => {
const post = (path: string, session: string, body: Record<string, unknown>) => app.request(path, {
method: "POST", headers: { "Content-Type": "application/json", "X-OpenRig-Session": session }, body: JSON.stringify(body),
@@ -22,6 +22,7 @@ import { ContextUsageStore } from "../src/domain/context-usage-store.js";
import type { ProjectionEntry, ProjectionPlan } from "../src/domain/projection-planner.js";
import type { NodeBinding, RuntimeAdapter } from "../src/domain/runtime-adapter.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { MANAGED_BLOCK_START } from "../src/domain/managed-blocks.js";
// Real snapshot/restore/startup/projection and settings files; only terminal and
// native launch/readiness are simulated. No Claude, provider or daemon is started.
@@ -99,21 +100,25 @@ describe("contained Claude restore activity resources", () => {
return Object.values(s.hooks ?? {}).flatMap((groups: any) => groups.flatMap((g: any) => g.hooks ?? []))
.map((h: any) => h.command as string).filter(c => c.includes("state/claude-activity-hooks/activity-relay.cjs"));
}
function seed(pod: boolean, selected = true, runtime = "claude-code") {
function seed(pod: boolean, selected = true, runtime = "claude-code", withStartupContext = true, seedCwd: string | null = cwd,
extraEntries: Array<{ category: string; effectiveId: string; absolutePath: string; mergeStrategy: string }> = []) {
const rig = rigs.createRig("restore");
if (pod) db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod", rig.id, "P");
const node = rigs.addNode(rig.id, pod ? "p.seat" : "seat", { runtime, cwd, ...(pod ? { podId: "pod" } : {}) });
const node = rigs.addNode(rig.id, pod ? "p.seat" : "seat", { runtime, ...(seedCwd ? { cwd: seedCwd } : {}), ...(pod ? { podId: "pod" } : {}) });
const session = registry.registerSession(node.id, "seat@restore");
registry.updateStatus(session.id, "running");
registry.updateResumeToken(session.id, runtime === "codex" ? "codex_id" : runtime === "pi" ? "pi_session_file" : "claude_id", token);
const entries = [
...(selected ? [activity()] : []),
{ category: "guidance", effectiveId: "do-not-replay", absolutePath: path.join(root, "missing-guidance.md"), mergeStrategy: "managed_block" },
...extraEntries,
];
const files = [{ path: "missing.md", absolutePath: path.join(root, "missing.md"), required: true, appliesOn: ["restore"] }];
const actions = [{ type: "send_text", value: "DO NOT REPLAY", phase: "after_ready", appliesOn: ["restore"], idempotent: true }];
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
if (withStartupContext) {
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
}
const saved = db.prepare("SELECT * FROM node_startup_context WHERE node_id=?").get(node.id);
const snap = capture.captureSnapshot(rig.id, "manual");
registry.updateStatus(session.id, "exited");
@@ -122,8 +127,8 @@ describe("contained Claude restore activity resources", () => {
}
function restore(f: ReturnType<typeof seed>, runtimeAdapter: RuntimeAdapter = adapter, beforeResume = () => {}, desired?: (nodeId: string) =>
| { status: "known"; selected: boolean } | { status: "unknown"; evidence: string },
adapters?: Record<string, RuntimeAdapter>) {
const claude = { canResume: () => true, resume: vi.fn(async () => { beforeResume(); return { ok: true }; }) } as unknown as ClaudeResumeAdapter;
adapters?: Record<string, RuntimeAdapter>, claudeResume?: ClaudeResumeAdapter) {
const claude = claudeResume ?? { canResume: () => true, resume: vi.fn(async () => { beforeResume(); return { ok: true }; }) } as unknown as ClaudeResumeAdapter;
const nodeLauncher = new NodeLauncher({ db, rigRepo: rigs, sessionRegistry: registry, eventBus: events, tmuxAdapter: tmux });
const orch = new RestoreOrchestrator({ db, rigRepo: rigs, sessionRegistry: registry, eventBus: events,
snapshotRepo: snapshots, snapshotCapture: capture, checkpointStore: checkpoints, nodeLauncher, tmuxAdapter: tmux,
@@ -285,6 +290,227 @@ describe("contained Claude restore activity resources", () => {
expect(fs.existsSync(path.join(cwd, "CLAUDE.md"))).toBe(false);
});
// Follow-up to #2338 (CodeRabbit Major 4436149570): an older snapshot with
// NO nodeStartupContext row at all must not leave a known-selected legacy
// Claude seat hookless with no restore gap. The runtime falls back to the
// node's saved runtime and absent projection entries count as an empty list.
// The fallback is scoped to legacy seats whose live runtime still matches
// the snapshot's: a pod-aware seat, or one whose runtime changed since the
// snapshot, gets no hooks write from this recovery (see the two tests
// below). The restore itself is unchanged in both cases.
it("re-applies the current selection when the saved startup context is absent (legacy seat)", async () => {
const f = seed(false, false, "claude-code", false);
const project = vi.spyOn(adapter, "project");
const result = await restore(f, adapter, () => {}, () => ({ status: "known", selected: true }));
expect(result.ok).toBe(true);
if (!result.ok) return;
const recoveryCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(recoveryCall).toBeDefined();
expect(recoveryCall![0].entries).toHaveLength(1);
expect(recoveryCall![0].entries[0]).toMatchObject({
category: "runtime_resource", resourceType: "claude_activity_hooks", sourceSpec: "current-selection",
});
expect(owned().length).toBeGreaterThan(0);
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:") && w.includes("current"))).toBe(true);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
expect(tmux.sendText).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(cwd, "CLAUDE.md"))).toBe(false);
});
it("does not consult current selection or project claude hooks for legacy codex seat with absent startup context", async () => {
const runtime = "codex";
const f = seed(false, false, runtime, false);
const project = vi.fn(CodexRuntimeAdapter.prototype.project);
const launch = vi.fn(async (_b, opts) => ({ ok: true as const, resumeToken: opts.resumeToken, resumeType: "codex_id" }));
const other = { runtime, project, listInstalled: async () => [], deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: async () => ({ ready: true }), launchHarness: launch } as RuntimeAdapter;
const claudeProject = vi.spyOn(adapter, "project");
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
const result = await restore(f, other, () => {}, desired, { "claude-code": adapter, codex: other });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(desired).not.toHaveBeenCalled();
const claudeRecovery = claudeProject.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(claudeRecovery).toBeUndefined();
const otherRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(otherRecovery).toBeUndefined();
expect(owned()).toEqual([]);
expect(writes).toEqual([]);
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
});
// Maintainer review on #2373: the no-context fallback must not write Claude
// hooks for a snapshot whose agent runtime changed after the snapshot was
// taken. With a saved startup context, the compatibility gate stops the
// mismatch; without one, nothing does, so the fallback must compare the
// live runtime against the snapshot's before re-applying anything.
it("does not project claude hooks when the live runtime no longer matches the snapshot runtime (no startup context)", async () => {
const f = seed(false, false, "claude-code", false);
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run("codex", f.node.id);
const project = vi.spyOn(adapter, "project");
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
const result = await restore(f, adapter, () => {}, desired);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(desired).not.toHaveBeenCalled();
const claudeRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(claudeRecovery).toBeUndefined();
expect(owned()).toEqual([]);
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
});
// Maintainer review on #2373: a pod-aware agent with no startup context
// must get no hooks write from this recovery — it still ends at the
// "Harness not started" gate, and the restore itself is unchanged.
it("does not project claude hooks for a pod-aware seat with no startup context", async () => {
const f = seed(true, false, "claude-code", false);
const project = vi.spyOn(adapter, "project");
const desired = vi.fn((): { status: "known"; selected: boolean } => ({ status: "known", selected: true }));
const result = await restore(f, adapter, () => {}, desired);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.result.nodes[0]).toMatchObject({
status: "attention_required", error: "Harness not started: no startup context for p.seat.",
});
expect(desired).not.toHaveBeenCalled();
const claudeRecovery = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(claudeRecovery).toBeUndefined();
expect(owned()).toEqual([]);
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
});
// CodeRabbit follow-up on #2373: the snapshot node may have no cwd while
// the live node does. NodeLauncher launches the seat in the live node's
// directory (opts.cwd ?? live node cwd), so the hook projection must use
// that resolved directory — not "." (the daemon's working directory), which
// would write another project's hook settings. The snapshot is captured
// with no cwd; the live row gains one only after capture.
function liveProject() {
const liveCwd = path.join(root, "live-project");
const liveSettingsPath = path.join(liveCwd, ".claude/settings.local.json");
fs.mkdirSync(path.dirname(liveSettingsPath), { recursive: true });
fs.writeFileSync(liveSettingsPath, JSON.stringify(originalSettings));
return { liveCwd, liveSettingsPath };
}
function liveOwned(liveSettingsPath: string): string[] {
return owned(JSON.parse(fs.readFileSync(liveSettingsPath, "utf8")));
}
it("projects the current selection into the live seat directory when the snapshot node has no cwd", async () => {
const { liveCwd, liveSettingsPath } = liveProject();
const f = seed(false, false, "claude-code", false, null);
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
const project = vi.spyOn(adapter, "project");
const result = await restore(f, adapter, () => {}, () => ({ status: "known", selected: true }));
expect(result.ok).toBe(true);
if (!result.ok) return;
const recoveryCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(recoveryCall).toBeDefined();
expect(recoveryCall![0].cwd).toBe(liveCwd);
expect(recoveryCall![1].cwd).toBe(liveCwd);
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("projects saved hooks entries into the live seat directory when the snapshot node has no cwd", async () => {
const { liveCwd, liveSettingsPath } = liveProject();
const f = seed(false, true, "claude-code", true, null);
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
const project = vi.spyOn(adapter, "project");
const { StartupOrchestrator } = await import("../src/domain/startup-orchestrator.js");
const startNode = vi.spyOn(StartupOrchestrator.prototype, "startNode");
const result = await restore(f, adapter);
expect(result.ok).toBe(true);
if (!result.ok) return;
const savedCall = project.mock.calls.find(([plan]) => plan.entries.some((e) => e.resourceType === "claude_activity_hooks"));
expect(savedCall).toBeDefined();
expect(savedCall![0].cwd).toBe(liveCwd);
expect(savedCall![1].cwd).toBe(liveCwd);
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
// The same restore's startup replay must use the live seat directory too:
// with the snapshot cwd absent it previously wrote the collector/status
// line into the daemon's own working folder ("."), polluting later tests.
const replayCalls = startNode.mock.calls.filter(([args]) => args.plan && args.binding);
expect(replayCalls.length).toBeGreaterThan(0);
for (const [{ plan: replayPlan, binding: replayBinding }] of replayCalls) {
expect(replayPlan.cwd).toBe(liveCwd);
expect(replayBinding.cwd).toBe(liveCwd);
}
expect(result.result.warnings.some((w) => w.startsWith("Restore activity hooks:"))).toBe(false);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
});
// CodeRabbit follow-up on #2373: the legacy resume adapter must receive the
// same resolved launch directory as the hook projection and the startup
// replay — when the snapshot node has no cwd, the seat was launched in the
// live node's directory, not "/" (which a managed launch would reject as a
// directory mismatch for a seat with an explicit permission mode).
it("resumes the legacy seat in the live seat directory when the snapshot node has no cwd", async () => {
const { liveCwd, liveSettingsPath } = liveProject();
const f = seed(false, true, "claude-code", true, null);
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
const resume = vi.fn(async () => ({ ok: true }));
const result = await restore(f, adapter, () => {}, undefined, undefined,
{ canResume: () => true, resume } as unknown as ClaudeResumeAdapter);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(resume).toHaveBeenCalled();
// 4th positional argument: the launch directory handed to the resume adapter.
expect(resume.mock.calls[0]![3]).toBe(liveCwd);
expect(liveOwned(liveSettingsPath).length).toBeGreaterThan(0);
expect(result.result.nodes[0].restoreGaps).toBeUndefined();
});
// CodeRabbit follow-up on #2373: the saved-guidance lookups must use the
// same resolved launch directory as the hook projection and startup replay.
// When the snapshot node has no cwd, seatGuidance with node.cwd (null)
// returns null, so the pre-resume repair is skipped and the post-launch
// check reports nothing — a seat whose guidance blocks are missing in the
// live directory comes back fully_restored with no warning. The seed's
// "do-not-replay" guidance entry (source always missing) stays a gap in
// both tests; only the SOP.md entry distinguishes fixed from unfixed.
it("puts back saved guidance blocks in the live seat directory when the snapshot node has no cwd", async () => {
const { liveCwd } = liveProject();
const sourcePath = path.join(root, "SOP.md");
fs.writeFileSync(sourcePath, "Current SOP text.");
const guidanceFile = path.join(liveCwd, "CLAUDE.md");
fs.writeFileSync(guidanceFile, "# My project notes\n");
const f = seed(false, false, "claude-code", true, null, [
{ category: "guidance", effectiveId: "SOP.md", absolutePath: sourcePath, mergeStrategy: "managed_block" },
]);
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
const result = await restore(f, adapter);
expect(result.ok).toBe(true);
if (!result.ok) return;
const content = fs.readFileSync(guidanceFile, "utf-8");
expect(content).toContain(MANAGED_BLOCK_START("SOP.md"));
expect(content).toContain("Current SOP text.");
expect(result.result.nodes[0].guidanceGaps).toEqual(["do-not-replay"]);
});
it("discloses a guidance gap in the live seat directory when the snapshot node has no cwd and the source is gone", async () => {
const { liveCwd } = liveProject();
const sourcePath = path.join(root, "SOP-gone.md");
fs.writeFileSync(sourcePath, "Current SOP text.");
const guidanceFile = path.join(liveCwd, "CLAUDE.md");
fs.writeFileSync(guidanceFile, "# My project notes\n");
const f = seed(false, false, "claude-code", true, null, [
{ category: "guidance", effectiveId: "SOP.md", absolutePath: sourcePath, mergeStrategy: "managed_block" },
]);
db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(liveCwd, f.node.id);
fs.rmSync(sourcePath);
const result = await restore(f, adapter);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect([...(result.result.nodes[0].guidanceGaps ?? [])].sort()).toEqual(["SOP.md", "do-not-replay"]);
expect(result.result.warnings.join("\n")).toContain("SOP.md");
expect(fs.readFileSync(guidanceFile, "utf-8")).toBe("# My project notes\n");
});
// The recovery projection must be awaited: an adapter whose project()
// suspends (real async work) must not have its warnings/gaps silently
// dropped because postLaunchRestore continued to the rollup first.
@@ -61,7 +61,7 @@ const nodeFs = {
interface SeatOptions {
podAware?: boolean;
runtime?: "claude-code" | "codex";
runtime?: "claude-code" | "codex" | "pi";
claudeManagedBlockFile?: "CLAUDE.md" | "CLAUDE.local.md";
userText?: string;
/** An extra "auto" startup file whose content marks it as a skill. */
@@ -74,6 +74,10 @@ interface SeatOptions {
seatSkillsLine?: boolean;
}
/** The resume type each runtime records for its native session. */
const resumeTypeOf = (runtime: NonNullable<SeatOptions["runtime"]>): string =>
runtime === "codex" ? "codex_id" : runtime === "pi" ? "pi_session_file" : "claude_id";
const SEAT_SKILLS_ASSET = path.join(import.meta.dirname, "../assets/guidance/openrig-seat-skills.md");
describe("a seat's guidance across rig down then rig up --existing (exact resume)", () => {
@@ -128,7 +132,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
const sess = sessionRegistry.registerSession(node.id, sessionName);
sessionRegistry.updateStatus(sess.id, "running");
if (opts.podAware) {
sessionRegistry.updateResumeToken(sess.id, runtime === "codex" ? "codex_id" : "claude_id", TOKEN);
sessionRegistry.updateResumeToken(sess.id, resumeTypeOf(runtime), TOKEN);
} else {
db.prepare("UPDATE sessions SET resume_type = ?, resume_token = ?, restore_policy = ? WHERE id = ?")
.run("claude_name", TOKEN, "resume_if_possible", sess.id);
@@ -165,7 +169,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
const actions = [{ type: "send_text", value: "STARTUP-PROMPT-MUST-NOT-REPLAY", phase: "after_ready", appliesOn: ["fresh_start", "restore"], idempotent: true }];
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
const guidanceFile = path.join(cwd, runtime === "codex" ? "AGENTS.md" : (opts.claudeManagedBlockFile ?? "CLAUDE.md"));
const guidanceFile = path.join(cwd, runtime === "claude-code" ? (opts.claudeManagedBlockFile ?? "CLAUDE.md") : "AGENTS.md");
if (opts.userText) fs.writeFileSync(guidanceFile, opts.userText);
mergeManagedBlock(nodeFs, guidanceFile, "lead-role", "You lead this team.");
mergeManagedBlock(nodeFs, guidanceFile, "SOP.md", "How work flows here.");
@@ -199,7 +203,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => {
observe();
return { ok: true as const, resumeToken: TOKEN, resumeType: runtime === "codex" ? "codex_id" : "claude_id" };
return { ok: true as const, resumeToken: TOKEN, resumeType: resumeTypeOf(runtime) };
}),
};
const restore = new RestoreOrchestrator({
@@ -210,7 +214,7 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
// The pane's process tree shows the same native session resumed (the joined-resume proof).
listProcesses: async () => [
{ pid: 1234, ppid: 1, command: "zsh" },
{ pid: 1235, ppid: 1234, command: runtime === "codex" ? `codex resume ${TOKEN}` : `claude --resume ${TOKEN}` },
{ pid: 1235, ppid: 1234, command: runtime === "codex" ? `codex resume ${TOKEN}` : runtime === "pi" ? `pi --session ${TOKEN}` : `claude --resume ${TOKEN}` },
],
});
const up = await restore.restore(down.snapshotId!, { adapters: { [runtime]: adapter as never } });
@@ -272,6 +276,20 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
}
});
// #875: a Pi seat's blocks live in AGENTS.md. Down takes them out like a Codex seat's, and the exact resume puts them back.
it("a Pi seat's exact resume puts back the blocks down took out of its AGENTS.md, and writes no other file", async () => {
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi", userText: "Person notes\n" });
let afterDown: string | null = null;
const { result, fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi",
betweenDownAndUp: () => { afterDown = fs.readFileSync(guidanceFile, "utf-8"); } });
expect(afterDown, "AGENTS.md after rig down").toBe("Person notes\n");
expect(fileAtHarnessStart).toContain("Person notes\n");
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("lead-role"));
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("SOP.md"));
expect(result.nodes[0]?.guidanceGaps).toBeUndefined();
for (const other of ["CLAUDE.md", "CLAUDE.local.md"]) expect(fs.existsSync(path.join(cwd, other)), other).toBe(false);
});
it("only a missing block is put back: an existing block keeps its content, and user text is preserved", async () => {
const { rigId, guidanceFile } = launchedSeat({ userText: "# My project notes\nKeep tests fast.\n" });
const { fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, {
+203 -1
View File
@@ -23,6 +23,10 @@ import { promisify } from "node:util";
import { managedBlockText, mergeManagedBlock } from "../src/domain/managed-blocks.js";
import { SEAT_SKILLS_BOOTSTRAP_LINE } from "../src/domain/seat-skill-storage.js";
import { getNodeInventory } from "../src/domain/node-inventory.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { replaceFile } from "../src/adapters/replace-file.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { claudeActivityRelayPath } from "../src/domain/claude-activity-hooks.js";
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
@@ -594,7 +598,7 @@ describe("RigTeardownOrchestrator", () => {
});
it.each([
["claude-code", "CLAUDE.md"], ["claude-code", "CLAUDE.local.md"], ["codex", "AGENTS.md"],
["claude-code", "CLAUDE.md"], ["claude-code", "CLAUDE.local.md"], ["codex", "AGENTS.md"], ["pi", "AGENTS.md"], ["omp", "AGENTS.md"],
])("preserves %s guidance when a sibling sharing %s cannot stop", async (runtime, fileName) => {
const { rigId, nodeId, sessionId } = seedRigWithNode({ runtime, cwd: tmpDir });
if (fileName === "CLAUDE.local.md") rigRepo.setRigClaudeManagedBlockFile(rigId, fileName);
@@ -740,6 +744,204 @@ describe("RigTeardownOrchestrator", () => {
expect(fs.readFileSync(guidance, "utf-8")).toBe(content);
});
// #875: Pi and omp seats write their blocks to AGENTS.md too, so a Codex rig stopping must not strip a file they still use.
it.each(["pi", "omp"])("keeps a shared AGENTS.md that another rig's live %s seat still uses when a Codex rig stops", async (runtime) => {
const { rigId } = seedRigWithNode({ runtime: "codex", cwd: tmpDir });
const other = rigRepo.createRig("other-rig");
const otherNode = rigRepo.addNode(other.id, "dev", { runtime, cwd: tmpDir });
sessionRegistry.updateStatus(sessionRegistry.registerSession(otherNode.id, "dev@other-rig").id, "running");
const guidance = path.join(tmpDir, "AGENTS.md");
const content = "<!-- BEGIN OpenRig MANAGED BLOCK: role -->\nshared guidance\n<!-- END OpenRig MANAGED BLOCK: role -->\n";
fs.writeFileSync(guidance, content);
await buildTeardown().teardown(rigId);
expect(fs.readFileSync(guidance, "utf-8")).toBe(content);
});
// #875: once no seat in a cwd still uses them, down takes back the collector, its status line and the activity relay hooks
// OpenRig added there, and leaves everything else as the person had it.
describe("OpenRig's Claude collector, status line and activity hooks in a seat's cwd", () => {
const COLLECTOR = "// shipped collector\n";
/** A Claude seat's cwd after a real collector install, with the person's own settings text if given. */
function claudeCwd(ownSettings?: string) {
const cwd = fs.mkdtempSync(path.join(tmpDir, "claude-cwd-"));
const state = path.join(tmpDir, "openrig-home");
const asset = path.join(tmpDir, "collector-asset.cjs");
fs.writeFileSync(asset, COLLECTOR);
const settings = path.join(cwd, ".claude", "settings.local.json");
if (ownSettings !== undefined) {
fs.mkdirSync(path.dirname(settings), { recursive: true });
fs.writeFileSync(settings, ownSettings);
}
const fsOps = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
copyFile: replaceFile, homedir: os.homedir(),
};
new ClaudeCodeAdapter({ tmux: {} as TmuxAdapter, fsOps, stateDir: state, collectorAssetPath: asset })
.ensureContextCollector({ cwd, tmuxSession: "dev@claude-rig" });
const relay = `${shellQuote(process.execPath)} ${shellQuote(claudeActivityRelayPath(state))}`;
return { cwd, asset, settings, collector: path.join(cwd, ".openrig", "context-collector.cjs"), relay };
}
/** Add hook groups the way the adapter writes them. */
function addHooks(settings: string, hooks: Record<string, unknown[]>): void {
const current = JSON.parse(fs.readFileSync(settings, "utf-8")) as Record<string, unknown>;
current["hooks"] = { ...(current["hooks"] as Record<string, unknown> | undefined), ...hooks };
fs.writeFileSync(settings, JSON.stringify(current, null, 2));
}
function claudeRig(name: string, cwd: string): string {
const rig = rigRepo.createRig(name);
const node = rigRepo.addNode(rig.id, "dev", { runtime: "claude-code", cwd });
sessionRegistry.updateStatus(sessionRegistry.registerSession(node.id, `dev@${name}`).id, "running");
return rig.id;
}
function teardownWith(asset: string, tmux?: TmuxAdapter) {
return new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux ?? mockTmux(),
snapshotCapture: mockSnapshotCapture(db), eventBus, claudeCollectorAssetPath: asset });
}
it("gives a non-git cwd back the person's own settings bytes, and removes the collector and its emptied folder", async () => {
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] }, env: { TEAM: "1" } }, null, 2);
const s = claudeCwd(own);
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }],
PreToolUse: [{ hooks: [{ type: "command", command: s.relay, timeout: 5 }] }] });
expect(fs.readFileSync(s.settings, "utf-8")).not.toBe(own);
expect(fs.existsSync(path.join(s.cwd, ".git"))).toBe(false);
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
expect(fs.readFileSync(s.settings, "utf-8")).toBe(own);
expect(fs.existsSync(s.collector)).toBe(false);
expect(fs.existsSync(path.join(s.cwd, ".openrig"))).toBe(false);
expect(result.keptFiles).toBeUndefined();
expect(result.errors).toEqual([]);
});
it("keeps settings.local.json as {} when OpenRig's entries were all it held, since nothing says who created it", async () => {
const s = claudeCwd();
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }] });
await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
expect(fs.readFileSync(s.settings, "utf-8")).toBe("{}");
expect(fs.existsSync(path.join(s.cwd, ".claude"))).toBe(true);
expect(fs.existsSync(path.join(s.cwd, ".openrig"))).toBe(false);
});
it("takes only OpenRig's hooks out of a mixed group, keeping the person's hooks, the group's other fields and their status line fields", async () => {
const person = { type: "command", command: "echo person" };
const s = claudeCwd(JSON.stringify({ permissions: { deny: ["Read(.env)"] }, statusLine: { padding: 2 } }, null, 2));
addHooks(s.settings, {
PreToolUse: [{ matcher: "Bash", hooks: [person, { type: "command", command: s.relay }] }],
Stop: [{ hooks: [{ type: "command", command: s.relay }] }],
Notification: [{ matcher: "", hooks: [person] }],
});
await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
expect(JSON.parse(fs.readFileSync(s.settings, "utf-8"))).toEqual({
permissions: { deny: ["Read(.env)"] },
statusLine: { padding: 2 },
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [person] }], Notification: [{ matcher: "", hooks: [person] }] },
});
});
it("keeps and names a collector whose bytes aren't the shipped collector's, and still takes its status line out", async () => {
const s = claudeCwd();
fs.appendFileSync(s.collector, "// a person's edit\n");
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
expect(fs.readFileSync(s.collector, "utf-8")).toBe(`${COLLECTOR}// a person's edit\n`);
expect(result.keptFiles).toEqual([`${s.collector}: not the shipped collector's bytes, so it was kept`]);
expect(JSON.parse(fs.readFileSync(s.settings, "utf-8"))).toEqual({});
expect(result.errors).toEqual([]);
});
it.each(["{ \"permissions\": ", "[]"])("leaves settings it can't read as a settings object (%s) byte for byte, and names them", async (text) => {
const s = claudeCwd(text);
const result = await teardownWith(s.asset).teardown(claudeRig("claude-rig", s.cwd));
expect(fs.readFileSync(s.settings, "utf-8")).toBe(text);
expect(result.keptFiles).toEqual([`${s.settings}: not a JSON settings object, so it was left as it is`]);
expect(fs.existsSync(s.collector)).toBe(false);
expect(result.errors).toEqual([]);
});
it.each(["another rig's seat", "a sibling whose stop failed"] as const)("keeps everything while %s still uses the cwd", async (consumer) => {
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] } }, null, 2);
const s = claudeCwd(own);
addHooks(s.settings, { Stop: [{ hooks: [{ type: "command", command: s.relay }] }] });
const before = fs.readFileSync(s.settings, "utf-8");
const rigId = claudeRig("claude-rig", s.cwd);
const tmux = mockTmux();
if (consumer === "another rig's seat") claudeRig("other-rig", s.cwd);
else {
const sibling = rigRepo.addNode(rigId, "survivor", { runtime: "claude-code", cwd: s.cwd });
sessionRegistry.updateStatus(sessionRegistry.registerSession(sibling.id, "survivor@claude-rig").id, "running");
tmux.killSession = vi.fn(async target => target === "survivor@claude-rig"
? { ok: false, code: "kill_failed", message: "tmux error" } : { ok: true });
}
await teardownWith(s.asset, tmux).teardown(rigId);
expect(fs.readFileSync(s.settings, "utf-8")).toBe(before);
expect(fs.readFileSync(s.collector, "utf-8")).toBe(COLLECTOR);
if (consumer === "a sibling whose stop failed") {
// Once that seat stops too, the next down takes OpenRig's additions back.
tmux.killSession = vi.fn(async () => ({ ok: true }));
await teardownWith(s.asset, tmux).teardown(rigId);
expect(fs.readFileSync(s.settings, "utf-8")).toBe(own);
expect(fs.existsSync(s.collector)).toBe(false);
}
});
// A link below the cwd shares a file between two directories: the stopping seat's cwd A and a live seat's cwd B. Whichever
// way the link points, the shared target stays and is named; A's own, unshared targets still go.
it.each(["B's .claude and .openrig link to A's", "A's .claude and .openrig link to B's", "settings.local.json is hardlinked"] as const)(
"keeps a Claude target a seat in another directory still uses when %s", async (shape) => {
const own = JSON.stringify({ permissions: { allow: ["Bash(ls:*)"] } }, null, 2);
const real = claudeCwd(own);
addHooks(real.settings, { Stop: [{ hooks: [{ type: "command", command: real.relay }] }] });
const other = fs.mkdtempSync(path.join(tmpDir, "claude-other-"));
let a = real.cwd, b = other;
if (shape === "A's .claude and .openrig link to B's") { a = other; b = real.cwd; }
if (shape === "settings.local.json is hardlinked") {
fs.mkdirSync(path.join(other, ".claude"));
fs.linkSync(real.settings, path.join(other, ".claude", "settings.local.json"));
} else {
fs.symlinkSync(path.join(real.cwd, ".claude"), path.join(other, ".claude"));
fs.symlinkSync(path.join(real.cwd, ".openrig"), path.join(other, ".openrig"));
}
const before = fs.readFileSync(real.settings, "utf-8");
const rigId = claudeRig("claude-rig", a);
claudeRig("other-rig", b);
const result = await teardownWith(real.asset).teardown(rigId);
expect(fs.readFileSync(real.settings, "utf-8")).toBe(before);
const aSettings = path.join(a, ".claude", "settings.local.json");
const aCollector = path.join(a, ".openrig", "context-collector.cjs");
if (shape === "settings.local.json is hardlinked") {
// A's collector isn't shared, so it still goes.
expect(fs.existsSync(real.collector)).toBe(false);
expect(result.keptFiles).toEqual([`${aSettings}: another seat's directory still uses it through a link, so it was kept`]);
} else {
expect(fs.readFileSync(real.collector, "utf-8")).toBe(COLLECTOR);
expect(result.keptFiles).toEqual([
`${aSettings}: another seat's directory still uses it through a link, so it was kept`,
`${aCollector}: another seat's directory still uses it through a link, so it was kept`,
]);
}
expect(result.errors).toEqual([]);
});
});
// T12: Kill failure + --delete -> blocked
it("kill failure blocks --delete", async () => {
const { rigId, nodeId } = seedRig();
+4 -3
View File
@@ -218,9 +218,10 @@ describe("source order", () => {
for (const type of ["agent_end", "agent_start"]) core.handlePiLine(JSON.stringify({ type }));
const sent = posts.filter((p) => typeof p.hookEvent === "string" && p.eventFamily === undefined);
const newest = sent.at(-1)!;
expect([sent.at(-2)!.hookEvent, newest.hookEvent, newest.sequence]).toEqual(["Stop", "active", 36]);
await f.deliver(newest); // the next turn's start arrives first, then all 35 older posts, the Stop last
for (const older of sent.slice(0, -1)) await f.deliver(older);
// Each run opens with its accepted turn (contract section 4), so the next turn's start is two posts.
expect([sent.at(-3)!.hookEvent, sent.at(-2)!.hookEvent, newest.hookEvent, newest.sequence]).toEqual(["Stop", "UserPromptSubmit", "active", 38]);
for (const next of sent.slice(-2)) await f.deliver(next); // the next turn's start arrives first, then all 36 older posts, the Stop last
for (const older of sent.slice(0, -2)) await f.deliver(older);
expect(f.state().activity).toBe("working");
await f.restart(); // restoring uses the same order: the newest start, not the late Stop
expect(f.state().activity).toBe("working");
@@ -14,10 +14,10 @@ type Check = { name: string; ok: boolean; detail?: string };
type Result = { id: string; status: "pass" | "fail" | "skip" | "advisory"; met?: boolean; checks: Check[]; reason?: string };
type Report = { results: Result[]; summary: { pass: number; fail: number; skip: number; advisory: number } };
async function check(args: string[], runnerFlags = "", nodeArgs: string[] = []): Promise<{ code: number; report: Report }> {
async function check(args: string[], runnerFlags = "", nodeArgs: string[] = [], timeoutMs = 90_000): Promise<{ code: number; report: Report }> {
const runner = `${process.execPath} ${RUNNER} --agent {agent}${runnerFlags}`;
try {
const { stdout } = await promisify(execFile)(process.execPath, [...nodeArgs, CHECK, "--runner", runner, "--name", "minimal", "--json", ...args], { timeout: 90_000 });
const { stdout } = await promisify(execFile)(process.execPath, [...nodeArgs, CHECK, "--runner", runner, "--name", "minimal", "--json", ...args], { timeout: timeoutMs });
return { code: 0, report: JSON.parse(stdout) };
} catch (error) {
const failed = error as { code?: number; stdout?: string; stderr?: string };
@@ -32,7 +32,10 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
it("passes every case for a runner that keeps the contract", async () => {
const { code, report } = await check(["--resume", "--first-input"]);
expect(failing(report)).toEqual([]);
expect(report.summary).toEqual({ pass: 12, fail: 0, skip: 0, advisory: 1 });
// 3b, the optional working beat, skips: this double sends no beats unless it's run with --beat.
expect(report.summary).toEqual({ pass: 12, fail: 0, skip: 1, advisory: 2 });
expect(report.results.find((r) => r.id === "3b")).toMatchObject({ status: "skip", reason: expect.stringContaining("optional") });
expect(report.results.find((r) => r.id === "4c")).toMatchObject({ status: "advisory", met: true });
expect(report.results.find((r) => r.id === "7c")).toMatchObject({ status: "advisory", met: true });
expect(code).toBe(0);
}, 120_000);
@@ -49,6 +52,18 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
expect(code).toBe(0);
}, 90_000);
// Section 7 lets a runner drop a person's words (OpenRig's `said`) and still keep the contract, so passing them on is
// reported on its own, like the shutdown duty.
it("reports a runner that drops the person's words as an advisory result that doesn't fail the run", async () => {
const { code, report } = await check(["--cases", "4b,4c"], " --break drop-words");
const words = report.results.find((r) => r.id === "4c")!;
expect(words).toMatchObject({ status: "advisory", met: false });
expect(words.checks.filter((c) => !c.ok).map((c) => c.name)).toContainEqual(expect.stringContaining("the person's words are queued to the agent as a prompt"));
expect(report.results.find((r) => r.id === "4b")!.status).toBe("pass");
expect(report.summary).toMatchObject({ fail: 0, advisory: 1 });
expect(code).toBe(0);
}, 90_000);
it("skips resume and first input when the runner's preset declares neither, and says why", async () => {
const { code, report } = await check(["--cases", "6,9"]);
expect(report.results.map((r) => [r.id, r.status])).toEqual([["6", "skip"], ["9", "skip"]]);
@@ -85,6 +100,9 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
["hold-on-cancel", "4b", "stale ask: nothing is held"],
["hold-after-withdraw", "4b", "turn end: nothing is held once the ask is withdrawn"],
["stop-before-end", "4b", "turn end: nothing is held once the ask is withdrawn"],
["stop-low-sequence", "4b", "pending: no idle Stop while it waits"],
// An idle Stop whose occurredAt isn't a string: OpenRig dates it by its arrival, so it counts.
["stop-numeric-time", "4b", "pending: no idle Stop while it waits"],
["hold-after-withdraw", "4b", "pending: after /abort, the turn's end is reported, holding nothing"],
["forget-permitted-ask", "4b", "unreachable read: the ask is still withdrawn at the turn's end"],
["cancel-on-revoked", "4b", "activity_grant_revoked: the agent gets its allow_once option"],
@@ -92,10 +110,15 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
["no-withdraw", "4b", "pending: /abort withdraws the ask with turn_cancelled"],
["late-deliver", "4b", "pending: an answer after the withdraw isn't delivered"],
["paste-answers", "4b", "pending: a pasted message isn't an answer"],
["said-as-answer", "4b", "permitted at read, with the person's words: the agent gets its allow_once option"],
["wrong-generation", "5", "generation is OPENRIG_OCCUPANT_GENERATION on every post"],
["new-on-resume", "6", "other failure: no new conversation starts"],
["missing-on-any-error", "6", "other failure: not reported missing or resumed"],
["idle-on-exit", "7", "no idle Stop after the exit"],
["idle-on-exit", "7", "no idle report for the turn it died in"],
// An idle Stop numbered below the turn's accepted prompt but later in time: OpenRig orders by time first, so it counts.
["idle-low-sequence-on-exit", "7", "no idle report for the turn it died in"],
// An idle Stop at the accepted prompt's time with a string sequence: OpenRig reads no sequence there, so it counts.
["idle-string-sequence-on-exit", "7", "no idle report for the turn it died in"],
["no-error-post", "7b", "a failed session/prompt posts Notification/runtime_error"],
["ignore-unknown", "8", "the unknown request is answered -32601"],
["first-input-trim", "9", "the first prompt is the file's bytes, newlines and spaces included"],
@@ -126,6 +149,28 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
expect(code).toBe(0);
}, 90_000);
// The optional working beat (section 4): a runner that beats in a quiet turn keeps 3b's promises, and each way of
// breaking one is caught by the check that names it. Case 3b's three launches take about 90 s, so it gets a longer bound.
const CASE_3B_MS = 170_000;
it("passes 3b for a runner that beats through a quiet turn, stops while a person is asked, and resolves before beating again", async () => {
const { code, report } = await check(["--cases", "3b"], " --beat", [], CASE_3B_MS);
expect(failing(report)).toEqual([]);
expect(report.results.map((r) => [r.id, r.status])).toEqual([["3b", "pass"]]);
expect(code).toBe(0);
}, CASE_3B_MS + 10_000);
it.concurrent.each([
["beat-after-stop", "no beat after the turn's Stop"],
["beat-during-ask", "asked: no beat while the ask waits on a person"],
["no-resolve", "asked: after the answer, active/permission_resolved names the turn and the ask, before any beat"],
["beat-time-backwards", "sequence rises with the runner's own time across every post, beats included"],
])("catches %s in case 3b", async (broken, name) => {
const { code, report } = await check(["--cases", "3b"], ` --beat --break ${broken}`, [], CASE_3B_MS);
const result = report.results.find((r) => r.id === "3b")!;
expect(result.status).toBe("fail");
expect(result.checks.filter((c) => !c.ok).map((c) => c.name)).toContainEqual(expect.stringContaining(name));
expect(code).toBe(1);
}, CASE_3B_MS + 10_000);
// Section 7 sets no time limit for a permit after a read OpenRig answered with a status it doesn't list. A runner that
// retries such a read a few times first keeps the contract, so the check waits its ordinary per-step bound for it.
it("passes 4b for a runner that retries a failed read three times, 1.5 s apart, before it permits", async () => {
@@ -135,6 +180,15 @@ describe.skipIf(!hasTmux)("the runner conformance check", () => {
expect(code).toBe(0);
}, 120_000);
// A runner may report idle at startup, before any turn: that's neither an idle report while an ask waits (4b) nor one
// for a turn the agent died in (7), even when its POST arrives after the agent's exit.
it("passes 4b and 7 for a runner that reports idle at startup, even when that report arrives late", async () => {
const { code, report } = await check(["--cases", "4b,7"], " --startup-idle 1500");
expect(failing(report)).toEqual([]);
expect(report.results.map((r) => [r.id, r.status])).toEqual([["4b", "pass"], ["7", "pass"]]);
expect(code).toBe(0);
}, 150_000);
// The contract doesn't order a turn's Stop and its ask's withdraw. A runner that posts the Stop first keeps it.
it("passes 4b for a runner that withdraws a cancelled or ended turn's ask after posting its Stop", async () => {
const { code, report } = await check(["--cases", "4b"], " --withdraw-after-stop");
@@ -214,14 +214,37 @@ describe("a runner seat's answered permission path", () => {
});
it.each([
["a typed yes", { decision: "Yes." }, "answered", "allow"],
["a typed no", { decision: "no" }, "answered", "reject"],
["typed words that are neither", { decision: "what does this edit do?" }, "permitted", "allow"],
])("reads %s from the person as the offered option, and no answer as the default", async (_case, choice, state, optionId) => {
["a typed yes", { decision: "Yes." }, "answered", "allow", undefined],
["a typed no", { decision: "no" }, "answered", "reject", undefined],
["typed words that are neither", { decision: "what does this edit do?" }, "permitted", "allow", "what does this edit do?"],
])("reads %s from the person as the offered option, and no answer as the default", async (_case, choice, state, optionId, said) => {
const f = fixture();
const askId = String((await f.submit()).body.askId);
expect(await f.answer(askId, choice)).toMatchObject({ status: 200 });
expect(await f.read(askId)).toMatchObject({ body: { state, optionId } });
const read = await f.read(askId);
expect(read).toMatchObject({ body: { state, optionId } });
// The person's own words reach the runner beside the default, never in place of an option.
expect(read.body.said).toBe(said);
});
it("carries the person's words with the refusal when the agent offered no allow option, never reading them as a no", async () => {
const f = fixture();
const askId = String((await f.submit({ options: [{ optionId: "reject", name: "Reject", kind: "reject_once" },
{ optionId: "never", name: "Never", kind: "reject_always" }] })).body.askId);
expect(await f.answer(askId, { decision: "use the other file instead" })).toMatchObject({ status: 200 });
expect((await f.read(askId)).body).toEqual({ ok: true, state: "refused", reason: "no_allow_offered", said: "use the other file instead" });
});
it("carries a colleague's words, whole, when the ask was routed to them", async () => {
const f = fixture();
const askId = String((await f.submit()).body.askId);
await f.contract.act({ verb: "route", qitemId: askId, actorSession: PERSON, destinationSession: OTHER_PERSON, reason: "Dana decides", notify: false } as never);
const colleague = f.queueRepo.list({ limit: 100 }).find((row) => row.destinationSession === OTHER_PERSON && row.handedOffFrom === askId)!;
const words = `only the tests folder; ${"and nothing else. ".repeat(200)}`;
expect(await f.answer(colleague.qitemId, { decision: words }, OTHER_PERSON)).toMatchObject({ status: 200 });
const read = await f.read(askId, 1);
expect(read.body).toMatchObject({ state: "permitted", optionId: "allow" });
expect(read.body.said).toBe(words.trim());
});
it.each([
@@ -0,0 +1,335 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
import { SeatActivityService, HOOK_AUTHORITY_WINDOW_MS } from "../src/domain/seat-activity-service.js";
import { activityRoutes, restoredRunnerHookEvidence } from "../src/routes/activity.js";
import { runtimeRungInventory } from "../src/domain/activity-taxonomy.js";
import { RunnerCore, type RunnerIo } from "../src/adapters/pi-runner.js";
// The runner contract's working beat (section 4): `active` / `liveness` keeps a long, quiet turn reading working while
// the runner says its prompt is outstanding and its agent alive. A beat counts only for the live generation's latest
// accepted turn that hasn't ended, with a sequence above every earlier post's, and never while attention is
// outstanding; it lasts its livenessSeconds, clamped to 5-60 s (30 s without one), then the seat reads unknown again.
// Every test posts what a runner posts, through the real route, recorder and activity service.
const cleanups: Array<() => void> = [];
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
function fixture() {
const runtime = "pi";
const clock = { now: Date.parse("2026-11-01T12:00:00.000Z") };
const db = createFullTestDb();
cleanups.push(() => db.close());
const rigs = new RigRepository(db);
const rig = rigs.createRig("working-liveness");
const node = rigs.addNode(rig.id, "dev.a", { runtime });
const sessions = new SessionRegistry(db);
const oldSession = sessions.registerSession(node.id, "dev-a-old@working-liveness");
const oldGeneration = sessions.currentOccupantTenure(node.id)!.generationUuid;
sessions.updateStatus(oldSession.id, "exited");
const session = sessions.registerSession(node.id, "dev-a@working-liveness", "handover");
sessions.updateStatus(session.id, "running");
const eventBus = new EventBus(db);
const store = new AgentActivityStore({ db, eventBus, now: () => new Date(clock.now),
resolveOccupantGeneration: id => sessions.currentOccupantTenure(id)?.generationUuid ?? null,
isRegisteredOccupantGeneration: (id, generation) => sessions.isOccupantGenerationRegistered(id, generation) });
const newOracle = () => {
const service = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null }, defaultWindowSeconds: 3,
now: () => new Date(clock.now), restoreHookEvidence: (seat, seatRuntime) => restoredRunnerHookEvidence(store, seat, seatRuntime) });
service.declareRungInventory({ seatNodeId: node.id, sessionName: session.sessionName }, runtimeRungInventory(runtime));
return service;
};
let oracle = newOracle();
let app = new Hono();
const mount = (routes: typeof activityRoutes) => {
app = new Hono();
app.use("*", async (c, next) => {
c.set("agentActivityStore" as never, store as never);
c.set("activityHookToken" as never, "fixture-token" as never);
c.set("seatActivityService" as never, oracle as never);
await next();
});
app.route("/api/activity", routes);
};
mount(activityRoutes);
let generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
async function post(hookEvent: string, subtype: string | null, over: Record<string, unknown> = {}) {
const response = await app.request("/api/activity/hooks", { method: "POST",
headers: { authorization: "Bearer fixture-token", "Content-Type": "application/json" },
body: JSON.stringify({ sessionName: session.sessionName, nodeId: node.id, generation, runtime, hookEvent, subtype,
occurredAt: new Date(clock.now).toISOString(), ...over }) });
expect(response.status).toBe(200);
return await response.json() as { ok: boolean; ignored?: string };
}
let sequence = 0;
const next = () => ++sequence;
const accepted = (turnId: string) => post("UserPromptSubmit", "accepted", { turnId, sequence: next() });
const beat = (turnId: string, over: Record<string, unknown> = {}) =>
post("active", "liveness", { turnId, sequence: next(), livenessSeconds: 30, ...over });
const state = () => oracle.getSeatState(node.id)!;
const later = (ms: number) => { clock.now += ms; };
const at = (skewMs = 0) => new Date(clock.now + skewMs).toISOString();
// A daemon restart: a new activity service takes the seat's last recorded hook, as its reconcile pass does.
const restart = () => {
oracle = newOracle();
const evidence = restoredRunnerHookEvidence(store, { seatNodeId: node.id, sessionName: session.sessionName }, runtime);
if (evidence) oracle.reportEvidence({ ...evidence, seq: 0 });
};
// The same, with what the route module held in memory lost too, its working-liveness gate among it.
const restartDaemon = async () => {
vi.resetModules();
mount((await import("../src/routes/activity.js")).activityRoutes);
restart();
};
// `rig seat clear-attention`: the operator's word that the prompt is gone, outside the hook route.
const clearAttention = () => {
store.recordHookEvent({ sessionName: session.sessionName, runtime, generation, hookEvent: "PermissionPromptCleared" });
oracle.settlePermissionPrompt(session.sessionName);
};
// A same-name replacement: a new occupant takes the seat and keeps its session name.
const replaceOccupant = () => {
const previous = generation;
sessions.updateStatus(session.id, "exited");
const replacement = sessions.registerSession(node.id, session.sessionName, "handover");
sessions.updateStatus(replacement.id, "running");
generation = sessions.currentOccupantTenure(node.id)!.generationUuid;
return { previous, current: generation };
};
return { post, accepted, beat, next, state, later, at, restart, restartDaemon, clearAttention, replaceOccupant, oldGeneration, runtime,
sessionName: session.sessionName, nodeId: node.id, generation: () => generation };
}
describe("the runner's working beat", () => {
it("keeps a long silent turn reading working while beats keep coming, then unknown, never idle, once they stop", async () => {
const f = fixture();
await f.accepted("1");
for (let i = 0; i < 8; i++) {
f.later(12_000);
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000); // past the 15 s a bare working post has, inside the beat's 30 s
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
f.later(-(HOOK_AUTHORITY_WINDOW_MS + 5_000));
}
f.later(31_000); // the last beat's 30 s are up
expect(f.state()).toMatchObject({ activity: "unknown", decidedBy: null });
});
it("lasts its livenessSeconds, clamped to 5-60 s, and 30 s when the beat has none", async () => {
for (const [given, lasts] of [[600, 60], [1, 5], [undefined, 30], [20, 20]] as const) {
const f = fixture();
await f.accepted("1");
await f.beat("1", given === undefined ? { livenessSeconds: undefined } : { livenessSeconds: given });
f.later(lasts * 1000 - 500);
expect(f.state().activity, `livenessSeconds ${given} still counts at ${lasts} s`).toBe("working");
f.later(1_000);
expect(f.state().activity, `livenessSeconds ${given} has lapsed after ${lasts} s`).toBe("unknown");
}
});
it("is ignored after its turn's Stop, for an earlier turn, out of order, or with no sequence", async () => {
const f = fixture();
await f.accepted("1");
await f.post("Stop", "end_turn", { sequence: f.next(), livenessSeconds: 180 });
expect(await f.beat("1")).toMatchObject({ ok: true, ignored: "turn_ended" });
expect(f.state()).toMatchObject({ activity: "idle-at-prompt" });
await f.accepted("2");
expect(await f.beat("1")).toMatchObject({ ignored: "wrong_turn" });
expect(await f.beat("2", { sequence: 2 })).toMatchObject({ ignored: "out_of_order" });
expect(await f.beat("2", { sequence: undefined })).toMatchObject({ ignored: "no_sequence" });
f.later(HOOK_AUTHORITY_WINDOW_MS + 1_000);
expect(f.state()).toMatchObject({ activity: "unknown" }); // none of them kept turn 2 working
});
it("is ignored before any accepted turn, and from the previous occupant's generation", async () => {
const f = fixture();
expect(await f.beat("1")).toMatchObject({ ignored: "no_turn" });
await f.accepted("1");
expect(await f.beat("1", { generation: f.oldGeneration })).toMatchObject({ ignored: "stale_generation" });
f.later(HOOK_AUTHORITY_WINDOW_MS + 1_000);
expect(f.state()).toMatchObject({ activity: "unknown" });
});
it("never clears attention; after the runner's permission_resolved, the same turn's beats count again", async () => {
const f = fixture();
await f.accepted("1");
// OpenRig records the seat's permission prompt when it opens an ask, with no runner sequence.
await f.post("Notification", "permission_prompt", { turnId: "1" });
const delayed = f.next(); // a beat posted before the ask, arriving after it
expect(await f.beat("1", { sequence: delayed })).toMatchObject({ ignored: "attention_outstanding" });
expect(f.state().needsInput).toMatchObject({ count: 1 });
// The answer is delivered and the agent goes on with turn 1.
await f.post("active", "permission_resolved", { turnId: "1", askId: "ask-1", sequence: f.next() });
expect(f.state()).toMatchObject({ activity: "working", needsInput: { count: 0 } });
expect(await f.beat("1", { sequence: delayed })).toMatchObject({ ignored: "out_of_order" });
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
expect(f.state()).toMatchObject({ activity: "working", needsInput: { count: 0 } });
});
it("is ignored after the agent's exit", async () => {
const f = fixture();
await f.accepted("1");
await f.post("Stop", `${f.runtime}_exited`, { sequence: f.next() });
expect(await f.beat("1")).toMatchObject({ ignored: "turn_ended" });
expect(f.state()).toMatchObject({ activity: "unknown" });
});
it("isn't ended by an earlier turn's Stop that arrives after the next accepted turn", async () => {
const f = fixture();
await f.accepted("A");
f.later(1_000);
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at() }; // posted now, delivered late
f.later(1_000);
await f.accepted("B");
await f.post("Stop", "end_turn", stopA);
expect(await f.beat("B")).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
expect(f.state()).toMatchObject({ activity: "working" });
});
it("orders a beat sent without occurredAt by the time it was recorded, so a late older Stop still doesn't end the turn", async () => {
const f = fixture();
await f.accepted("A");
f.later(1_000);
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at() }; // posted now, delivered late
f.later(1_000);
await f.accepted("B");
f.later(1_000);
expect(await f.beat("B", { occurredAt: undefined })).toEqual({ ok: true, activity: expect.anything() });
await f.post("Stop", "end_turn", stopA);
expect(await f.beat("B")).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
expect(f.state()).toMatchObject({ activity: "working" });
});
it("doesn't take an undated beat's arrival as newer when the daemon's clock runs behind the runner's", async () => {
const f = fixture();
const ahead = 3_000; // the runner's clock runs 3 s ahead of the daemon's
await f.post("UserPromptSubmit", "accepted", { turnId: "A", sequence: f.next(), occurredAt: f.at(ahead) });
f.later(500);
const stopA = { turnId: "A", sequence: f.next(), occurredAt: f.at(ahead) }; // posted now, delivered late
f.later(500);
await f.post("UserPromptSubmit", "accepted", { turnId: "B", sequence: f.next(), occurredAt: f.at(ahead) });
f.later(500);
await f.beat("B", { occurredAt: undefined }); // recorded at the daemon's time, which reads before B's accepted
await f.post("Stop", "end_turn", stopA);
expect(await f.beat("B", { occurredAt: f.at(ahead) })).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
expect(f.state()).toMatchObject({ activity: "working" });
});
it("counts again after an operator clears the seat's attention", async () => {
const f = fixture();
await f.accepted("1");
await f.post("Notification", "permission_prompt", { turnId: "1" });
expect(await f.beat("1")).toMatchObject({ ignored: "attention_outstanding" });
f.clearAttention();
expect(f.state().needsInput).toMatchObject({ count: 0 });
expect(await f.beat("1")).toEqual({ ok: true, activity: expect.anything() });
f.later(HOOK_AUTHORITY_WINDOW_MS + 5_000);
expect(f.state()).toMatchObject({ activity: "working" });
});
it("keeps its admitted lifetime across a daemon restart", async () => {
for (const [given, lasts] of [[6000, 60], [5, 5]] as const) {
const f = fixture();
await f.accepted("1");
await f.beat("1", { livenessSeconds: given });
f.restart();
f.later(lasts * 1000 - 500);
expect(f.state().activity, `a restored livenessSeconds ${given} beat still counts at ${lasts} s`).toBe("working");
f.later(1_000);
expect(f.state().activity, `a restored livenessSeconds ${given} beat has lapsed after ${lasts} s`).toBe("unknown");
}
});
it("is ignored with no generation, and from the previous occupant after a same-name replacement", async () => {
const f = fixture();
await f.accepted("1");
expect(await f.beat("1", { generation: undefined })).toMatchObject({ ignored: "stale_generation" });
const { previous, current } = f.replaceOccupant();
// The previous occupant's beat, and the new occupant's beat for the turn its predecessor opened.
expect(await f.beat("1", { generation: previous })).toMatchObject({ ignored: "stale_generation" });
expect(await f.beat("1", { generation: current })).toMatchObject({ ignored: "stale_generation" });
});
it("is ignored for a turn whose accepted prompt carried no generation", async () => {
const f = fixture();
await f.post("UserPromptSubmit", "accepted", { turnId: "1", sequence: f.next(), generation: undefined });
expect(await f.beat("1")).toMatchObject({ ignored: "stale_generation" });
});
// Pi's stream beat (#2336) on the contract's beat: the real Pi runner core, its posts sent through the real route.
it("keeps a real Pi runner's long, quiet run reading working, until its agent_end", async () => {
const f = fixture();
const sent: Array<Promise<unknown>> = [];
const io: RunnerIo = {
sendRpc: () => {}, mirrorLine: () => {}, mirrorAppend: () => {}, writeSidecar: () => {}, now: () => f.at(),
// Activity posts only: the session-identity post goes to a recorder this fixture doesn't have.
postActivity: (payload) => { if (!payload.eventFamily) sent.push(f.post(String(payload.hookEvent), payload.subtype as string | null, payload)); },
};
const core = new RunnerCore(io, { sessionName: f.sessionName, nodeId: f.nodeId, launchId: "launch-1", generation: f.generation() });
core.start();
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: "/pi/session.jsonl", sessionId: "pi-1" } }));
core.handlePiLine(JSON.stringify({ type: "agent_start" }));
await Promise.all(sent);
for (let i = 0; i < 12; i++) { // a minute with nothing from the model, the runner's 5 s tick beating
f.later(5_000);
core.heartbeat();
await Promise.all(sent);
expect(f.state(), `${(i + 1) * 5} s in`).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
}
core.handlePiLine(JSON.stringify({ type: "agent_end" }));
await Promise.all(sent);
expect(f.state()).toMatchObject({ activity: "idle-at-prompt" });
});
// After a daemon restart mid-run its gate has no turn, so the run's beats are ignored until the runner's next accepted
// prompt (section 4's restart rule). A prompt or a follow-up submitted into the open run is that prompt.
it("counts a real Pi runner's beats again after a daemon restart, from its next prompt or follow-up into the open run", async () => {
const f = fixture();
const posts: Array<{ payload: Record<string, unknown>; result: Promise<{ ok: boolean; ignored?: string }> }> = [];
const io: RunnerIo = {
sendRpc: () => {}, mirrorLine: () => {}, mirrorAppend: () => {}, writeSidecar: () => {}, now: () => f.at(),
postActivity: (payload) => {
if (!payload.eventFamily) posts.push({ payload, result: f.post(String(payload.hookEvent), payload.subtype as string | null, payload) });
},
};
const settled = () => Promise.all(posts.map(p => p.result));
const lastBeat = async () => {
const beat = posts.filter(p => p.payload.subtype === "liveness").at(-1)!;
return { turnId: beat.payload.turnId, ...await beat.result };
};
const core = new RunnerCore(io, { sessionName: f.sessionName, nodeId: f.nodeId, launchId: "launch-1", generation: f.generation() });
core.start();
core.handlePiLine(JSON.stringify({ type: "response", id: "pi-runner-get-state", data: { sessionFile: "/pi/session.jsonl", sessionId: "pi-1" } }));
core.handlePiLine(JSON.stringify({ type: "agent_start" }));
await settled();
await f.restartDaemon();
f.later(5_000); core.heartbeat(); await settled();
expect(await lastBeat()).toMatchObject({ turnId: "1", ignored: "no_turn" });
for (const [block, turnId] of [["look at b.txt too", "2"], ["/followup then c.txt", "3"]] as const) {
core.handleUserBlock(block);
for (let i = 0; i < 6; i++) { // half a minute with nothing from the model
f.later(5_000); core.heartbeat(); await settled();
const beat = await lastBeat();
expect(beat, `${block}: ${(i + 1) * 5} s in`).toMatchObject({ turnId, ok: true });
expect(beat.ignored).toBeUndefined();
expect(f.state(), `${block}: ${(i + 1) * 5} s in`).toMatchObject({ activity: "working", decidedBy: "lifecycle-hooks" });
}
}
});
it("leaves a runner that sends no beats as before: a quiet turn falls to unknown after 15 s", async () => {
const f = fixture();
await f.accepted("1");
f.later(HOOK_AUTHORITY_WINDOW_MS - 1_000);
expect(f.state()).toMatchObject({ activity: "working" });
f.later(2_000);
expect(f.state()).toMatchObject({ activity: "unknown" });
});
});
@@ -570,6 +570,12 @@ describe("live seat runtime switch", () => {
expect(result).toMatchObject({ ok: false, code: "runtime_switch_unsupported" });
expect((result as { message: string }).message).toMatch(/only a single-window, single-pane seat can/);
});
it("a seat with no bound pane says how to get it on the other runtime, and what that route doesn't carry", async () => {
db.prepare("UPDATE bindings SET tmux_pane = NULL WHERE node_id = ?").run(nodeId);
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported",
message: expect.stringMatching(/it has no bound tmux pane/),
guidance: expect.stringMatching(/start it first.*never started.*declare the other runtime.*rig spec.*doesn't carry this seat's edges, settings or queued work/s) });
});
it("a seat on another runtime", async () => {
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
+24 -1
View File
@@ -3,7 +3,9 @@ import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { startupRevision } from "../src/routes/startup.js";
import { defaultProbeRuntimes } from "../src/domain/kernel-boot.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { verifyCodexPaneProcess, verifyPaneIdleShell } from "../src/domain/native-process-lineage.js";
vi.mock("../src/domain/native-process-lineage.js", async (original) => {
const actual = await original<typeof import("../src/domain/native-process-lineage.js")>();
@@ -42,6 +44,27 @@ describe("startup consent and effect boundary", () => {
const second = await request();
expect(await second.json()).toMatchObject({ ok: true, rigId: body.rigId, reused: true });
});
it("prepares a new kernel in the workspace's own kernel folder, outside its projects", async () => {
const workspace = mkdtempSync(join(tmpdir(), "startup-kernel-ws-"));
const saved = process.env["OPENRIG_WORKSPACE_ROOT"];
process.env["OPENRIG_WORKSPACE_ROOT"] = workspace;
try {
setup = createTestApp(db, { podInstantiatorFsOps: { exists: existsSync, readFile: (path) => readFileSync(path, "utf8") } });
const response = await setup.app.request("/api/startup/kernel", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runtime: "claude-code" }) });
const body = await response.json();
expect(body).toMatchObject({ ok: true, rigId: expect.any(String) });
// Every seat works in `<workspace.root>/kernel`, beside `<workspace.root>/projects` rather than above it, so the
// guidance a seat writes in its directory isn't read by project seats. Nothing else is written in the workspace.
const cwds = setup.rigRepo.getRig(body.rigId)!.nodes.map((node) => node.cwd);
expect(new Set(cwds)).toEqual(new Set([join(workspace, "kernel")]));
expect(statSync(join(workspace, "kernel")).isDirectory()).toBe(true);
expect(readdirSync(workspace)).toEqual(["kernel"]);
} finally {
if (saved === undefined) delete process.env["OPENRIG_WORKSPACE_ROOT"]; else process.env["OPENRIG_WORKSPACE_ROOT"] = saved;
rmSync(workspace, { recursive: true, force: true });
}
});
it("rejects a changed occupant or model before invoking fresh launch", async () => {
const { rig, node } = seat(); const revision = startupRevision(db, node);
db.prepare("UPDATE nodes SET model = ? WHERE id = ?").run("changed-model", node.id);
@@ -464,6 +464,20 @@ describe("TranscriptStore", () => {
});
describe("grep", () => {
it.each(["\n", ""])("preserves long matching lines across chunks (ending %j)", (ending) => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
store.ensureTranscriptDir("my-rig");
// Numbered sections catch lost or reordered chunks; multibyte characters
// straddle the reader's 64 KiB boundaries.
const long = Array.from({ length: 10000 }, (_, i) => `${i}:é中😀`).join("|");
const first = `MATCH first ${long}`;
const last = `MATCH last ${long}`;
writeFileSync(store.getTranscriptPath("my-rig", "dev@my-rig"),
`ignored\n\x1b[32m${first}\x1b[0m\nignored ${long}\nMATCH short\n${last}${ending}`);
expect(store.grep("my-rig", "dev@my-rig", "^MATCH")).toEqual([first, "MATCH short", last]);
expect(store.grep("my-rig", "dev@my-rig", "not present")).toEqual([]);
});
it("returns matching lines with ANSI stripped", () => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir });
store.ensureTranscriptDir("my-rig");
@@ -349,6 +349,36 @@ describe("transport routes", () => {
}
});
it("a header-LESS broadcast carries the delivered-unsigned notice only when something was sent", async () => {
seedRig();
const post = (body: Record<string, unknown>) => sendApp().request("/api/transport/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" }, // no X-OpenRig-Session
body: JSON.stringify(body),
});
const missed = (await (await post({ rig: "no-such-rig", text: "hi" })).json()) as { sent: number; warning?: string };
expect(missed.sent).toBe(0);
expect(missed.warning).toBeUndefined();
const sent = (await (await post({ rig: "my-rig", text: "hi" })).json()) as { sent: number; warning?: string };
expect(sent.sent).toBeGreaterThan(0);
expect(sent.warning).toContain("Delivered without sender identity");
});
it.each([
[{ total: 2, sent: 0, retained: 2, failed: 0, results: [] }, "Held for delivery without sender identity"],
[{ total: 2, sent: 1, retained: 1, failed: 0, results: [] }, "Delivered without sender identity"],
])("a header-LESS broadcast still advises signing when recipients retain the text (%#)", async (result, notice) => {
const app = createApp({ sessionTransport: { broadcast: async () => result } as unknown as SessionTransport });
const res = await app.request("/api/transport/broadcast", {
method: "POST",
headers: { "Content-Type": "application/json" }, // no X-OpenRig-Session
body: JSON.stringify({ rig: "my-rig", text: "hi" }),
});
const body = (await res.json()) as { warning?: string };
expect(body.warning).toContain(notice);
expect(body.warning).toContain("Follow up and sign it");
});
it("A3b — a header-LESS broadcast auto-records NO rows (no derived sender to attribute)", async () => {
seedRig();
const res = await sendApp().request("/api/transport/broadcast", {
@@ -96,3 +96,36 @@ describe("validateWorkspaceFrontmatter (PL-007)", () => {
expect(fs.readFileSync(path.join(dir, "a.md"), "utf-8")).toBe(content);
});
});
describe("malformed frontmatter structure", () => {
it.each(["\n", "\r\n"])("reports an unclosed block with %j line endings even in optional mode", (newline) => {
const content = ["---", "doc: alpha", "status: active", "created: 2026-05-04", "owner: someone", ""].join(newline);
write("broken.md", content);
for (const requireFrontmatter of [false, true]) {
const report = validateWorkspaceFrontmatter({ root: dir, requireFrontmatter });
expect(report.filesWithFrontmatter).toBe(1);
expect(report.gaps).toMatchObject([{ relativePath: "broken.md", kind: "parse-error", field: null }]);
expect(report.gapCount).toBe(1);
}
expect(fs.readFileSync(path.join(dir, "broken.md"), "utf8")).toBe(content);
});
it.each(["[]", "- doc: alpha", "- alpha\n- beta"])("reports a YAML sequence as a parse error: %s", (body) => {
write("array.md", `---\n${body}\n---\n`);
for (const workspaceKind of [undefined, "knowledge"] as const) {
const report = validateWorkspaceFrontmatter({ root: dir, workspaceKind });
expect(report.filesWithFrontmatter).toBe(1);
expect(report.gaps).toMatchObject([{ kind: "parse-error", field: null }]);
expect(report.gapCount).toBe(1);
}
});
it("keeps a closing delimiter at EOF valid and skips ordinary Markdown", () => {
write("valid.md", "---\ndoc: alpha\n---");
write("plain.md", "# Notes\n---\nA horizontal rule.\n");
write("prefix.md", "----\nNot a frontmatter opener.\n");
const report = validateWorkspaceFrontmatter({ root: dir, workspaceKind: "project" });
expect(report).toMatchObject({ totalFiles: 3, filesWithFrontmatter: 1, gapCount: 0 });
});
});
@@ -45,6 +45,23 @@ describe("workspace HTTP routes (PL-007)", () => {
expect(body.gaps[0]?.field).toBe("owner");
});
it("reports malformed structure without requiring frontmatter or a workspace kind", async () => {
fs.writeFileSync(path.join(dir, "unclosed.md"), "---\ndoc: alpha\n");
fs.writeFileSync(path.join(dir, "sequence.md"), "---\n- doc: alpha\n---\n");
const res = await app.request("/api/workspace/validate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ root: dir }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toMatchObject({ totalFiles: 2, filesWithFrontmatter: 2, gapCount: 2 });
expect(body.gaps).toEqual(expect.arrayContaining([
expect.objectContaining({ relativePath: "unclosed.md", kind: "parse-error", field: null }),
expect.objectContaining({ relativePath: "sequence.md", kind: "parse-error", field: null }),
]));
});
it("POST /validate rejects missing root with 400", async () => {
const res = await app.request("/api/workspace/validate", {
method: "POST",
+3 -4
View File
@@ -386,11 +386,10 @@ export class DaemonClient {
}
/** Recent terminal transitions (JUST FINISHED) via the SAME shipped /list
* route with a COMMA multi-state ?state=done,handed-off (queue.ts splits
* stateRaw on ","). No fleet-wide finish-time transitions endpoint exists, so
* this is a bounded RECENT WINDOW fetched in ts_created order; the PULSE render
* re-sorts by tsUpdated DESC (finish time) for newest-first. NOT a new endpoint. */
* stateRaw on ","). sort=updated picks this bounded RECENT WINDOW by finish time,
* so an old item closed just now is in it. NOT a new endpoint. */
queueRecentlyFinished(limit = 20) {
return this.get(`/api/queue/list?state=done,handed-off&limit=${limit}`);
return this.get(`/api/queue/list?state=done,handed-off&sort=updated&limit=${limit}`);
}
/** A single qitem by id via the shipped GET /api/queue/:qitemId (queue.ts:864,
* returns the QueueItem or 404). Used for the BLOCKED ON AGENTS bounded per-row
+6 -16
View File
@@ -1,3 +1,4 @@
import { DEFAULT_TIME_ZONE, displayClock } from "../time.js";
import { isHumanSeatSessionRef } from "@openrig/daemon/attention";
// PULSE view data model (5.2 Wave B). A reusable, view-local model so the pulse
// row/section renderers (pulse/render-pulse.ts) can be shared — crash-cart rides
@@ -266,17 +267,6 @@ function parkedRows(
* in the header) — never a silent truncation (see [[pagination-terminates-on-data-not-budgets]]). */
const LANE_ROW_CAP = 5;
/** Absolute UTC clock label for a JUST FINISHED transition time (e.g. "11:44").
* UTC keeps it deterministic + TZ-independent; an unparseable stamp renders "". */
function hhmm(iso: string | null | undefined): string {
if (!iso) return "";
const t = Date.parse(iso);
if (Number.isNaN(t)) return "";
const d = new Date(t);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
/** The drill-in Action for a lane row keyed on a SEAT session. A seat resolvable
* in the topology drills to that AGENT (the shipped agent drill — recovers the
* full identity the compact lane label drops, parity with clicking the agent
@@ -316,17 +306,17 @@ function nowRows(seatActivity: SeatActivitySummary[], inProgress: QueueRead[], s
}
/** ✓ JUST FINISHED rows — recent terminal transitions newest-FINISHED-first.
* The shipped /list read serves ts_created order, so we re-sort by tsUpdated
* The /list read picks the most recently updated rows; we sort them by tsUpdated
* DESC (the finish time) and cap to a recent window (count == rendered — this is
* inherently a WINDOW, not a total). Time = the transition's tsUpdated (HH:MM). */
function finishedRows(recent: QueueRead[], cap: number, snap: FleetSnapshot): PulseLaneRow[] {
function finishedRows(recent: QueueRead[], cap: number, snap: FleetSnapshot, timeZone: string): PulseLaneRow[] {
return [...recent]
.sort((a, b) => Date.parse(b.tsUpdated) - Date.parse(a.tsUpdated))
.slice(0, cap)
.map((q) => {
const label = q.summary ?? bodyHead(q.body);
// drill to the seat that FINISHED it (destinationSession → agent).
return { glyph: "✓", token: "ok" as Token, time: hhmm(q.tsUpdated), label, action: seatRowAction(snap, q.destinationSession, `${q.destinationSession} · ${label}`) };
return { glyph: "✓", token: "ok" as Token, time: displayClock(q.tsUpdated, timeZone), label, action: seatRowAction(snap, q.destinationSession, `${q.destinationSession} · ${label}`) };
});
}
@@ -358,7 +348,7 @@ function upNextLane(pending: QueueRead[], cap: number, snap: FleetSnapshot): Pul
* derived FROM the built model so header numbers can never diverge from their
* referent sets (label==referent). demoPulseModel is retained only as a static
* renderer fixture (spatial-contract tests), no longer this builder's source. */
export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now()): PulseModel {
export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now(), timeZone = DEFAULT_TIME_ZONE): PulseModel {
const exceptions: PulseExceptionSection[] = [];
const needs = needsRows(snap.attention, nowMs);
@@ -378,7 +368,7 @@ export function buildPulseModel(snap: FleetSnapshot, nowMs: number = Date.now())
if (blocked.length > 0) exceptions.push({ glyph: "⧗", token: PULSE_INFO_TOKEN, label: "BLOCKED ON AGENTS", rows: blocked });
const now = nowRows(snap.seatActivity, snap.inProgress, snap);
const finished = finishedRows(snap.recentlyFinished, LANE_ROW_CAP, snap);
const finished = finishedRows(snap.recentlyFinished, LANE_ROW_CAP, snap, timeZone);
const lanes: [PulseLane, PulseLane, PulseLane] = [
{ label: "NOW", count: now.length, rows: now },
{ label: "JUST FINISHED", count: finished.length, rows: finished },
+1 -1
View File
@@ -1568,7 +1568,7 @@ function renderPulseScreen(state: ViewState, snap: FleetSnapshot, options: Rende
lines.push(paneRule(cols, explW, "top", explorerTitle, contentTitle));
const contentWidth = Math.max(cols - explW - 2, 0);
const model = buildPulseModel(snap, nowMs);
const model = buildPulseModel(snap, nowMs, state.timeZone);
const chromeRows = 3; // bottom rule + hint bar + status line
const bodyRows = Math.max(rows - lines.length - chromeRows, 1);
+6
View File
@@ -26,6 +26,12 @@ export function displayTime(value: unknown, timeZone = DEFAULT_TIME_ZONE): strin
return `${p("year")}-${p("month")}-${p("day")} ${p("hour")}:${p("minute")}:${p("second")} ${p("timeZoneName")}${zone !== timeZone ? " (timezone fallback)" : ""}`;
}
/** displayTime's HH:MM, for a row with room only for a clock; "" when the time is unknown. */
export function displayClock(value: unknown, timeZone = DEFAULT_TIME_ZONE): string {
const time = displayTime(value, timeZone);
return time === "time unknown" ? "" : time.slice(11, 16);
}
export function resolveTimeZone(setting: unknown, readWarning = false): { timeZone: string; warning: string | null } {
const timeZone = validTimeZone(setting) ? setting : DEFAULT_TIME_ZONE;
return { timeZone, warning: validTimeZone(setting) && !readWarning ? null
+2 -3
View File
@@ -470,9 +470,8 @@ export interface FleetSnapshot {
* the render keeps only unclaimed (claimedAt null) and caps for display. */
pending: QueueRead[];
/** PULSE JUST FINISHED source (increment 3) — the shipped
* state=done,handed-off read (a bounded recent WINDOW; no fleet-wide
* finish-time-ordered transitions endpoint exists — the served order is
* ts_created, so the render re-sorts by tsUpdated DESC for newest-finished-first). */
* state=done,handed-off read with sort=updated (a bounded recent WINDOW of the
* most recently finished; the render sorts by tsUpdated DESC for newest-finished-first). */
recentlyFinished: QueueRead[];
/** When THIS snapshot finished hydrating (TUI render-time fact, not a daemon
* read) — the PULSE footer "updated Ns ago" freshness. Absent before the first
+5 -5
View File
@@ -160,7 +160,7 @@ const FIXTURES: Record<string, unknown> = {
"/api/queue/list?state=in-progress": [],
// PULSE ○ UP NEXT + ✓ JUST FINISHED lane sources (increment 3) — same /list route
"/api/queue/list?state=pending&limit=50": [],
"/api/queue/list?state=done,handed-off&limit=20": [],
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
"/api/queue/recent-transitions?scope=rig&rig=myrig&limit=20": [],
};
@@ -372,7 +372,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
});
it("hydrates the seat's recently finished items on an agent page so RECENTLY FINISHED can fill", async () => {
const row = (qitemId: string, state: string, destinationSession: string) => ({ qitemId, state, destinationSession, blockedOn: null, handedOffTo: null, tier: null, tags: [], summary: `work ${qitemId}`, body: "", claimedAt: null, tsUpdated: "2026-09-12T10:00:00.000Z" });
const finished = { "/api/queue/list?state=done,handed-off&limit=20": [row("qd1", "done", "dev-impl@myrig"), row("qd2", "done", "dev-qa@myrig")] };
const finished = { "/api/queue/list?state=done,handed-off&sort=updated&limit=20": [row("qd1", "done", "dev-impl@myrig"), row("qd2", "done", "dev-qa@myrig")] };
const page = async (drill: Record<string, unknown>, tab: "table" | "graph" = "table") => {
const calls: string[] = [];
const full = await hydrateSnapshot(fixtureClient());
@@ -384,7 +384,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
return { calls, snap, text: renderScreen(view.get(), snap, { cols: 220, rows: 120 }).lines.join("\n") };
};
const agent = await page({ resource: "agent", name: "dev.impl", target: { host: "mm2-openrig1", rig: "myrig", pod: "dev" } });
expect(agent.calls).toContain("/api/queue/list?state=done,handed-off&limit=20");
expect(agent.calls).toContain("/api/queue/list?state=done,handed-off&sort=updated&limit=20");
expect(agent.snap.recentlyFinished.map((q) => q.qitemId)).toEqual(["qd1", "qd2"]);
expect(agent.text).toContain("RECENTLY FINISHED · bounded window");
expect(agent.text).toContain("qd1 · done");
@@ -392,7 +392,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
// The rig table and graph draw no finished items, so they keep that read off.
for (const tab of ["table", "graph"] as const) {
const rig = await page({ resource: "rig", name: "myrig", target: { host: "mm2-openrig1" } }, tab);
expect(rig.calls).not.toContain("/api/queue/list?state=done,handed-off&limit=20");
expect(rig.calls).not.toContain("/api/queue/list?state=done,handed-off&sort=updated&limit=20");
}
});
it("marks health unavailable when the selected health view read fails", async () => {
@@ -814,7 +814,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
];
const snap = await hydrateSnapshot(fixtureClient({}, {
"/api/queue/list?state=pending&limit=50": pending,
"/api/queue/list?state=done,handed-off&limit=20": finished,
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": finished,
}));
expect(snap.pending.map((q) => q.qitemId)).toEqual(["qp1"]);
expect(snap.recentlyFinished.map((q) => q.qitemId)).toEqual(["qf1", "qf2"]);
@@ -280,7 +280,7 @@ describe("S12 view-aware hydration", () => {
"/api/queue/list?state=blocked": [],
"/api/queue/list?state=in-progress": [],
"/api/queue/list?state=pending&limit=50": [],
"/api/queue/list?state=done,handed-off&limit=20": [],
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
"/api/scopes?detail=1": { missions: [] },
"/api/views/execution": { rows: [] },
"/api/queue/recent-transitions?scope=instance&limit=20": [],
@@ -310,7 +310,7 @@ describe("S12 view-aware hydration", () => {
"/healthz": {}, "/api/queue/attention-aggregate": { hosts: [] }, "/api/rigs/summary": [],
"/api/review/fleet": { needsYou: { items: [] }, hosts: [] }, "/api/stream/list?limit=5&direction=latest": [],
"/api/queue/list?attention=1": [], "/api/queue/list?state=blocked": [], "/api/queue/list?state=in-progress": [],
"/api/queue/list?state=pending&limit=50": [], "/api/queue/list?state=done,handed-off&limit=20": [],
"/api/queue/list?state=pending&limit=50": [], "/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
"/api/scopes?detail=1": { missions: [] }, "/api/views/execution": { rows: [] },
};
return { ok: route in empty, status: route in empty ? 200 : 404, json: async () => empty[route] ?? {} } as Response;
+15 -2
View File
@@ -314,13 +314,13 @@ describe("PULSE view increment 3 — Lanes LIVE (NOW / JUST FINISHED / UP NEXT +
it("JUST FINISHED: recent done/handed-off newest-FINISHED-first (tsUpdated desc) with HH:MM times", () => {
const snap = liveSnap({
recentlyFinished: [
// served in ts_created order (NOT finish order) — the view re-sorts by tsUpdated
// any served order — the view sorts by tsUpdated
attn({ qitemId: "f1", state: "done", summary: "older close-out", tsUpdated: "2026-08-06T10:58:00.000Z" }),
attn({ qitemId: "f2", state: "handed-off", summary: "newest fold receipt", tsUpdated: "2026-08-06T11:44:00.000Z" }),
attn({ qitemId: "f3", state: "done", summary: "mid terminal CLEAR", tsUpdated: "2026-08-06T11:20:00.000Z" }),
],
});
const jf = buildPulseModel(snap, NOW).lanes[1];
const jf = buildPulseModel(snap, NOW, "UTC").lanes[1];
expect(jf.label).toBe("JUST FINISHED");
expect(jf.rows.map((r) => r.label)).toEqual(["newest fold receipt", "mid terminal CLEAR", "older close-out"]);
expect(jf.rows.map((r) => r.time)).toEqual(["11:44", "11:20", "10:58"]);
@@ -328,6 +328,19 @@ describe("PULSE view increment 3 — Lanes LIVE (NOW / JUST FINISHED / UP NEXT +
expect(jf.count).toBe(3);
});
it("JUST FINISHED: clocks read in the TUI's time zone, and an unreadable stamp shows none", () => {
const snap = liveSnap({
recentlyFinished: [
attn({ qitemId: "f1", state: "done", summary: "closed", tsUpdated: "2026-08-06T23:44:00.000Z" }),
attn({ qitemId: "f2", state: "done", summary: "unreadable", tsUpdated: "not a time" }),
],
});
const times = (zone: string) => Object.fromEntries(buildPulseModel(snap, NOW, zone).lanes[1].rows.map((r) => [r.label, r.time]));
expect(times("Asia/Kolkata")).toEqual({ closed: "05:14", unreadable: "" });
expect(times("America/Los_Angeles")).toEqual({ closed: "16:44", unreadable: "" });
expect(times("UTC")).toEqual({ closed: "23:44", unreadable: "" });
});
it("UP NEXT: pending in the SERVED order (verbatim — no client priority synthesis); beyond the cap shows a '…' overflow row with the TRUE total count", () => {
const pend = Array.from({ length: 6 }, (_, i) => attn({ qitemId: `p${i}`, state: "pending", summary: `pending item ${i}`, claimedAt: null }));
const un = buildPulseModel(liveSnap({ pending: pend }), NOW).lanes[2];
+9 -1
View File
@@ -63,8 +63,16 @@ describe("PULSE view (5.2 Wave B — increment 1: static skeleton from the appro
expect(iBlocked).toBeLessThan(iLanes);
});
it("draws JUST FINISHED clocks in the TUI's time zone", () => {
const v = createViewState({ instanceId: "t", timeZone: "Asia/Kolkata", ...withSnap });
v.dispatch({ type: "tab", tab: "pulse" });
const body = renderScreen(v.get(), snap, { cols: 140, rows: 44, nowMs: DEMO_NOW }).lines.join("\n");
expect(body).toContain("17:14");
expect(body).not.toContain("11:44");
});
it("builds the three lanes + footer LIVE from the demo snapshot (label==referent honesty)", () => {
const v = createViewState({ instanceId: "t", ...withSnap });
const v = createViewState({ instanceId: "t", timeZone: "UTC", ...withSnap });
v.dispatch({ type: "tab", tab: "pulse" });
const body = renderScreen(v.get(), snap, { cols: 140, rows: 44, nowMs: DEMO_NOW }).lines.join("\n");