Compare commits

..
Author SHA1 Message Date
Mike Schwarz c92b4f3c93 fix(delivery): ignore Claude's drawn cursor blank (#2933)
For #2925. On an idle Claude seat with the typing guard set to draft-aware, every automatic delivery was held as draft_input_busy while rig capture read the input box as empty. The guard's strict reader counted Claude's own drawn cursor, a single inverse-video blank at the start of the composer, as typed text; the capture annotation already trimmed it. The strict reader now recognises that one drawn blank at the start of a one-row Claude composer and reads the box as empty. Real draft text, ordinary spaces, a moved cursor and multi-row input keep the existing protection, and the explicit draft-aware mode's handling of unreadable input is unchanged. No new refusal or timeout. Reproduced in an isolated 68x31 tmux 3.6 pane with the reported frame: unchanged main held the send, and with the change the message submits once while a real draft stays untouched. It doesn't explain the reported delay of about fifteen minutes before the first hold.
2026-11-10 19:05:22 -08:00
Mike Schwarz 85b4ccf27a docs: clarify when seats receive new guidance (#2930)
Three corrections to the agent startup guide's section on how a running seat gets new guidance. A send can be retained rather than delivered, so the guide no longer promises next-turn delivery or relative order. A managed refocus after compaction waits for an eligible prompt, which may not be the immediate next one. And rig walk reads each named file and sends its contents, unlike a pointer to a file that the seat still has to read. Finding a file, a hook picking it up and the agent reading it stay separate facts. Docs only.
2026-11-10 18:37:30 -08:00
Mike Schwarz eb73d75edd refactor(daemon): resolve a seat's kind at one boundary for send and capture (#2932)
Five places in session-transport.ts (send, its wait path, capture, the activity probe and target resolution) each compared a seat's attachment type with external_cli on their own. A new seat-kinds.ts resolves the kind once: tmux (also for a legacy row with no value), external_cli, or unknown with the stored value, so an unknown value is named rather than silently read as tmux, while keeping today's tmux path. The five branches now ask it. No result, error text or refusal changes. It's the first step of one seat boundary for delivery evidence, and it builds on rlch's SeatKind facade and attachment key proposed in #1429, with credit.
2026-11-10 18:16:14 -08:00
Mike Schwarz 1f30867775 docs: say how queueCounts treats a row tagged with several slices (#2929)
For #2902's queueCounts. The slice-list section of the daemon HTTP clients reference now says how a row tagged with several slices or missions is counted. Its tags count only when they all name the same physical slice or mission (a slice's folder name and its frontmatter id, for example). A row that names two distinct slices, missions or projects counts nowhere in queueCounts, while the older qitemCount can include it under several tagged slices, subject to the existing project filter and the newest 500 rows per slice. Docs only.
2026-11-10 18:13:32 -08:00
Mike Schwarz e89031d211 test(refocus): skip mode-000 denial cases as root (#2928)
Follow-up to #2916. Two refocus-record test cases rely on chmod 000 causing EACCES, which root doesn't get. They now skip when process.getuid?.() === 0, with the reason in their names, following the existing root skip in refocus-notes-local.test.ts. The other eight cases still run, and non-root runs keep both. Test only.
2026-11-10 17:56:14 -08:00
Mike Schwarz 1d920e756a docs(setup): offer to register the person, and fix the empty-registry hint (#2924)
When setup finds no registered person, the installer skill and the operator's guidance now offer to register the person installing, in the agent's own conversation: rig gateway human add with --default, only on their yes. An existing default is kept and never replaced unless they ask; with several people the agent never picks one, and rig gateway default runs only on their choice. Nothing is registered silently. The empty-registry hint from rig gateway human list, which still asked for --binding, now names the current command, and the pinned copies of the skill change only by that string.
2026-11-10 17:54:32 -08:00
Mike Schwarz 0fbfbf75a3 fix(queue): describe update delivery without assuming Slack (#2927)
Follow-up to #2909. A queue create that marks an update to a person with --alert is refused, as before. The refusal still says to send it without --alert, and now says updates are notices whose delivery depends on the person's connector and settings, so it's true for Slack, a relay connector and a person with no connector. The refusal, its error code and every delivery path are unchanged; the existing exact assertion is updated.
2026-11-10 17:52:58 -08:00
Mike Schwarz 4fa7ee0ab6 feat(daemon): one default receiving agent for answers nothing else reads (#2923)
Some answers have no asking agent able to read them: a person's answer to a host pairing request, the notice when a scheduled mode ends, and Slack messages with no route. Each chose its own receiver. A new resolver gives one answer: agents.operator_session when it names an agent's session, otherwise the kernel operator when its seat exists, otherwise none. It reports where the choice came from and the receiver's state (running, stopped or unknown), and never picks another seat because one is stopped.

Pairing's owner, the scheduled mode end and Slack's unrouted destination now use it. A Slack inboundDestination saved in the config file is kept exactly as written, including one equal to the old default; only an absent field, or the explicit default-receiver choice, inherits from the resolver, and channel and thread routes still come first. When the setting is invalid or can't be read, pairing, mode end and Slack say so instead of calling it unset. rig config and the TUI's settings show the effective receiving agent, where it came from and its state.
2026-11-10 17:51:03 -08:00
43 changed files with 759 additions and 505 deletions

No files matched your search

+12 -10
View File
@@ -428,22 +428,24 @@ running seat only through the action in the last column.
### New guidance for a running seat
These reach a running seat without a restart, in the order they reach the agent:
There are three ways for a seat to pick up new guidance:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
hook's size threshold.
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
file, a hook picking up, and the agent reading and using it are separate facts.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
+6 -2
View File
@@ -218,5 +218,9 @@ an open FYI, or a closed ask is not that receipt. The rig-wide reads and their l
and every catalog project's missions root. A row may omit `project:` or `mission:` only when its remaining
tags identify exactly one physical slice with a resolved project and mission. Ambiguous matches count nowhere,
even if first-wins discovery or a list filter leaves only one row visible; an explicit mismatched project or
mission never counts. Counts are recomputed on every list request. `?refresh=1|true` invalidates the separate
listing cache, not a stored queue-count snapshot. This resolution does not change the returned slice list or filters.
mission never counts. Several `slice:` or `mission:` tags on one row count only when they all name the same
physical slice or mission (a slice's folder name and its frontmatter `id:`, say); a row that names two distinct
slices, missions or projects counts nowhere, while `qitemCount` can include it under several tagged slices,
subject to the existing project filter and the newest 500 rows per slice. Counts are recomputed on every list
request. `?refresh=1|true` invalidates the separate listing cache, not a stored queue-count snapshot. This
resolution does not change the returned slice list or filters.
+18
View File
@@ -76,6 +76,24 @@ go to:
`rig gateway default` shows the current default; `rig gateway default --clear` clears the choice. Choosing a default
never changes `workspace.operator_seat_name`, which keeps its own meaning.
## The default receiving agent
The default person is who OpenRig asks. The default receiving agent is the agent that reads what a person answers when
nothing else reads it: a request with no owner of its own, such as a host pairing request or a mode's scheduled end, and
a Slack message no thread or channel route claims. It is:
1. the agent chosen in `agents.operator_session` (`rig config set agents.operator_session <seat>@<rig>`; a person's
address there is ignored);
2. otherwise the kernel operator, `operator-agent@kernel`, when this host has that seat, running or stopped;
3. otherwise none, and the request says no agent reads answers on this host.
A stopped receiving agent stays the receiver: answers wait for it, and the person is told it isn't running. OpenRig
never picks whichever seat happens to be running. The TUI shows the effective agent, where it came from and its state
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
messages to the default receiving agent, read when the connector starts. A destination already saved in the connector
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
## In the TUI: whose queue, and who acts
The person at the terminal is the **primary user**. The TUI records their acts as unnamed (`human@host`),
+2 -2
View File
@@ -281,7 +281,7 @@ Examples:
.description("List the registered people, with delivery readiness and quiet hours (rig gateway participants adds organizations, groups, policy and shared channels)")
.option("--json", "Complete record(s) as JSON")
.action(async (opts: { json?: boolean }) => {
const { listHumans } = await import("@openrig/daemon/gateway-human-registry");
const { listHumans, NO_HUMAN_CONFIGURED } = await import("@openrig/daemon/gateway-human-registry");
const res = listHumans();
if (!res.ok) { console.error(`refused: ${res.error}`); process.exitCode = 1; return; }
const humans = await Promise.all(res.humans.map(async (record) => {
@@ -290,7 +290,7 @@ Examples:
quiet: quiet.ok ? quiet.quiet : { unknown: `quiet hours could not be read from the daemon: ${quiet.reason}` } };
}));
if (opts.json) { console.log(JSON.stringify({ ok: true, humans })); return; }
if (res.humans.length === 0) { console.log("no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"); return; }
if (res.humans.length === 0) { console.log(NO_HUMAN_CONFIGURED); return; }
for (const h of humans) {
const inbound = h.bindings.inboundResolvable || !h.bindings.primary ? "" : " [outbound-only]";
const primary = h.bindings.primary ? `primary ${h.bindings.primary.kind}:${h.bindings.primary.connectorRef}` : "no connector: Feed and Steering only";
+3 -2
View File
@@ -181,7 +181,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
.command("setup")
.description("Configure the connector (first-class config; secrets stay in the env file, never here)")
.option("--channel <id>", "Slack channel id the connector app must be a member of")
.option("--inbound-destination <session>", "where inbound human messages land (default operator-agent@kernel)")
.option("--inbound-destination <session>", "where a person's unrouted message lands: a seat's session, or default-receiver (the default) for the host's default receiving agent (agents.operator_session, else the kernel operator)")
.option("--minimum-level-that-posts <level>", "minimum OWNER level posted to Slack: RECORD|NOTICE|ALERT")
.option("--minimum-level-that-interrupts <level>", "minimum OWNER level that mentions/interrupts: RECORD|NOTICE|ALERT")
.option("--source-label <label>", "label shown in the posted message footer (where the queue lives)")
@@ -251,7 +251,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
for (const r of readiness) log(` ${r.ok ? "✓" : "✗"} ${r.label}: ${r.detail}`);
log(`Daemon: ${observation.state}${observation.reason ? ` (${observation.reason})` : ""}`);
const connector = observation.connector as { configurationDigest?: string; inboundReady?: boolean; outboundReady?: boolean;
inboundRouting?: { fallback: string; destination: string };
inboundRouting?: { fallback: string; destination: string; destinationFrom?: string; destinationNote?: string };
settlement?: { pending: number; lastFailure: string | null };
deadLetterBacklog?: number | null; deadLetterBacklogState?: string; deadLetterBacklogReason?: string; relayAttemptsInFlight?: number;
inbound?: InboundDeliveryStatus & { state?: string; generation?: number; lastEventAt?: string };
@@ -268,6 +268,7 @@ export function slackCommand(deps: SlackDeps = {}): Command {
if (connector.settlement.lastFailure) log(` Last ask-edit failure: ${connector.settlement.lastFailure}`);
}
if (connector.inboundRouting?.fallback === "local") log(` Configured routing fallback: local to ${connector.inboundRouting.destination} (after forwarding, thread and channel routes)`);
if (connector.inboundRouting?.destinationNote) log(` ${connector.inboundRouting.destinationFrom === "receiving-agent" ? "Default receiving agent" : "No receiving agent"}: ${connector.inboundRouting.destinationNote}`);
log(` Socket: ${connector.inbound?.state ?? "unknown"}; generation ${connector.inbound?.generation ?? "unknown"}; last event ${connector.inbound?.lastEventAt ?? "unknown"}`);
for (const line of socketDeliveryLines(connector.inbound)) log(` ${line}`);
const recovery = connector.recovery;
@@ -1,103 +0,0 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
function processExists(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === "ESRCH") return false;
throw error;
}
}
function acquire(stateFile) {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const base = `${stateFile}.guard`;
const token = `${process.pid}-${randomUUID()}`;
const until = performance.now() + 200;
const wait = new Int32Array(new SharedArrayBuffer(4));
let lock = base;
do {
try {
fs.symlinkSync(token, lock);
return () => {
try {
if (fs.readlinkSync(lock) === token) fs.unlinkSync(lock);
} catch {}
};
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
let owner;
try {
owner = fs.readlinkSync(lock);
} catch (error) {
if (error.code === "ENOENT") continue;
throw error;
}
const match = /^([1-9][0-9]*)-([0-9a-f-]{36})$/.exec(owner);
if (!match) throw new Error("unrecognized state guard owner");
if (!processExists(Number(match[1]))) {
// The owner might have released the path before exiting. Recheck its
// token after proving death; that same owner can no longer remove it.
try {
if (fs.readlinkSync(lock) === owner) lock = `${base}.${match[2]}`;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
// Keep the dead link. Contenders follow it to one shared successor
// without ever removing a guard another process might have acquired.
continue;
}
Atomics.wait(wait, 0, 0, 10);
} while (performance.now() < until);
throw new Error("state guard still busy");
}
function openRefocusState(stateFile) {
const report = (operation, error) =>
process.stderr.write(`refocus: could not ${operation} (${error.code || error.message})\n`);
let release;
try {
release = acquire(stateFile);
} catch (error) {
report("coordinate state. Continuing with available evidence", error);
}
function read(file) {
try {
const state = JSON.parse(fs.readFileSync(file, "utf8"));
if (state && typeof state === "object" && !Array.isArray(state)) return state;
throw new Error("expected a state object");
} catch (error) {
if (error.code !== "ENOENT") report("read state", error);
return null;
}
}
function write(file, state) {
const tmp = `${file}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
fs.renameSync(tmp, file);
return true;
} catch (error) {
try { fs.unlinkSync(tmp); } catch {}
report("record state", error);
return false;
}
}
return {
exclusive: Boolean(release),
close: release || (() => {}),
read: () => read(stateFile),
write: (state) => write(stateFile, state),
readDeferred: (kind) => read(`${stateFile}.${kind}-pending`),
writeDeferred: (kind, pending) => write(`${stateFile}.${kind}-pending`, pending),
};
}
module.exports = { openRefocusState, processExists };
@@ -12,7 +12,6 @@ const path = require("node:path");
const crypto = require("node:crypto");
const { spawnSync } = require("node:child_process");
const { recordRefocusResult } = require("./refocus-health.cjs");
const { openRefocusState, processExists } = require("./refocus-state.cjs");
// Keep the operator's command lookup order; env-node CLI scripts can also use this hook's Node.
const childEnv = { ...process.env, PATH: [process.env.PATH, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter) };
@@ -46,18 +45,6 @@ const lookupTimeout = (reserve = 0) => SELECTED_BUDGET ? Math.min(LOOKUP_CAP_MS,
// Normal managed restore advances on ~30 s polls with a 10 s idle wait.
// A lost daemon stage must not suppress this occupant's refocus indefinitely.
const MANAGED_RESTORE_HOLD_MS = 10 * 60_000;
const DELIVERY_CLAIM_MS = 60_000;
function liveDelivery(claim) {
if (!claim) return false;
const age = Date.now() - claim.at;
try {
if (Number.isSafeInteger(claim.pid) && claim.pid > 0
&& age >= 0 && age < DELIVERY_CLAIM_MS && processExists(claim.pid)) return true;
} catch {}
process.stderr.write("refocus: prior delivery owner could not be confirmed. Attempting the pending refocus\n");
return false;
}
function runtime() {
const index = process.argv.indexOf("--runtime");
@@ -386,6 +373,8 @@ function runsForThisSeatsProject(env = process.env) {
const seat = process.env.OPENRIG_SESSION_NAME || "unknown-seat";
const home = process.env.OPENRIG_HOME || path.join(process.env.HOME || "/tmp", ".openrig");
const transcriptPath = input.transcript_path || input.transcriptPath || "";
let size = 0;
try { size = fs.statSync(transcriptPath).size; } catch {}
const stateDir = path.join(home, "refocus");
@@ -445,139 +434,80 @@ function runsForThisSeatsProject(env = process.env) {
} catch {}
const stateFile = path.join(stateDir, `${seatKey}__${keyFor(identity)}.json`);
// Only the two state transitions need exclusive access. Trace rendering and
// stdout run outside the guard so an arriving PostCompact can record its work.
const start = openRefocusState(stateFile);
const promptId = firstString(input.prompt_id);
let state = null;
try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")) || null; } catch {}
const persist = () => {
try {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify(state));
} catch {}
};
if (state === null) {
// First observation for this occupant: the baseline is its OWN current size —
// growth accumulates from here; nothing is inherited. A zero-size read means
// the transcript is absent/unreadable, which is instrument absence, not a
// baseline: record zero only when that is what was genuinely measured.
state = { lastBytes: size, baselineAt: new Date().toISOString() };
persist();
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
// Shrink invalidates growth-based due state, not an exact PostCompact event
// awaiting its actionable restore turn.
if (state.pendingOn !== "PostCompact") {
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
}
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
state.lastBytes = size;
persist();
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
}
const lastBytes = Number(state.lastBytes || 0);
const grown = size > lastBytes ? size - lastBytes : 0;
const onDemand = enabled(process.env.OPENRIG_REFOCUS_NOW, false);
let state, size = 0, grown, claimed;
const claim = { id: crypto.randomUUID(), pid: process.pid, at: Date.now(), promptId };
try {
state = start.read();
try { size = fs.statSync(transcriptPath).size; } catch {}
if (state === null) {
// First observation for this occupant: the baseline is its OWN current size —
// growth accumulates from here; nothing is inherited. A zero-size read means
// the transcript is absent/unreadable, which is instrument absence, not a
// baseline: record zero only when that is what was genuinely measured.
state = { lastBytes: size, baselineAt: new Date().toISOString() };
if (start.exclusive) start.write(state);
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
// Shrink invalidates growth-based due state, not an exact PostCompact event
// awaiting its actionable restore turn.
if (state.pendingOn !== "PostCompact") {
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
delete state.pendingRevision;
}
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
state.lastBytes = size;
if (start.exclusive) start.write(state);
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
}
const thresholdDue = harness === "claude" && grown >= threshold();
const due = onDemand
|| event === "PostCompact"
|| Boolean(state.pendingOn)
|| thresholdDue;
if (!due) process.exit(0);
// An event that could not take the guard records its pending work separately.
// The delivery callback never writes these slots.
for (const kind of ["other", "compact"]) {
const deferred = start.readDeferred(kind);
if (!deferred?.pendingRevision || state.seenDeferred?.[kind] === deferred.pendingRevision) continue;
state.pendingRevision = deferred.pendingRevision;
if (kind === "compact" || !state.pendingOn) {
state.pendingOn = deferred.pendingOn;
state.pendingAt = deferred.pendingAt;
if (event !== "UserPromptSubmit") {
if (event === "PostCompact" && harness === "claude") {
// PreCompact records whether THIS compact was initiated by the enforcer.
// A manual /compact overwrites the marker with false; other occupants
// cannot hold this session. Read the early sentinel first if it survives.
state.managedRestorePending = false;
for (const suffix of [".expected.json", ".json"]) {
try {
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
state.managedRestorePending = marker.managedRefocusPending === true;
break;
} catch {}
}
if (kind === "compact") {
delete state.pendingFailure;
state.managedRestorePending = deferred.managedRestorePending;
state.managedRestorePendingAt = deferred.managedRestorePendingAt;
}
state.seenDeferred = { ...state.seenDeferred, [kind]: deferred.pendingRevision };
if (start.exclusive) start.write(state);
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
else delete state.managedRestorePendingAt;
}
const lastBytes = Number(state.lastBytes || 0);
grown = size > lastBytes ? size - lastBytes : 0;
const growthThreshold = threshold();
const thresholdDue = harness === "claude" && grown >= growthThreshold;
const due = onDemand
|| event === "PostCompact"
|| Boolean(state.pendingOn)
|| thresholdDue;
if (start.exclusive && event === "UserPromptSubmit" && promptId && state.lastPromptId === promptId) {
process.stderr.write("refocus: another handler already wrote refocus output for this prompt\n");
return;
}
if (!due) return;
if (event !== "UserPromptSubmit") {
if (event === "PostCompact" && harness === "claude") {
// PreCompact records whether THIS compact was initiated by the enforcer.
// A manual /compact overwrites the marker with false; other occupants
// cannot hold this session. Read the early sentinel first if it survives.
state.managedRestorePending = false;
for (const suffix of [".expected.json", ".json"]) {
try {
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
state.managedRestorePending = marker.managedRefocusPending === true;
break;
} catch {}
}
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
else delete state.managedRestorePendingAt;
}
// A new compaction is a fresh obligation, not a retry of the refocus before it.
const compact = event === "PostCompact";
if (compact) delete state.pendingFailure;
const newWork = compact || !state.pendingOn || onDemand
|| (thresholdDue && size - (state.deliveryClaim?.bytes ?? lastBytes) >= growthThreshold);
if (newWork) state.pendingRevision = crypto.randomUUID();
if (compact || !state.pendingOn) state.pendingOn = event;
state.pendingAt ||= new Date().toISOString();
if ((!start.exclusive || !start.write(state)) && newWork) {
start.writeDeferred(compact ? "compact" : "other", {
pendingOn: event,
pendingRevision: state.pendingRevision,
pendingAt: state.pendingAt,
...(compact ? {
managedRestorePending: state.managedRestorePending,
managedRestorePendingAt: state.managedRestorePendingAt,
} : {}),
});
}
return;
}
if (start.exclusive && liveDelivery(state.deliveryClaim)) {
if (onDemand && promptId && state.deliveryClaim.promptId && promptId !== state.deliveryClaim.promptId) {
const pending = {
pendingOn: state.pendingOn || event,
pendingAt: state.pendingAt || new Date().toISOString(),
pendingRevision: crypto.randomUUID(),
};
Object.assign(state, pending);
if (!start.write(state)) start.writeDeferred("other", pending);
}
process.stderr.write("refocus: another hook is delivering. Any later pending work stays queued\n");
return;
}
if (harness === "claude" && state.managedRestorePending) {
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
// Missing/invalid timestamps from older state, or a clock moving backwards,
// cannot establish a live hold. No timer or extra turn is created here.
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) return;
delete state.managedRestorePending;
delete state.managedRestorePendingAt;
}
state.pendingOn ||= event;
// A new compaction is a fresh obligation, not a retry of the refocus before it.
if (event === "PostCompact") delete state.pendingFailure;
if (event === "PostCompact" || !state.pendingOn) state.pendingOn = event;
state.pendingAt ||= new Date().toISOString();
state.deliveryClaim = { ...claim, bytes: size };
claimed = start.exclusive && start.write(state);
} finally {
start.close();
persist();
process.exit(0);
}
if (harness === "claude" && state.managedRestorePending) {
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
// Missing/invalid timestamps from older state, or a clock moving backwards,
// cannot establish a live hold. No timer or extra turn is created here.
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) process.exit(0);
delete state.managedRestorePending;
delete state.managedRestorePendingAt;
persist();
}
// Run the public trace before resolving a context ref. Besides keeping the
@@ -641,36 +571,22 @@ function runsForThisSeatsProject(env = process.env) {
// their fixed caps, so a default attempt can run past its 4.5 s budget).
recordRefocusResult({ home, seat, identity, failed: failed || Boolean(error), env: childEnv,
deadlineMs: Math.max(BUDGET_MS, Math.floor(process.uptime() * 1_000)) + REPORT_MS });
if (!claimed) return;
const finish = openRefocusState(stateFile);
try {
if (!finish.exclusive) return;
const latest = finish.read();
if (latest?.deliveryClaim?.id !== claim.id) return;
delete latest.deliveryClaim;
if (!error && promptId) latest.lastPromptId = promptId;
const samePending = latest.pendingRevision === state.pendingRevision;
if (failed || error) {
if (samePending) {
const failure = rendered.failure
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
latest.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
}
} else {
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
latest.firedAt = new Date().toISOString();
latest.firedOn = event;
if (samePending) {
delete latest.pendingOn;
delete latest.pendingAt;
delete latest.pendingFailure;
delete latest.pendingRevision;
}
}
finish.write(latest);
} finally {
finish.close();
if (failed || error) {
state.pendingOn ||= event;
state.pendingAt ||= new Date().toISOString();
const failure = rendered.failure
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
state.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
persist();
return;
}
if (size > 0) state.lastBytes = size;
state.firedAt = new Date().toISOString();
state.firedOn = event;
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
persist();
});
})();
@@ -145,6 +145,21 @@ person needs in their project or home folder and name that path.
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks go to them, and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
need a person to reach. Ask the name to use (their login name is only a suggestion). On yes, run
`rig gateway human add <id> --display-name "<name>" --delivery-class B --default`: they're reached in the TUI's Feed
and Steering, and Slack can be added later. On no or not now, register nothing and say that questions to a person
can't reach anyone until someone is registered, with that command for later.
- Never pass `--default` over an existing default, or `--replace`, unless they ask. Their own words decide: a
different name, Slack now, or not now.
- An agent reads their answers: the kernel operator, or the agent chosen in `agents.operator_session`. If the kernel
isn't running here and no agent is chosen, offer to choose the team's lead (`rig config set agents.operator_session
<seat>@<rig>`), on their yes only.
- A machine they'll pair with needs its own person: registering here doesn't give that machine one.
- **Give the operator the goal:** ask what they want worked on, in which repository and on which branch, unless they've
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
(never the logical ID or a guessed name) and send:
@@ -87,6 +87,19 @@ After install, the person usually talks to you first. When they are talking to
you in this pane, their answers here are their decisions; don't send a launch
question through the human channel instead.
If no person is registered yet (`rig gateway participants` shows `default: none`
and no people), offer to register the person you're talking to, as an installer
would: on their yes, `rig gateway human add <id> --display-name "<name>"
--delivery-class B --default`; on a no, register nothing and say that questions
to a person can't reach anyone until someone is. Keep an existing default, and
never pick one of several people yourself.
Unless `agents.operator_session` names another agent, you're this host's
receiving agent: what a person answers on a request nobody else owns, such as
another machine asking to pair, comes to you. Act on what they meant through
that request's own command (for pairing, `rig host approve <code>` or
`rig host deny <code>`), and ask back only when it isn't clear.
An installing Codex agent may hand the person to this conversation because
they declined remembered command approvals. Keep that choice: don't offer it
again or change their permissions. Take the supplied goal and constraints,
@@ -0,0 +1,74 @@
import type Database from "better-sqlite3";
import { readReceivingAgentSetting } from "./queue-owner.js";
/** The kernel's operator agent (pod operator, member agent): the receiving agent when none is set. */
export const KERNEL_OPERATOR_SESSION = "operator-agent@kernel";
export type DefaultReceiverSource = "setting" | "kernel-operator" | "none";
/** The seat's newest recorded session status, as recorded, never as proof it's reading: `running`, `idle` and `unknown`
* are kept apart; `stopped` is an exited, detached or superseded one (`recorded` names which). `missing`: no seat by that
* name on this host. `unknown` with no `recorded`: the seat couldn't be read. None of these picks another seat. */
export type ReceiverState = "running" | "idle" | "stopped" | "missing" | "unknown";
/** The agent that receives what a person answers when nothing else reads it: requests with no owner of their own, such
* as a pairing request or a mode's scheduled end, and Slack messages no thread or channel route claims. */
export interface DefaultReceiver {
session: string | null;
source: DefaultReceiverSource;
state: ReceiverState;
/** The seat's newest recorded status, when there is one. */
recorded?: string;
/** Why the chosen agent couldn't be used (an unreadable setting, or one that isn't an agent's session), carried with
* whatever was resolved instead, so a fallback is never presented as the choice. */
note?: string;
}
/** The chosen agent (`agents.operator_session`, an agent's canonical session), else the kernel operator when this host has
* its seat, else none. A stopped receiver stays the receiver: its answers wait for it, and its state says so. This never
* chooses whichever seat happens to be running, and never a person: the person's own address
* (`workspace.operator_seat_name`) is a separate setting. */
export function resolveDefaultReceiver(
db: Database.Database,
configured: () => string | null | { session: string | null; problem?: string } = readReceivingAgentSetting,
): DefaultReceiver {
const read = configured();
const chosen = typeof read === "object" && read !== null ? read : { session: read };
const note = chosen.problem ? { note: chosen.problem } : {};
if (chosen.session) return { session: chosen.session, source: "setting", ...seatState(db, chosen.session), ...note };
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return { session: null, source: "none", state: kernel.state, ...note };
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel, ...note };
}
function seatState(db: Database.Database, session: string): Pick<DefaultReceiver, "state" | "recorded"> {
let row: { status: string | null } | undefined;
try {
row = db.prepare("SELECT status FROM sessions WHERE session_name = ? ORDER BY rowid DESC LIMIT 1").get(session) as { status: string | null } | undefined;
} catch {
return { state: "unknown" };
}
if (!row) return { state: "missing" };
const recorded = row.status ?? "unknown";
const state: ReceiverState = recorded === "running" || recorded === "idle" || recorded === "unknown" ? recorded : "stopped";
return { state, recorded };
}
/** One line on who receives answers here and in what recorded state, for the person's settings and status views. It says
* what's recorded, not that the agent is reading. */
export function describeDefaultReceiver(receiver: DefaultReceiver): string {
const note = receiver.note ? `${receiver.note}. ` : "";
if (!receiver.session) {
return note + (receiver.state === "unknown"
? "OpenRig couldn't tell whether this host has a receiving agent: its seats couldn't be read."
: `No receiving agent on this host: set one with rig config set agents.operator_session <seat>, or start the kernel (${KERNEL_OPERATOR_SESSION}).`);
}
const from = receiver.source === "setting" ? "chosen in agents.operator_session"
: receiver.note ? "the kernel operator, used instead" : "the kernel operator, since agents.operator_session is unset";
const state = receiver.state === "running" ? "its session is recorded as running"
: receiver.state === "idle" ? "its session is recorded as idle"
: receiver.state === "stopped" ? `its session is recorded as ${receiver.recorded}: answers wait for it, and nothing is decided meanwhile`
: receiver.state === "missing" ? "no seat by that name on this host yet: answers wait until it exists"
: receiver.recorded ? "its session's state is recorded as unknown: answers may wait"
: "its seat couldn't be read: answers may wait";
return `${note}${receiver.session}, ${from}; ${state}.`;
}
@@ -998,7 +998,7 @@ export function pendingConversationsFor(entityId: string, home: string = getOpen
/** The refusal when nobody is registered yet; the wording `rig gateway human list` uses for an empty registry. */
export const NO_HUMAN_CONFIGURED =
"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …";
"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)";
export type LoadResult =
| { ok: true; entities: HumanFragment[] }
@@ -13,6 +13,7 @@ import { BASELINE_REQUIRED_SCOPES } from "./capabilities.js";
import { unsupportedChannelMapFields, validateChannelMap, type ChannelMapEntry } from "./channel-map.js";
import { botInboxProblems, type BotInboxConfig } from "./bot-inbox.js";
import { forwardChannelProblems, type ForwardChannel } from "./forwarding.js";
import { describeDefaultReceiver, type DefaultReceiver } from "../../default-receiver.js";
export interface SlackConnectorConfig {
enabled: boolean;
@@ -65,9 +66,33 @@ export interface SlackConnectorConfig {
export const SLACK_REACTIONS_MODES = ["route", "off"] as const;
export type SlackReactionsMode = typeof SLACK_REACTIONS_MODES[number];
/** The inbound destination that means "the host's default receiving agent". It's never written to the file: a saved config
* without `inboundDestination` inherits it (so an older daemon reading that file uses its own default), and
* `rig slack setup --inbound-destination default-receiver` returns to it. A destination already saved in the file is kept
* as chosen, `operator-agent@kernel` included: nothing records whether a person chose it. */
export const INHERIT_INBOUND_DESTINATION = "default-receiver";
/** Where an inherited destination goes when the host has no receiving agent: the kernel operator's address, as before. */
export const LEGACY_INBOUND_DESTINATION = "operator-agent@kernel";
/** Where a person's unrouted Slack message goes: the destination saved in the config, else the host's receiving agent, else
* the kernel operator's address. Thread and channel routes still come first. */
export function effectiveInboundDestination(cfg: Pick<SlackConnectorConfig, "inboundDestination">, receiver: string | null): { destination: string; from: "config" | "receiving-agent" | "default" } {
if (cfg.inboundDestination !== INHERIT_INBOUND_DESTINATION) return { destination: cfg.inboundDestination, from: "config" };
return receiver ? { destination: receiver, from: "receiving-agent" } : { destination: LEGACY_INBOUND_DESTINATION, from: "default" };
}
/** What the status says about an inherited destination: the receiving agent and its recorded state, or, with none, that the
* kernel operator's address is kept for compatibility, so it isn't presented as a resolved receiver. Nothing for a
* destination saved in the config. */
export function inboundDestinationNote(inbound: { destination: string; from: "config" | "receiving-agent" | "default" }, receiver: DefaultReceiver | null): string | undefined {
if (inbound.from === "config") return undefined;
const who = receiver ? describeDefaultReceiver(receiver) : "OpenRig couldn't tell whether this host has a receiving agent.";
return inbound.from === "receiving-agent" ? who : `${who} Unrouted messages go to ${inbound.destination} for compatibility, as before.`;
}
export const DEFAULT_CONFIG: SlackConnectorConfig = {
enabled: false,
inboundDestination: "operator-agent@kernel",
inboundDestination: INHERIT_INBOUND_DESTINATION,
outboundDestinations: [],
sourceLabel: "openrig",
channel: null,
@@ -118,7 +143,9 @@ export function saveConfig(cfg: SlackConnectorConfig, home?: string): string {
validateConfig(cfg);
const p = configPathFor(home);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
// An inherited destination is left out of the file, so the file records only a destination someone chose.
const { inboundDestination, ...rest } = cfg;
fs.writeFileSync(p, JSON.stringify(inboundDestination === INHERIT_INBOUND_DESTINATION ? rest : cfg, null, 2) + "\n");
return p;
}
@@ -146,7 +173,8 @@ export function staticReadiness(cfg: SlackConnectorConfig, hasBotToken: boolean,
{ ok: hasBotToken, label: "bot-token", detail: hasBotToken ? "resolved" : "unset (outbound cannot post; scope/membership verify unavailable)" },
{ ok: hasAppToken, label: "app-token (Socket Mode)", detail: hasAppToken ? "resolved" : "unset (inbound cannot connect)" },
{ ok: cfg.channel !== null, label: "channel", detail: cfg.channel ?? "unset (outbound cannot post)" },
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination", detail: cfg.inboundDestination },
{ ok: Boolean(cfg.inboundDestination), label: "inbound-destination",
detail: cfg.inboundDestination === INHERIT_INBOUND_DESTINATION ? `${INHERIT_INBOUND_DESTINATION} (the host's default receiving agent)` : cfg.inboundDestination },
{ ok: cfg.enabled, label: "enabled", detail: cfg.enabled ? "yes" : "no (run `rig slack enable`)" },
...channelMapReadiness(cfg),
...botInboxReadiness(cfg),
@@ -21,7 +21,8 @@ import { buildInProcessWire, type GatewayWire, type SubsystemDeliverFn } from ".
import { DispatchBuffer } from "../dispatch-buffer.js";
import { downloadPrivateFile, isSlackHost, lookupBotApp, postChatMessage } from "./slack-api.js";
import { botInboxProblems } from "./bot-inbox.js";
import { loadConfig } from "./config.js";
import { effectiveInboundDestination, inboundDestinationNote, loadConfig } from "./config.js";
import { resolveDefaultReceiver } from "../../default-receiver.js";
import { resolveSecret } from "./secrets.js";
import { SeenStore, DeadLetterStore, InboundReceiptStore, slackMicros } from "./state-store.js";
import { makeQueuePorts } from "./queue-access.js";
@@ -310,7 +311,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
const outboundReady = cfg.enabled && bot !== null && cfg.channel !== null;
const inboundReady = cfg.enabled && app !== null;
const inboundRouting = { fallback: "local", destination: cfg.inboundDestination };
// Read once, when the connector starts, like the rest of its configuration.
const receiver = (() => { try { return resolveDefaultReceiver(opts.queueRepo.db); } catch { return null; } })();
const inbound = effectiveInboundDestination(cfg, receiver?.session ?? null);
const inboundNote = inboundDestinationNote(inbound, receiver);
if (inbound.from === "default") log(`slack inbound: ${inboundNote}`);
const inboundRouting = { fallback: "local", destination: inbound.destination, destinationFrom: inbound.from, ...(inboundNote ? { destinationNote: inboundNote } : {}) };
const dead = new DeadLetterStore<SlackEvent>(path.join(stateDir(opts.home), "slack-inbound-deadletter.jsonl"));
const deadActions = new DeadLetterStore<SlackBlockActions>(path.join(stateDir(opts.home), "slack-inbound-action-deadletter.jsonl"));
// Retained relays belong to their original host, even after forwarding configuration changes.
@@ -1359,14 +1365,14 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
queue: ports,
seen: inboundSeen,
deadLetter: dead,
destination: cfg.inboundDestination,
destination: inbound.destination,
reactions: cfg.reactions === "off" ? "off" : "route",
resolveSender: makeInboundSenderResolver(registry, opts.home),
// S10 — deterministic thread routing: mapped thread → exactly the mapped seat; unmapped
// or human-initiated → the configured orchestrator slot as an unrouted-signal row.
resolveRoute: makeThreadRouteResolver({
map: threadMap,
unroutedDestination: cfg.inboundDestination,
unroutedDestination: inbound.destination,
// A person's top-level message in a channel the map routes: its `inbound` seat or its rig's representative.
resolveChannelDestination: (channel) =>
resolveInboundChannelDestination(cfg, channel, (rig) => resolveRigRepresentative(opts.queueRepo.db, rig)),
@@ -8,10 +8,11 @@ import type { WatchdogJobsRepository } from "../watchdog-jobs-repository.js";
import { HumanModeService, registryAvailability, type ModeApplication } from "../gateway/human-operating-modes.js";
import { loadHumanRegistry } from "../gateway/human-registry.js";
import type { Policy, PolicyJob, PolicyEvaluation } from "./types.js";
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, resolveDefaultReceiver, type DefaultReceiver } from "../default-receiver.js";
export const MODE_SCHEDULED_END_POLICY = "mode-scheduled-end";
/** The kernel's operator agent (pod operator, member agent), whose role guidance covers giving the account. */
export const KERNEL_OPERATOR_SESSION = "operator-agent@kernel";
export { KERNEL_OPERATOR_SESSION };
const applicationSpec = (applicationId: string) => `applicationId: "${applicationId}"`;
@@ -37,6 +38,8 @@ export interface ModeScheduledEndInput {
home: string;
jobId: string;
now?: Date;
/** Who receives requests no one else owns here; the host's default receiving agent unless a caller supplies it. */
receiver?: (db: Database.Database) => DefaultReceiver;
}
/** The notice's stable id: one per application, so a retry finds what an earlier run already sent. */
@@ -98,20 +101,27 @@ const SCHEDULER = "daemon@kernel";
* is decided yet. */
type Recipient = { ok: true; to: string; kind: "operator" | "applied-by" | "person"; why: string | null } | { ok: false; known: boolean; why: string };
/** Who's told, through the queue's own destination check: the kernel operator, which gives the account; where it isn't on
* this host, the seat that applied the mode, if the queue would accept it (a named operator outside a seat isn't a
* destination); otherwise the person, whose account it is. */
/** Who's told, through the queue's own destination check: the host's receiving agent (the chosen one, else the kernel
* operator), which gives the account; where there's none, the seat that applied the mode, if the queue would accept it
* (a named operator outside a seat isn't a destination); otherwise the person, whose account it is. */
function recipientFor(input: ModeScheduledEndInput, ended: ModeApplication): Recipient {
const unknown = (why: string): Recipient => ({ ok: false, known: false, why });
// A check that throws hasn't said no.
const accepts = (to: string): boolean | Error => { try { return input.queueRepo.acceptsDestination(to); } catch (error) { return error as Error; } };
const operatorHere = !!input.db.prepare("SELECT 1 FROM sessions WHERE session_name = ? LIMIT 1").get(KERNEL_OPERATOR_SESSION);
if (operatorHere) {
const ok = accepts(KERNEL_OPERATOR_SESSION);
if (ok instanceof Error) return unknown(`checking ${KERNEL_OPERATOR_SESSION} failed: ${ok.message}`);
if (ok) return { ok: true, to: KERNEL_OPERATOR_SESSION, kind: "operator", why: null };
const receiver = (input.receiver ?? resolveDefaultReceiver)(input.db);
// Not knowing whether there's a receiving agent isn't knowing there's none: the notice stays owed for the next pass.
if (!receiver.session && receiver.state === "unknown") return unknown(describeDefaultReceiver(receiver));
if (receiver.session) {
const ok = accepts(receiver.session);
if (ok instanceof Error) return unknown(`checking ${receiver.session} failed: ${ok.message}`);
// A receiver used instead of an unusable choice is delivered to with the reason, never silently.
if (ok) return { ok: true, to: receiver.session, kind: "operator", why: receiver.note ?? null };
}
const noOperator = `${KERNEL_OPERATOR_SESSION} isn't on this host`;
// An unusable setting is named as such, never as unset.
const note = receiver.note ? `${receiver.note}; ` : "";
const noOperator = receiver.session ? `${note}${receiver.session}, the receiving agent, isn't an address the queue accepts`
: receiver.state === "unknown" ? `${note}OpenRig couldn't tell whether this host has a receiving agent`
: `this host has no receiving agent (${receiver.note ?? "no agents.operator_session"}, and ${KERNEL_OPERATOR_SESSION} isn't on this host)`;
const applier = accepts(ended.startedBy);
if (applier instanceof Error) return unknown(`checking ${ended.startedBy} failed: ${applier.message}`);
if (applier) return { ok: true, to: ended.startedBy, kind: "applied-by", why: noOperator };
+13 -4
View File
@@ -20,13 +20,22 @@ export function resolveOperatorSeat(): string | null {
* approvals use. Null when unset, unreadable, or not an agent's canonical session (a person's address or
* human seat ref never counts). */
export function resolveRecoveryAgentSeat(): string | null {
return readReceivingAgentSetting().session;
}
/** `agents.operator_session` as the receiving agent reads it: the agent's session, or null with why when it can't be used
* (`problem`: unreadable, or not an agent's canonical session). Unset is null with no problem. */
export function readReceivingAgentSetting(): { session: string | null; problem?: string } {
let session: string;
try {
const v = new SettingsStore().resolveOne("agents.operator_session" as never).value;
const session = typeof v === "string" ? v.trim() : "";
return session && parseSessionName(session).kind === "canonical" && !isHumanSeatSessionRef(session) ? session : null;
} catch {
return null;
session = typeof v === "string" ? v.trim() : "";
} catch (error) {
return { session: null, problem: `agents.operator_session couldn't be read (${(error as Error).message})` };
}
if (!session) return { session: null };
return parseSessionName(session).kind === "canonical" && !isHumanSeatSessionRef(session) ? { session }
: { session: null, problem: `agents.operator_session is "${session}", which isn't an agent's session, so it's ignored` };
}
/** The durable session→node binding (the session-registry precedent: latest sessions row
@@ -2466,7 +2466,7 @@ export class QueueRepository {
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy: it needs a human destination.");
}
if (humanIntent === "update") {
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices and follow Slack's posting floor and delivery settings.");
throw new QueueRepositoryError("invalid_human_notification", "humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices; delivery depends on the person's connector and settings.");
}
}
+25
View File
@@ -0,0 +1,25 @@
// The seat boundary. A seat's kind comes from its binding's `attachment_type`, the key the seat-kinds facade in #1429
// proposed (by rlch, credited). This first boundary covers today's kinds only: tmux, which is also the column's default
// and what a legacy row with no value means, and external_cli. Each seat's three responsibilities stay where they are:
// the runtime that keeps the process alive (RuntimeAdapter), the session that carries messages and turns to the agent
// (SessionTransport, which asks this resolver), and the view a person watches and types into (TerminalProvider).
/** Today's seat kinds. */
export type SeatKind = "tmux" | "external_cli";
/** A binding's kind as stored. `unknown` names a stored value that isn't one of today's kinds, and is never read as tmux. */
export type ResolvedSeatKind = { kind: SeatKind } | { kind: "unknown"; stored: string };
export function resolveSeatKind(stored: string | null | undefined): ResolvedSeatKind {
if (stored === null || stored === undefined || stored === "tmux") return { kind: "tmux" };
if (stored === "external_cli") return { kind: "external_cli" };
return { kind: "unknown", stored };
}
/** How OpenRig's inbound transport (send, capture and the activity probe) reaches a seat of this kind. An external CLI
* seat runs inside someone else's program, so OpenRig can't type into it or read it: `none`. An unknown stored kind
* keeps the path it takes today, the tmux one, so nothing changes for it here; whether it should is an open question
* for its owner, not something this boundary settles. */
export function seatInboundTransport(seat: ResolvedSeatKind): "tmux" | "none" {
return seat.kind === "external_cli" ? "none" : "tmux";
}
+26 -14
View File
@@ -51,6 +51,7 @@ import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { hashSentText, type CaptureObserverSink, type CaptureSlot, type ObservationInput, type ObservedBinding } from "./capture-observer.js";
import { isAttentionRequiredReadinessCode, type NodeBinding, type ReadinessOptions, type ReadinessResult } from "./runtime-adapter.js";
import { getLatestStartupFailedEvent, hasClaudeResumeIdentityWork, isRecoverableStartupFailure } from "./startup-orchestrator.js";
import { resolveSeatKind, seatInboundTransport } from "./seat-kinds.js";
// OPR.0.4.1.10 — send-readiness freshness. The runtime-hook store keeps a 5min freshness for activity
// DISPLAY, but "safe to send NOW" needs a tight window: a stale "idle" read must not authorize a send
@@ -448,14 +449,18 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false;
let faint = false, inverse = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
const append = (value: string) => {
text += value;
faintCells.push(...Array<boolean>(value.length).fill(faint));
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
};
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -466,15 +471,18 @@ function styledTextLines(screen: string) {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
i += skip;
} else if (code === "" || code === "0" || code === "22") faint = false;
} else if (code === "" || code === "0") faint = inverse = false;
else if (code === "22") faint = false;
else if (code === "2") faint = true;
else if (code === "7") inverse = true;
else if (code === "27") inverse = false;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells };
return { text, faintCells, inverseCells };
});
}
@@ -569,7 +577,11 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
const empty = atStart && rows.length === 1 && rows[0] === "";
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
// that single cell at the start is padding; ordinary/preceding spaces stay input.
const inverseCells = styled[start]!.inverseCells;
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
@@ -969,7 +981,7 @@ export async function probeSessionActivity(input: {
};
}
const readTarget = binding?.pane ?? sessionName;
if (attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(attachmentType)) === "none") {
return {
state: "unknown",
reason: "unsupported_attachment",
@@ -2569,7 +2581,7 @@ export class SessionTransport {
const binding = this.sessionRegistry.getBindingForNode(node.id);
const latestSession = latestByNode.get(node.id);
if (binding?.attachmentType === "external_cli" && binding.externalSessionName) {
if (resolveSeatKind(binding?.attachmentType).kind === "external_cli" && binding?.externalSessionName) {
targets.push({
sessionName: binding.externalSessionName,
rigName,
@@ -2900,7 +2912,7 @@ export class SessionTransport {
// External CLI seats never wait: main refuses them before anything else,
// and a probe or wait would only delay that refusal (or mistake it for
// an absent session).
if (meta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(meta.attachmentType)) === "none") {
return { ok: false, failure: {
ok: false,
sessionName,
@@ -3094,7 +3106,7 @@ export class SessionTransport {
const waitMode = waitForIdleMs !== undefined;
let waitEvidence: Pick<SendResult, "activity" | "waitedMs" | "attempts"> = {};
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
@@ -4360,7 +4372,7 @@ export class SessionTransport {
readable?: { width: number; height: number }; readableBudget?: ReadableBudget }): Promise<CaptureResult> {
sessionName = physicalSessionName(this.db, sessionName);
const sessionMeta = this.getSessionMeta(sessionName);
if (sessionMeta.attachmentType === "external_cli") {
if (seatInboundTransport(resolveSeatKind(sessionMeta.attachmentType)) === "none") {
return {
ok: false,
sessionName,
@@ -4,10 +4,11 @@ import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG } from "../gateway/slack/config.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
import { loadHostRegistry } from "../hosts/hosts-registry-reader.js";
import { DEFAULT_HEALTH_POLICY, validateHealthPolicy } from "../health-policy.js";
import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiver.js";
type Value = string | number | boolean | null;
type State = "available" | "missing" | "malformed" | "unavailable";
@@ -104,7 +105,8 @@ function application(key: string): string {
}
/** Additive safe view of existing owners. No mutation, host probe, provider call or repair. */
export function settingsBrowser(store: SettingsStore, gateway: Record<string, unknown> | null = null, home = getOpenRigHome()) {
export function settingsBrowser(store: SettingsStore, gateway: Record<string, unknown> | null = null, home = getOpenRigHome(),
receiver: DefaultReceiver | null = null) {
const read = readConnectionConfiguration(home);
const redact = read.text;
const entries: BrowserEntry[] = [];
@@ -139,6 +141,13 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
add(key, "general", r?.value, r?.defaultValue, r?.source ?? "unavailable", kindFor(key),
key === "ui.terminal.max_live_terminals" ? "Legacy web client" : "Displayed daemon instance", application(key));
}
// Derived, not a setting: the agent that receives answers nothing else reads, from agents.operator_session or the
// kernel operator. Shown with where it came from and its state; changed with rig config set agents.operator_session.
if (receiver) {
add("agents.default_receiver", "general", receiver.session ?? "", undefined,
receiver.source === "setting" ? resolved?.["agents.operator_session"]?.source ?? "unreported" : "default", "identity",
"Displayed daemon instance", describeDefaultReceiver(receiver));
}
if (resolved) {
for (const host of store.listFeedHostSubscriptions()) {
const key = "feed.subscriptions." + host.hostId + ".enabled";
@@ -158,8 +167,11 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const cfg = read.cfg as unknown as Record<string, unknown> | null;
const defs = DEFAULT_CONFIG as unknown as Record<string, unknown>;
const flatten = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join(", ") : v;
// The cross-reference to Agents & runtime: an unchosen destination is the host's receiving agent.
const unchosen = key === "inboundDestination" && (!cfg || cfg[key] === INHERIT_INBOUND_DESTINATION);
add("slack." + key, "slack", cfg ? flatten(cfg[key]) : null, flatten(defs[key]),
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind);
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind, undefined,
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
}
// #192: the channel map as one read-only line, "match: channel" per entry.
add("slack.channelMap", "slack", read.cfg ? c.configuration!.channelMap.map((e) => `${e.match ?? "withheld"}: ${e.channel ?? "withheld"}`).join(", ") : null, "",
@@ -134,6 +134,8 @@ export const SETTINGS_VALID_KEYS = [
// dispatch ACK §4: allowlist-only edit; no migrations / new
// endpoints / event types.
"agents.advisor_session",
// The default receiving agent: reads what a person answers when nothing else reads it (resolveDefaultReceiver), and the
// agent a dead seat's recovery reaches. Unset means the kernel operator, when this host has that seat.
"agents.operator_session",
// Explicit operator override; unset means discover the registered human.
"workspace.operator_seat_name",
+8 -1
View File
@@ -14,6 +14,8 @@
import { Hono } from "hono";
import { settingsBrowser } from "../domain/user-settings/settings-browser.js";
import { resolveDefaultReceiver, type DefaultReceiver } from "../domain/default-receiver.js";
import type { QueueRepository } from "../domain/queue-repository.js";
import {
SETTINGS_VALID_KEYS,
isSettingsValidKey,
@@ -42,7 +44,12 @@ export function configRoutes(opts: { home?: string } = {}): Hono {
const gateway = c.get("gatewaySubsystem" as never) as { status(): Record<string, unknown> } | undefined;
let observed: Record<string, unknown> | null = null;
try { observed = gateway?.status() ?? null; } catch { /* configuration still browsable */ }
return c.json(settingsBrowser(store, observed, opts.home));
let receiver: DefaultReceiver | null = null;
try {
const repo = c.get("queueRepo" as never) as QueueRepository | undefined;
if (repo) receiver = resolveDefaultReceiver(repo.db);
} catch { /* the rest stays browsable; the receiver row is left out */ }
return c.json(settingsBrowser(store, observed, opts.home, receiver));
}
// OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the
// static settings map (existing consumers of `settings` unaffected).
+24 -12
View File
@@ -57,7 +57,7 @@ import { addHostEntry, updateHostEntry, removeHostEntry } from "../domain/hosts/
import { defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, type HostEntry, type SshHostEntry } from "../domain/hosts/hosts-registry-reader.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
import { UNATTRIBUTED_ACTOR, type QueueItem, type QueueRepository } from "../domain/queue-repository.js";
import { KERNEL_OPERATOR_SESSION } from "../domain/policies/mode-scheduled-end.js";
import { KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../domain/default-receiver.js";
import type { HumanQuestion } from "../domain/human-questions.js";
import { PAIR_SOURCE_SESSION } from "../domain/hosts/pairing-ask.js";
import { existsSync } from "node:fs";
@@ -141,6 +141,12 @@ interface IssuedPair {
/** Why an agent-free request has no owner: this host has no kernel operator, the queue can't take rows to it, or
* OpenRig couldn't read which. */
freeReason?: "none" | "unroutable" | "unknown";
/** The receiving agent the queue couldn't take rows to (`unroutable`). */
freeSession?: string;
/** Why the chosen agent wasn't used on an agent-free request (an unusable agents.operator_session), told with the reason. */
freeNote?: string;
/** Why the owner is a receiver used instead of the chosen one (an unusable agents.operator_session). */
ownerNote?: string;
}
interface ClientPair {
@@ -180,23 +186,29 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
return c.get("queueRepo" as never) as QueueRepository;
}
/** Who reads a person's answer to a pairing request here: the kernel operator, when this host has one (a session, in any
* state) and the queue takes rows to it; else nobody, and the request is agent-free, with the reason. A stopped
* operator still owns it: the person's answer waits in its queue, and the person is told it isn't running. An operator
/** Who reads a person's answer to a pairing request here: the host's receiving agent (the chosen one, else the kernel
* operator), when the queue takes rows to it; else nobody, and the request is agent-free, with the reason. A stopped
* receiver still owns it: the person's answer waits in its queue, and the person is told it isn't running. A receiver
* the queue can't take rows to can't own it: the person's answer would be refused on its way there. */
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason"> {
function pairOwner(c: { get: (key: string) => unknown }): Pick<IssuedPair, "owner" | "freeReason" | "freeSession" | "ownerNote" | "freeNote"> {
const repo = getRepo(c);
try {
if (!repo.db.prepare("SELECT 1 FROM sessions WHERE session_name = ? LIMIT 1").get(KERNEL_OPERATOR_SESSION)) return { owner: null, freeReason: "none" };
return repo.acceptsDestination(KERNEL_OPERATOR_SESSION) ? { owner: KERNEL_OPERATOR_SESSION } : { owner: null, freeReason: "unroutable" };
const receiver = resolveDefaultReceiver(repo.db);
const freeNote = receiver.note ? { freeNote: receiver.note } : {};
if (!receiver.session) return { owner: null, freeReason: receiver.state === "unknown" ? "unknown" : "none", ...freeNote };
return repo.acceptsDestination(receiver.session) ? { owner: receiver.session, ...(receiver.note ? { ownerNote: receiver.note } : {}) }
: { owner: null, freeReason: "unroutable", freeSession: receiver.session, ...freeNote };
} catch { return { owner: null, freeReason: "unknown" }; }
}
/** Why nobody reads answers on an agent-free request, as the person is told it. */
/** Why nobody reads answers on an agent-free request, as the person is told it. An unusable setting is named as such,
* never as unset. */
function noReader(rec: IssuedPair): string {
return rec.freeReason === "unroutable" ? `${KERNEL_OPERATOR_SESSION} can't receive answers on this host`
: rec.freeReason === "unknown" ? "OpenRig couldn't tell whether an agent reads answers on this host"
: `no agent reads answers on this host (it has no ${KERNEL_OPERATOR_SESSION})`;
const note = rec.freeNote ? `${rec.freeNote}; ` : "";
return rec.freeReason === "unroutable" ? `${note}${rec.freeSession ?? "the receiving agent"} can't receive answers on this host`
: rec.freeReason === "unknown" ? `${note}OpenRig couldn't tell whether an agent reads answers on this host`
: rec.freeNote ? `no agent reads answers on this host (${rec.freeNote}, and it has no ${KERNEL_OPERATOR_SESSION})`
: `no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${KERNEL_OPERATOR_SESSION})`;
}
/** Whether the owner's session is running now; null when that can't be read. */
@@ -243,7 +255,7 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
`A remote operator (${requester}) is asking to pair with this host. "${requester}" is how the requesting machine describes itself.`,
`Pairing code: ${code} — confirm it matches the code shown on the requesting side.`,
...(owner ? [
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.`,
`Answer with Approve or Deny, or in your own words. ${owner} reads your answer and acts on it: a clear yes pairs, a clear no denies, and if your answer isn't clear it asks you back.${rec.ownerNote ? ` (${rec.ownerNote}, so ${owner} reads it instead.)` : ""}`,
...((running) => running === true ? [] : [`${running === false ? `${owner} isn't running right now, so your answer waits` : `OpenRig couldn't tell whether ${owner} is running, so your answer may wait`} until it starts; nothing is granted or denied meanwhile. To decide sooner, in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`])(ownerRunning(c, owner)),
`For ${owner}: when the person's answer to this request comes back, act on it. A clear yes or an Approve click: rig host approve ${code}. A clear no or a Deny click: rig host deny ${code}. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming pairing code ${code}, and act on what they answer.`,
] : [
@@ -162,6 +162,15 @@ describe("cursor-bound composer input", () => {
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
const cursor = { x: 2, y: 2 };
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { describeDefaultReceiver, KERNEL_OPERATOR_SESSION, resolveDefaultReceiver } from "../src/domain/default-receiver.js";
import { readReceivingAgentSetting } from "../src/domain/queue-owner.js";
import { effectiveInboundDestination, inboundDestinationNote, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION, loadConfig, saveConfig, configPathFor } from "../src/domain/gateway/slack/config.js";
import { readFileSync, writeFileSync } from "node:fs";
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
const closes: Array<() => void> = [];
afterEach(() => { vi.unstubAllEnvs(); for (const close of closes.splice(0)) close(); });
function db(seats: Array<[string, string]> = []): Database.Database {
const home = mkdtempSync(join(tmpdir(), "default-receiver-"));
const d = new Database(join(home, "q.sqlite"));
migrate(d, ALL_MIGRATIONS);
d.prepare("INSERT INTO rigs (id, name) VALUES ('r', 'kernel')").run();
seats.forEach(([session, status], i) => {
d.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, 'r', ?)").run(`n${i}`, `pod.m${i}`);
d.prepare("INSERT INTO sessions (node_id, session_name, status) VALUES (?, ?, ?)").run(`n${i}`, session, status);
});
closes.push(() => { d.close(); rmSync(home, { recursive: true, force: true }); });
return d;
}
describe("the default receiving agent", () => {
it("is the chosen agent first, even with a kernel operator here, with its own seat's state", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"], ["front-lead@team", "exited"]]);
expect(resolveDefaultReceiver(d, () => "front-lead@team")).toEqual({ session: "front-lead@team", source: "setting", state: "stopped", recorded: "exited" });
expect(resolveDefaultReceiver(d, () => "nobody-yet@team")).toEqual({ session: "nobody-yet@team", source: "setting", state: "missing" });
});
it("is the kernel operator when none is chosen, running or stopped: a stopped one stays the receiver", () => {
expect(resolveDefaultReceiver(db([[KERNEL_OPERATOR_SESSION, "running"]]), () => null))
.toEqual({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "running", recorded: "running" });
expect(resolveDefaultReceiver(db([[KERNEL_OPERATOR_SESSION, "exited"], ["other@team", "running"]]), () => null))
.toEqual({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "stopped", recorded: "exited" });
});
it("is none with no chosen agent and no kernel operator, and never whichever seat happens to be running", () => {
expect(resolveDefaultReceiver(db([["other@team", "running"]]), () => null)).toEqual({ session: null, source: "none", state: "missing" });
});
it("keeps idle and unknown apart from stopped, from the seat's newest recorded session", () => {
expect(resolveDefaultReceiver(db([["front-lead@team", "idle"]]), () => "front-lead@team")).toMatchObject({ state: "idle", recorded: "idle" });
expect(resolveDefaultReceiver(db([["front-lead@team", "unknown"]]), () => "front-lead@team")).toMatchObject({ state: "unknown", recorded: "unknown" });
// An older occupant's running row doesn't make a newer superseded one read running.
expect(resolveDefaultReceiver(db([["front-lead@team", "running"], ["front-lead@team", "superseded"]]), () => "front-lead@team"))
.toMatchObject({ state: "stopped", recorded: "superseded" });
});
it("reads unknown, not none, when the seats can't be read", () => {
const d = db();
vi.spyOn(d, "prepare").mockImplementation(() => { throw new Error("unreadable"); });
expect(resolveDefaultReceiver(d, () => null)).toEqual({ session: null, source: "none", state: "unknown" });
expect(resolveDefaultReceiver(d, () => "front-lead@team")).toEqual({ session: "front-lead@team", source: "setting", state: "unknown" });
});
it("takes the chosen agent from agents.operator_session; a person's address there is ignored, and the fallback says so", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"]]);
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "front-lead@team");
expect(resolveDefaultReceiver(d)).toMatchObject({ session: "front-lead@team", source: "setting" });
expect(resolveDefaultReceiver(d).note).toBeUndefined();
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
const fallback = resolveDefaultReceiver(d);
expect(fallback).toMatchObject({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator",
note: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' });
expect(describeDefaultReceiver(fallback)).toBe(`agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored. ${KERNEL_OPERATOR_SESSION}, the kernel operator, used instead; its session is recorded as running.`);
});
it("an unreadable agents.operator_session is reported, never read as unset", () => {
const d = db([[KERNEL_OPERATOR_SESSION, "running"]]);
const unreadable = { session: null, problem: "agents.operator_session couldn't be read (bad file)" };
expect(resolveDefaultReceiver(d, () => unreadable)).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: unreadable.problem });
expect(resolveDefaultReceiver(db(), () => unreadable)).toEqual({ session: null, source: "none", state: "missing", note: unreadable.problem });
vi.spyOn(SettingsStore.prototype, "resolveOne").mockImplementation(() => { throw new Error("config unreadable"); });
try {
expect(readReceivingAgentSetting()).toEqual({ session: null, problem: "agents.operator_session couldn't be read (config unreadable)" });
} finally { vi.restoreAllMocks(); }
});
it("is described with where it came from and what its state means for an answer", () => {
expect(describeDefaultReceiver({ session: "front-lead@team", source: "setting", state: "stopped", recorded: "exited" }))
.toBe("front-lead@team, chosen in agents.operator_session; its session is recorded as exited: answers wait for it, and nothing is decided meanwhile.");
// What's recorded, never a claim that it's reading now.
expect(describeDefaultReceiver({ session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", state: "running", recorded: "running" }))
.toBe(`${KERNEL_OPERATOR_SESSION}, the kernel operator, since agents.operator_session is unset; its session is recorded as running.`);
expect(describeDefaultReceiver({ session: null, source: "none", state: "missing" }))
.toContain("No receiving agent on this host: set one with rig config set agents.operator_session <seat>");
});
});
describe("Slack's unrouted destination", () => {
it("keeps a saved destination as chosen, the kernel operator's address included, and inherits only when none is saved", () => {
expect(effectiveInboundDestination({ inboundDestination: "triage@team" }, "front-lead@team")).toEqual({ destination: "triage@team", from: "config" });
// Compatibility control: a saved operator-agent@kernel may have been a person's choice, so it isn't repointed.
expect(effectiveInboundDestination({ inboundDestination: LEGACY_INBOUND_DESTINATION }, "front-lead@team"))
.toEqual({ destination: LEGACY_INBOUND_DESTINATION, from: "config" });
expect(effectiveInboundDestination({ inboundDestination: INHERIT_INBOUND_DESTINATION }, "front-lead@team"))
.toEqual({ destination: "front-lead@team", from: "receiving-agent" });
expect(effectiveInboundDestination({ inboundDestination: INHERIT_INBOUND_DESTINATION }, null))
.toEqual({ destination: LEGACY_INBOUND_DESTINATION, from: "default" });
});
it("an inherited destination with no receiver says the kernel address is kept for compatibility; a saved one says nothing", () => {
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "default" }, { session: null, source: "none", state: "missing" }))
.toBe(`No receiving agent on this host: set one with rig config set agents.operator_session <seat>, or start the kernel (${KERNEL_OPERATOR_SESSION}). Unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`);
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "default" }, { session: null, source: "none", state: "unknown" }))
.toContain("OpenRig couldn't tell whether this host has a receiving agent");
expect(inboundDestinationNote({ destination: "front-lead@team", from: "receiving-agent" }, { session: "front-lead@team", source: "setting", state: "idle", recorded: "idle" }))
.toBe("front-lead@team, chosen in agents.operator_session; its session is recorded as idle.");
expect(inboundDestinationNote({ destination: LEGACY_INBOUND_DESTINATION, from: "config" }, null)).toBeUndefined();
});
it("reads a file without a destination as inherited, never writes the inherit choice, and keeps a saved one", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-slack-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
expect(loadConfig(home).inboundDestination).toBe(INHERIT_INBOUND_DESTINATION);
saveConfig({ ...loadConfig(home), channel: "C123" }, home);
expect(JSON.parse(readFileSync(configPathFor(home), "utf8"))).not.toHaveProperty("inboundDestination");
expect(loadConfig(home).inboundDestination).toBe(INHERIT_INBOUND_DESTINATION);
writeFileSync(configPathFor(home), JSON.stringify({ ...JSON.parse(readFileSync(configPathFor(home), "utf8")), inboundDestination: LEGACY_INBOUND_DESTINATION }));
expect(loadConfig(home).inboundDestination).toBe(LEGACY_INBOUND_DESTINATION);
saveConfig(loadConfig(home), home);
expect(JSON.parse(readFileSync(configPathFor(home), "utf8")).inboundDestination).toBe(LEGACY_INBOUND_DESTINATION);
});
});
describe("the settings view", () => {
it("shows the receiving agent beside the settings, and Slack's unchosen destination points to it", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-browser-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
const store = new SettingsStore(join(home, "config.json"));
const view = settingsBrowser(store, null, home, { session: "front-lead@team", source: "setting", state: "running", recorded: "running" });
expect(view.entries.find((entry) => entry.key === "agents.default_receiver")).toMatchObject({
group: "general", value: "front-lead@team", visibility: "shown",
application: "front-lead@team, chosen in agents.operator_session; its session is recorded as running.",
});
expect(view.entries.find((entry) => entry.key === "slack.inboundDestination")?.application)
.toBe("Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime).");
expect(settingsBrowser(store, null, home).entries.some((entry) => entry.key === "agents.default_receiver")).toBe(false);
});
});
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
}
}, 30_000);
it("submits a multiline paste under a combined update footer through real tmux", async () => {
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,8 +143,9 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -157,7 +158,14 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
if (variant === "cursor-block") {
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(pane);
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
});
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
const text = "first line\nsecond line\nlast line";
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
@@ -165,7 +173,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
.toMatchObject({ outcome: "retained", sent: false });
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+7 -3
View File
@@ -5,19 +5,23 @@ import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
const cursorBlock = footerVariant === "cursor-block";
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (runtime === "claude") rows.push("────────────────────────────────────────");
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
rows.push(...(runtime === "claude" ? [rule, cursorBlock
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
@@ -1 +1 @@
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-a/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
@@ -1 +1 @@
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
@@ -1 +1 @@
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro-new","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro-new","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/new.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
@@ -1 +1 @@
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
{"ok":true,"issues":[{"code":"role_no_live_preferred_target","message":"role \"producer\" has preferred_targets [\"producer@rig\"] but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.","field":"workflow.roles.producer.preferred_targets","severity":"warning"}],"summary":{"workflowId":"wf-validate-ro","workflowVersion":"1","targetRig":null,"entryRole":"producer","stepCount":1},"exceptionReadiness":{"scope":"future-occurrences","posture":"advisory","selection":{"state":"missing","role":null,"source":"<TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role","entryRole":"producer"},"routes":[{"exceptionClass":"unmapped_failed","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"},{"exceptionClass":"stuck_overdue","state":"registry-unavailable","roleResolution":"missing-selection","destinationSession":null,"position":"orchestrator","resolvedVia":"engine-default","identity":"unverified","message":"no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)"}],"nextAction":"A defined entry/ordinary role does not select exception ownership. Select the intended declared role at <TMP>/specs-b/spec.workflow.yaml#workflow.exception_routing.orchestrator_role. Compile again after correction. This is a pre-failure advisory. At detection, a required resolution read that fails blocks that occurrence; ordinary work is not gated by this inspection."}}
@@ -147,7 +147,7 @@ describe("public human registry -> queue admission journey", () => {
expect(response.status).toBe(400);
const body = await response.json() as Record<string, unknown>;
expect(body).toMatchObject({ error: "human_registry_unavailable", registryLoadError: true });
const nobody = "no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …";
const nobody = "no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)";
expect(String(body.registryError)).toBe(nobody);
expect(body).not.toHaveProperty("registryProjectionError");
// The queue's own frame says nobody is registered, never "failed to load" or "repair the projection".
@@ -158,7 +158,7 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi
const loaded = loadHumanRegistry(home);
expect(loaded).toEqual({
ok: false,
error: "no human configured yet — register one: rig gateway human add <entityId> --display-name … --binding … --delivery-class …",
error: "no human configured yet — register one: rig gateway human add <entityId> --display-name … --delivery-class B [--default] (a connector --binding is optional)",
noHumans: true,
});
expect(existsSync(projectionPath(home))).toBe(false); // a load never creates the projection
@@ -58,7 +58,8 @@ async function fixture({ kernel = true, operator, ownershipUnreadable = false }:
// The ownership read fails while the request is made (the sessions lookup throws).
const real = db.prepare.bind(db);
const unreadable = ownershipUnreadable ? vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("FROM sessions WHERE session_name = ? LIMIT 1")) throw new Error("sessions unreadable");
// The receiving agent's seat read, whichever query reads it.
if (sql.includes("FROM sessions WHERE session_name = ?")) throw new Error("sessions unreadable");
return real(sql);
}) as typeof db.prepare) : null;
const issued = await (await app.request("/api/hosts/pair-request", { method: "POST", headers: { "Content-Type": "application/json" },
@@ -112,7 +113,7 @@ describe("host pairing with no kernel operator running: Approve and Deny decide,
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.humanQuestions).toEqual([{ id: "pair", question: `Pair requester@far-host (code ${f.issued.code}) with this host?`, options: [
{ id: "approve", label: "Approve: hand this host's bearer token to the requester" }, { id: "deny", label: "Deny" }] }]);
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no ${OPERATOR}).`);
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no ${OPERATOR}).`);
expect(item.body).toContain(`rig host approve ${f.issued.code}, or rig host deny ${f.issued.code}`);
expect(await f.poll()).toEqual({ http: 200, body: { status: "pending", code: f.issued.code }, leaksToken: false });
});
@@ -487,6 +488,27 @@ describe("host pairing: exact expiry, and an agent-free request says why nobody
expect(item.body).toContain(`Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: ${OPERATOR} can't receive answers on this host.`);
});
it("a receiving agent chosen in agents.operator_session owns the request, with no kernel operator here", async () => {
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@fixture-rig");
try {
const f = await fixture({ kernel: false });
const item = f.repo.getById(f.ask)!;
expect(item.sourceSession).toBe("lead@fixture-rig");
expect(item.body).not.toContain("Typed words are kept but don't decide it");
} finally { vi.unstubAllEnvs(); }
});
it("with no kernel operator here, an unusable agents.operator_session is named in the request, never as unset", async () => {
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
const f = await fixture();
const item = f.repo.getById(f.ask)!;
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.body).toContain(`you're asked again: no agent reads answers on this host (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and it has no ${OPERATOR}).`);
expect(item.body).not.toContain("no receiving agent is set");
} finally { vi.unstubAllEnvs(); }
});
it("when OpenRig can't read who owns the request, it's agent-free and says it couldn't tell", async () => {
const f = await fixture({ operator: "running", ownershipUnreadable: true });
const item = f.repo.getById(f.ask)!;
@@ -474,7 +474,7 @@ describe("pair-request — the target-side issuance handshake (FR-6)", () => {
// This host has no kernel operator, so the request is agent-free: Approve and Deny decide, typed words are kept and the
// person is asked again, and a terminal on this host can decide with rig host approve|deny.
expect(item.sourceSession).toBe("host-pair@kernel");
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (it has no operator-agent@kernel).");
expect(item.body).toContain("Answer with Approve or Deny. Typed words are kept but don't decide it, and you're asked again: no agent reads answers on this host (no receiving agent is set in agents.operator_session, and it has no operator-agent@kernel).");
expect(item.body).toContain(`To decide in a terminal on this host with its operator bearer token: rig host approve ${code}, or rig host deny ${code} (OPENRIG_AUTH_BEARER_TOKEN set, or --bearer <token>).`);
});
@@ -1,7 +1,7 @@
// A person's mode ends at the scheduled end they chose: armed with the application on the watchdog's own clock, it ends
// that application only, with the same end-if-mine path as "I'm back", and tells the kernel operator to give the account.
// Real SQLite (all migrations), real person records in a temp home, the real queue and watchdog jobs table.
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -76,7 +76,60 @@ describe("a mode's scheduled end", () => {
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // orch@rig is a routable seat
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "orch@rig" });
expect(f.toOperator()[0]!.body).toContain(`Sent to orch@rig because ${KERNEL_OPERATOR_SESSION} isn't on this host.`);
expect(f.toOperator()[0]!.body).toContain(`Sent to orch@rig because this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host).`);
});
it("a receiving agent chosen in agents.operator_session is told, ahead of the seat that applied the mode", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run();
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "lead@rig");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "lead@rig" });
} finally { vi.unstubAllEnvs(); }
});
it("when the host's seats can't be read, no other recipient is chosen: the notice stays owed for the next pass", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // the applier would be routable
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
const jobId = f.jobFor(app.applicationId)!.jobId;
const real = f.db.prepare.bind(f.db);
const spy = vi.spyOn(f.db, "prepare").mockImplementation(((sql: string) => {
if (sql.includes("FROM sessions WHERE session_name = ? ORDER BY rowid")) throw new Error("sessions unreadable");
return real(sql);
}) as typeof f.db.prepare);
try {
expect(await f.run(jobId, END)).toMatchObject({ state: "notice-pending", why: expect.stringContaining("OpenRig couldn't tell whether this host has a receiving agent") });
} finally { spy.mockRestore(); }
expect(f.toOperator()).toEqual([]);
expect(new WatchdogJobsRepository(f.db).getById(jobId)?.state).not.toBe("terminal");
});
it("a receiving agent used instead of an unusable setting is told why, in the notice", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-k', 'kernel')").run();
f.db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES ('n-op', 'r-k', 'operator.agent')").run();
f.db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES ('s-op', 'n-op', ?)").run(KERNEL_OPERATOR_SESSION);
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "op").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: KERNEL_OPERATOR_SESSION });
} finally { vi.unstubAllEnvs(); }
expect(f.toOperator()[0]!.body).toContain(`Sent to ${KERNEL_OPERATOR_SESSION} because agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored.`);
});
it("with no kernel operator here, an unusable setting is named in the reason, never as unset", async () => {
const f = fixture();
f.db.prepare("INSERT INTO rigs (id, name) VALUES ('r-rig', 'rig')").run(); // orch@rig is a routable seat
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "orch@rig").application!;
vi.stubEnv("OPENRIG_AGENTS_OPERATOR_SESSION", "alex@external");
try {
expect(await f.run(f.jobFor(app.applicationId)!.jobId, END)).toEqual({ state: "ended", notified: "orch@rig" });
} finally { vi.unstubAllEnvs(); }
const body = f.toOperator()[0]!.body;
expect(body).toContain(`Sent to orch@rig because this host has no receiving agent (agents.operator_session is "alex@external", which isn't an agent's session, so it's ignored, and ${KERNEL_OPERATOR_SESSION} isn't on this host).`);
expect(body).not.toContain("no agents.operator_session");
});
it("applied by a named operator outside any seat, with no kernel operator here, the person is told, as an update", async () => {
@@ -88,7 +141,7 @@ describe("a mode's scheduled end", () => {
expect(row).toMatchObject({ dest: "alex@external", intent: "update" });
expect(row.body).toBe(`Your asleep ended at its scheduled time. Its account: \`rig gateway human mode account alex ${app.applicationId}\`; ask your operator to go through it.`);
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal",
terminalReason: `ended at its scheduled time; alex@external told (${KERNEL_OPERATOR_SESSION} isn't on this host, and op, who applied it, isn't an address the queue accepts)` });
terminalReason: `ended at its scheduled time; alex@external told (this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host), and op, who applied it, isn't an address the queue accepts)` });
});
it("a person registry that can't be read leaves the notice owed, not lost; once it reads again the person is told once", async () => {
@@ -114,7 +167,7 @@ describe("a mode's scheduled end", () => {
const jobId = f.jobFor(app.applicationId)!.jobId;
expect(removeHumanFragment("alex", { inflight: [] }, f.home).ok).toBe(true);
expect(await f.run(jobId, END)).toEqual({ state: "ended", notified: undefined });
const reason = `ended at its scheduled time; no notice: ${KERNEL_OPERATOR_SESSION} isn't on this host, op, who applied it, isn't an address the queue accepts, and alex isn't registered`;
const reason = `ended at its scheduled time; no notice: this host has no receiving agent (no agents.operator_session, and ${KERNEL_OPERATOR_SESSION} isn't on this host), op, who applied it, isn't an address the queue accepts, and alex isn't registered`;
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal", terminalReason: reason });
await f.run(jobId, new Date("2026-10-21T07:05:00Z")); // a second pass changes nothing
expect(new WatchdogJobsRepository(f.db).getById(jobId)).toMatchObject({ state: "terminal", terminalReason: reason });
@@ -1,10 +1,11 @@
// Refocus: the hook's time budget, its failure report, and overlapping events for one occupant.
// Refocus: the hook's time budget against the harness's outer kill, its failure report, and (characterized, unresolved)
// overlapping events for one occupant.
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
// harness overlaps these events natively is not established here.
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -61,7 +62,7 @@ echo "TRACE OK: work and topology"
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000): Promise<Run> {
const transcript = join(root!, `${session}.jsonl`);
if (!existsSync(transcript)) writeFileSync(transcript, "");
const started = Date.now();
@@ -80,7 +81,7 @@ function hook(f: Fixture, event: string, session: string, extra: Record<string,
child.stderr.on("data", (chunk) => { stderr += chunk; });
const timer = setTimeout(() => { killed = true; child.kill("SIGKILL"); }, outerMs);
child.on("close", () => { clearTimeout(timer); done({ killed, ms: Date.now() - started, stdout, stderr }); });
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next", prompt_id: promptId }));
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next" }));
});
}
@@ -89,6 +90,7 @@ const stateOf = (f: Fixture, session: string) =>
JSON.parse(readFileSync(join(f.home, "refocus", `${SEAT}__${session}.json`), "utf8")) as Record<string, unknown>;
const healthOf = (f: Fixture) => readdirSync(join(f.home, "refocus")).filter((n) => n.endsWith(".health.json"))
.map((n) => JSON.parse(readFileSync(join(f.home, "refocus", n), "utf8")) as { failures?: number });
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe("refocus: the registrations' outer bound", () => {
it("allows refocus.cjs 60 s on both harnesses and leaves every other hook at 5 s", () => {
@@ -203,8 +205,12 @@ describe("refocus: the time budget", () => {
}, 60_000);
});
describe("refocus: overlapping events for one occupant", () => {
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
describe("refocus: overlapping events for one occupant (UNRESOLVED; characterized, not fixed)", () => {
// These describe main's behaviour, which this change keeps: two hook runs for one occupant are not serialized, so
// overlapping hook processes can lose a just-recorded compaction or deliver one refocus twice. Whether a harness
// overlaps these events natively is NOT ESTABLISHED; the gap is open, not shown safe. If a later change serializes
// the state, these flip and should then assert the fixed behaviour.
it("can lose a PostCompact that lands while an earlier refocus is being delivered", async () => {
const f = fixture();
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
@@ -214,7 +220,7 @@ describe("refocus: overlapping events for one occupant", () => {
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
const delivering = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-1");
});
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 10000 });
await hook(f, "PostCompact", "s1");
@@ -223,142 +229,21 @@ describe("refocus: overlapping events for one occupant", () => {
writeFileSync(release, "");
}
expect(context(await delivering)).toContain("REFOCUS");
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-1"))).toBe("");
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-2"))).toContain("REFOCUS (just compacted");
// The delivery wrote back the state it read before the PostCompact: the compaction's pending is gone.
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
}, 20_000);
it("delivers a pending refocus once when two handlers overlap", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
const revision = stateOf(f, "s1").pendingRevision;
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-A");
expect(context(second)).toBe("");
expect(second.stderr).toContain("another hook is delivering");
expect(stateOf(f, "s1").pendingRevision).toBe(revision);
} finally {
writeFileSync(release, "");
expect(context(await first)).toContain("REFOCUS");
}
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
}, 20_000);
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
it("can deliver one pending refocus twice when two prompts overlap", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-B");
expect(context(second)).toBe("");
} finally {
writeFileSync(release, "");
expect(context(await first)).toContain("REFOCUS");
}
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: expect.any(String) });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-C"))).toContain("REFOCUS");
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
await wait(200);
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
const delivered = (await Promise.all([first, second])).filter((r) => context(r).includes("REFOCUS"));
expect(delivered).toHaveLength(2);
}, 20_000);
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
const f = fixture();
const env = { OPENRIG_REFOCUS_NOW: "1" };
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
});
it("recovers pending work after the rendering hook is killed", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const interrupted = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 1000);
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
expect(await interrupted).toMatchObject({ killed: true, stdout: "" });
} finally {
writeFileSync(release, "");
}
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact", deliveryClaim: { pid: expect.any(Number) } });
const resumed = await hook(f, "UserPromptSubmit", "s1");
expect(resumed.stderr).toContain("prior delivery owner could not be confirmed");
expect(context(resumed)).toContain("REFOCUS (just compacted");
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("uses a shared successor guard after its former owner exits", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
const token = `${owner}-00000000-0000-4000-8000-000000000001`;
symlinkSync(token, guard);
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
const runs = await Promise.all([first, second]);
expect(runs.filter((run) => context(run).includes("REFOCUS"))).toHaveLength(1);
expect(readlinkSync(guard)).toBe(token);
});
it("does not take a successor when the old guard was released before the owner exited", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
const guard = stateFile + ".guard";
const oldPid = spawnSync(process.execPath, ["-e", ""]).pid;
symlinkSync(`${oldPid}-00000000-0000-4000-8000-000000000001`, guard);
const liveToken = `${process.pid}-00000000-0000-4000-8000-000000000002`;
const result = spawnSync(process.execPath, ["-e", `
const fs = require("node:fs");
const [helper, stateFile, liveToken, oldPid] = process.argv.slice(1);
const kill = process.kill;
process.kill = (pid, signal) => {
if (pid === Number(oldPid)) {
fs.unlinkSync(stateFile + ".guard");
fs.symlinkSync(liveToken, stateFile + ".guard");
}
return kill(pid, signal);
};
const access = require(helper).openRefocusState(stateFile);
console.log(access.exclusive);
access.close();
`, join(dirname(HOOK), "refocus-state.cjs"), stateFile, liveToken, String(oldPid)], { encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("false");
expect(readlinkSync(guard)).toBe(liveToken);
});
it("reports an unreadable guard and leaves the pending compaction available", async () => {
const f = fixture();
await hook(f, "UserPromptSubmit", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
mkdirSync(guard);
const compact = await hook(f, "PostCompact", "s1");
expect(compact.stderr).toContain("could not coordinate state");
const uncoordinated = await hook(f, "UserPromptSubmit", "s1");
expect(uncoordinated.stderr).toContain("could not coordinate state");
expect(context(uncoordinated)).toContain("REFOCUS (just compacted");
rmSync(guard, { recursive: true });
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toContain("REFOCUS (just compacted");
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("still delivers to each of two occupants of one seat when they overlap", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
+2 -2
View File
@@ -70,7 +70,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("an earlier record that can't be read makes the history unknown, never 'no record' or an older time", () => {
it.skipIf(process.getuid?.() === 0)("an earlier record that can't be read makes the history unknown, never 'no record' or an older time (root bypasses mode 000)", () => {
withHome((home) => {
// Alone: an unreadable earlier record, and the seat now has another conversation (or no resume token).
prompt(home, "earlier");
@@ -90,7 +90,7 @@ describe("seat status reads what OpenRig's own refocus hook recorded", () => {
});
});
it("a state folder that can't be read reads unknown, not 'no record'", () => {
it.skipIf(process.getuid?.() === 0)("a state folder that can't be read reads unknown, not 'no record' (root bypasses mode 000)", () => {
withHome((home) => {
prompt(home, conversation);
const dir = join(home, "refocus");
+24
View File
@@ -0,0 +1,24 @@
// The seat boundary's resolver: today's two kinds, and a stored value that's neither read as unknown, never as tmux.
import { describe, it, expect } from "vitest";
import { resolveSeatKind, seatInboundTransport } from "../src/domain/seat-kinds.js";
describe("resolveSeatKind", () => {
it.each([
[null, { kind: "tmux" }],
[undefined, { kind: "tmux" }],
["tmux", { kind: "tmux" }],
["external_cli", { kind: "external_cli" }],
["herdr", { kind: "unknown", stored: "herdr" }],
["", { kind: "unknown", stored: "" }],
["TMUX", { kind: "unknown", stored: "TMUX" }],
] as const)("reads %j as %j", (stored, expected) => {
expect(resolveSeatKind(stored)).toEqual(expected);
});
it("gives an external CLI seat no inbound transport, and every other kind today's tmux path", () => {
expect(seatInboundTransport({ kind: "external_cli" })).toBe("none");
expect(seatInboundTransport({ kind: "tmux" })).toBe("tmux");
// An unknown stored kind keeps the path it takes today; the resolver still names it unknown.
expect(seatInboundTransport({ kind: "unknown", stored: "herdr" })).toBe("tmux");
});
});
@@ -2741,6 +2741,27 @@ describe("SessionTransport", () => {
expect(hasSessionSpy).not.toHaveBeenCalled();
});
// A stored kind that isn't one of today's (only reachable by writing the column directly) takes today's path, the tmux
// one, for send and capture: the seat boundary adds no refusal and changes nothing for it.
it("a binding whose stored kind isn't tmux or external_cli sends and captures through tmux, as before the boundary", async () => {
const { node } = seedCanonicalRig();
db.prepare("UPDATE bindings SET attachment_type = 'herdr' WHERE node_id = ?").run(node.id);
const callOrder: string[] = [];
const tmux = mockTmux({
sendText: async () => { callOrder.push("sendText"); return { ok: true }; },
sendKeys: async (_t, keys) => { callOrder.push(`sendKeys:${keys.join(",")}`); return { ok: true }; },
capturePaneContent: async () => "line1",
});
const transport = createTransport(tmux);
const sent = await transport.send("dev-impl@my-rig", "hello");
expect(sent.reason).not.toBe("transport_unavailable");
expect(callOrder).toContain("sendText");
const captured = await transport.capture("dev-impl@my-rig");
expect(captured.ok).toBe(true);
expect(captured.content).toContain("line1");
});
// Test 11: resolveSessions by rig returns running sessions
it("resolveSessions by rig returns running sessions", async () => {
seedCanonicalRig();
+2 -1
View File
@@ -18,7 +18,8 @@ describe("Slice-11 config — first-class + honest unconfigured (item 5)", () =>
expect(configFileExists(home)).toBe(false);
const cfg = loadConfig(home);
expect(cfg.enabled).toBe(false);
expect(cfg.inboundDestination).toBe("operator-agent@kernel");
// Unchosen: the host's default receiving agent, resolved when the connector starts.
expect(cfg.inboundDestination).toBe("default-receiver");
expect(cfg).not.toHaveProperty("alertTag");
});
@@ -148,7 +148,7 @@ describe("an alert on an update", () => {
try {
const repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
await expect(repo.create({ ...request, humanIntent: "update" })).rejects.toThrow(
"humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices and follow Slack's posting floor and delivery settings.");
"humanAlert marks an ask to a person as alert-worthy, and an update isn't an ask: send it without --alert; updates are notices; delivery depends on the person's connector and settings.");
} finally { db.close(); }
});
});
+1
View File
@@ -51,6 +51,7 @@ const LABELS: Record<string, string> = {
"workspace.projects_root": "Project folders", "workspace.specs_root": "Spec folders",
"workspace.steering_path": "Steering file", "workspace.catalog_path": "Workspace catalog",
"workspace.operator_seat_name": "Operator seat", "db.path": "Database",
"agents.default_receiver": "Default receiving agent", "agents.operator_session": "Receiving agent (chosen)",
"topology.root": "Topology root", "context.root": "Context root", "skills.root": "Skills root",
"ui.timezone": "Timezone", "workflow.exception_routing": "Exception routing",
"queue.wake_retry_interval_seconds": "Retry interval", "queue.wake_retry_cap": "Retry limit",
+15
View File
@@ -145,6 +145,21 @@ person needs in their project or home folder and name that path.
seat sees that name, recorded as declared, and can reply to them if they're registered (`rig gateway human list`).
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks go to them, and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
need a person to reach. Ask the name to use (their login name is only a suggestion). On yes, run
`rig gateway human add <id> --display-name "<name>" --delivery-class B --default`: they're reached in the TUI's Feed
and Steering, and Slack can be added later. On no or not now, register nothing and say that questions to a person
can't reach anyone until someone is registered, with that command for later.
- Never pass `--default` over an existing default, or `--replace`, unless they ask. Their own words decide: a
different name, Slack now, or not now.
- An agent reads their answers: the kernel operator, or the agent chosen in `agents.operator_session`. If the kernel
isn't running here and no agent is chosen, offer to choose the team's lead (`rig config set agents.operator_session
<seat>@<rig>`), on their yes only.
- A machine they'll pair with needs its own person: registering here doesn't give that machine one.
- **Give the operator the goal:** ask what they want worked on, in which repository and on which branch, unless they've
said. Find the `operator.agent` row in `rig ps --nodes --rig kernel --json`, take its `canonicalSessionName`
(never the logical ID or a guessed name) and send: