Compare commits

...
Author SHA1 Message Date
core-builder-b 12e72889af fix(daemon): a person's canonical spelling reads unknown when the person registry can't be read
A person is resolved before a hosted member or seat, as the queue does, and a registered person's id can be spelled
`member@rig` as well as `<id>@external`. So when the registry can't be read, or there's no queue to read it from, any
external or canonical address reads unknown with the registry reason, instead of the seat or hosted member with that
name, or "no registered person". The reserved terminal person's seat is still a person. Nobody registered yet is a
readable registry with no one in it, so on a daemon with no people every seat still reads as itself.

The probe only: no send admission, routing or refusal changes. The docs also say a prompt answer typed without Enter
is unknown when the send succeeded and failed-effect-unknown when it failed after typing.
2026-11-13 19:30:35 +00:00
core-builder-b a00d0e3fe1 fix(daemon): an Enter-less prompt answer reads unknown, and only a registered person is a person
Round-one review of the member-connection probe and the delivery outcome
found two errors and one wording problem.

- A prompt answer typed without Enter succeeds with the submission
  unverified. The transport already records that as text_only, but its
  outcome read accepted, which means typed and submitted. The outcome is
  now read from the transport's own writtenFact, so the two can't
  disagree:
  - submitted (or submit-only) is accepted;
  - text only on a send that succeeded is unknown;
  - input that may have landed is failed-effect-unknown;
  - none written is not-delivered;
  - held stays held.
  An ordinary send whose screen check came back unconfirmed after Enter
  is still accepted.
- ?session=<address> treated any address shaped like a person's
  (<id>@external) as a person on the gateway route, even with nobody
  registered. Now it's a person only when the human registry resolves
  it. The terminal person's own seat (human@host, human-<x>@kernel|host)
  is kept, by name. An unregistered address reads unknown with the
  reason. If the registry can't be read, or isn't wired in, a person's
  address reads unknown and says so. The lexical admission and routing
  helpers are unchanged.
- A seat's "live" is what OpenRig has recorded as its current session,
  not an observed pane or process. The comments and docs now say
  "recorded as current", and that a declaration isn't readiness.
2026-11-13 16:30:44 +00:00
core-builder-b 0a246012f6 feat(daemon): connections declare what they support, and each delivery says what happened (member-connection.v1)
OpenRig's delivery routes made claims about members that nothing
declared. A wake typed into a terminal, a hosted member's trigger and a
person's gateway each support different things. A client couldn't ask
which, so it guessed, and a missing ability read as idle or as success.

GET /api/member-connection/capabilities now lists each route's
declaration:
- The routes are typed (by runtime, with * for any other), Codex's own
  codex-queue command (declared, not armed), hosted-trigger and gateway.
- Capabilities come in the design's four groups: observe, control,
  native identity and continuity.
- Each reads supported, not-supported, unavailable, not-applicable or
  unknown. A missing key means unknown, never idle or success.
- ?session=<address> resolves one member, read by seat rather than by
  the newest session row with that name: a person, a hosted member or a
  seat. A seat with no live terminal keeps its declaration, with what
  needs the terminal marked unavailable. An address nobody has reads
  every capability unknown, with the reason.

Each typed send's delivery evidence also records its outcome in the
shared words: { fact, reporter, route }. The fact is accepted (with
renderVerified when the screen showed it), held, not-delivered,
failed-effect-unknown or unknown. None of them means the member read
the work.

Two reads now say when what they list is partial:
- An ask's about is the newest 100 open rows blocked on it, and
  aboutTruncated says when there were more.
- An ask's answers carry answersFrom: "this-daemon", since an answer
  another host holds isn't read.
An empty list in either is not proof of none. A scoped MCP read drops
answersFrom along with answers.

Nothing here refuses anything, and no existing field changes meaning.
team-operations.ts now takes its contract name from the shared contract
module instead of defining it a second time.
2026-11-13 15:26:31 +00:00
16 changed files with 425 additions and 14 deletions

No files matched your search

+33 -2
View File
@@ -179,7 +179,8 @@ With `needs-me.read` supported:
`revision` and `about`. `revision` is the request's opaque decision revision: the same value as that row's
`decisionRevision`, and what an answer can name as `expectedRevision` so it applies only to the version the person
read. `about` lists the requests it concerns by id: a row parked on the person is itself the request, and an ask is
about the open rows blocked on it (at most 100; empty when none are recorded).
about the open rows blocked on it (the newest 100, with `aboutTruncated: true` when there are more). An empty `about`
isn't proof that it's about nothing: a request that names it only in its text isn't counted.
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
already reflected in it. A click that records or changes an answer emits `queue.answer_recorded` even while the set
@@ -191,7 +192,37 @@ With `needs-me.read` supported:
footer. `episode` names the park an answer belongs to, or is null. `recordedIdentity` is the identity provenance
recorded when the answer row was created. An answer row's own read has `replyTo`, the request it answers. A partial
set of choices stays on the request's `humanAnswers` until the whole set is returned. A runner's permission request
or a host pairing request is answered in place, with no answer row.
or a host pairing request is answered in place, with no answer row. `answersFrom: "this-daemon"` says where they were
read: an answer another host holds isn't among them, so an empty list isn't proof that nobody answered.
### What a connection supports, and what happened to a delivery (`member-connection.v1`)
`GET /api/member-connection/capabilities` lists each delivery route this daemon declares, with what it supports in four
groups: `observe` (`dialog`, `turnStart`), `control` (`submit`), `nativeIdentity` (`sessionId`, `resume`) and
`continuity` (`managedCompaction`, `restorePacketConfirmation`, `fork`). The routes are `typed` (OpenRig types into the
member's terminal; capabilities by runtime, with `*` for any other), `codex-queue` (Codex's own queue command), a hosted
member's `hosted-trigger`, and a person's `gateway`. A route with `armed: false` is declared but not used: nothing is
delivered on it until a setting arms it.
Each capability reads `supported`, `not-supported`, `unavailable` (the route has it, but this member can't use it now,
such as a seat with no session recorded as current), `not-applicable` (it means nothing on this route) or `unknown`. A
missing key means `unknown`. None of them means idle or success, and a declaration isn't readiness: whether a seat has a
current session comes from what OpenRig has recorded, not from looking at its pane or process. A daemon without the route (HTTP 404) predates the contract: treat
every capability as unknown and keep using what works today.
`?session=<address>` adds `member`: `{ member, route, runtime, capabilities, reason? }` for the member that address
names: a person the human registry resolves (or the terminal person's own `human@host`), a hosted member, or a seat. An
address shaped like a person's, such as `<id>@external`, is a person only when the registry resolves it. A person is
resolved first, so when the registry can't be read, any address it could resolve to a person (`<id>@external`, or any
`member@rig`, since a person's id can be spelled that way) comes back unknown, even if a seat or hosted member has that name. That
answer, and an address none of them has, comes back with `route: null`, every capability `unknown`, and the reason.
Each typed send also records what happened to it, in the daemon's `transport.delivery_evidence` event, as
`outcome: { fact, reporter, route }`. `fact` is `accepted` (typed and submitted, with `renderVerified: true` when
the screen showed it), `held`, `not-delivered`, `failed-effect-unknown` or `unknown`. A prompt answer typed without Enter on a send that
succeeded is `unknown`: whether the prompt took it isn't known; if the send failed after typing, it's
`failed-effect-unknown`. `reporter` says who established it.
None of them means the member read the work: picking it up is a claim on the row.
## Route details
+1
View File
@@ -325,6 +325,7 @@ function withoutDaemonLinkedRows<T>(row: T): T {
delete fields.successorsTruncated;
delete fields.successorNote;
delete fields.answers;
delete fields.answersFrom;
return fields as T;
}
@@ -1110,10 +1110,11 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
db.prepare("UPDATE queue_items SET destination_session = 'human-x@external' WHERE qitem_id = ?").run(own);
await repo.createHumanAnswer({ sourceSession: "human-x@external", destinationSession: CALLER, body: "The person's private reasoning",
inboundReplyTo: own, nudge: false } as never);
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }] });
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }], answersFrom: "this-daemon" });
const shown = await asExternal("rig_queue_show", { qitemId: own });
expect(shown.isError).toBe(false);
expect(shown.structured.item).not.toHaveProperty("answers");
expect(shown.structured.item).not.toHaveProperty("answersFrom");
expect(shown.text).not.toContain("private reasoning");
});
+4 -1
View File
@@ -28,8 +28,11 @@ export interface AttentionItem {
/** A queue request's decision revision: the same opaque token its single-row read returns and a settling act can name. */
revision?: string;
/** The requests this is about, by ID: a parked row is itself the request; an ask is about the open rows blocked on it
* (at most 100). Empty when no request is recorded as waiting on it. */
* (at most 100). Empty when no request is recorded as waiting on it, which isn't proof that it's about nothing: a
* request that names it only in its text isn't counted. */
about?: string[];
/** Present when more than 100 open rows are blocked on this ask: `about` is the newest 100, not all of them. */
aboutTruncated?: true;
}
export interface AttentionDetail {
item: AttentionItem;
@@ -186,3 +186,139 @@ export function readWakeOutcome(recorded: string | null | undefined): WakeOutcom
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
return at("unknown", null);
}
/** A delivery route as the member-connection contract names it. `typed` is OpenRig typing into the member's terminal
* (what `WakeRoute` calls `terminal`); `codex-queue` is Codex's own queue command, a route that doesn't type. */
export type ConnectionRoute = "typed" | "codex-queue" | "hosted-trigger" | "gateway";
/** What a connection says about one capability. `unavailable`: the connection has it, but it can't be used for this member
* now (a terminal seat with no session recorded as current). `not-applicable`: it means nothing on this route (a dialog check on a webhook).
* A client reads an absent key as `unknown`, never as `not-supported`, idle or success. */
export type Declared = "supported" | "not-supported" | "unavailable" | "not-applicable" | "unknown";
/** A connection's declared capabilities, in the design's four groups. Each says what this build of OpenRig can do on the
* route, not what the member's program could do. */
export interface ConnectionCapabilities {
observe: {
/** A dialog or picker on the member's screen is read before typing, so a send doesn't answer it. */
dialog: Declared;
/** A turn's start is correlated to the delivery that caused it. */
turnStart: Declared;
};
control: {
/** OpenRig can submit input for the member (type and press Enter). */
submit: Declared;
};
nativeIdentity: {
/** The program's own session id is captured. */
sessionId: Declared;
/** A stopped member can be resumed into its own conversation. */
resume: Declared;
};
continuity: {
/** OpenRig runs the member's compaction when its context fills. */
managedCompaction: Declared;
/** A handover's restore packet is seen leaving the member's input box, not only typed and submitted. */
restorePacketConfirmation: Declared;
/** A handover can fork the member's conversation. */
fork: Declared;
};
}
/** One route's declaration. A route that isn't armed is declared but not used: nothing is delivered on it until a
* setting arms it. */
export interface RouteDeclaration {
route: ConnectionRoute;
armed: boolean;
/** Capabilities by runtime for the typed route (`*` is any other runtime); one set for the others. */
capabilities: Record<string, ConnectionCapabilities>;
}
const caps = (dialog: Declared, turnStart: Declared, submit: Declared, sessionId: Declared, resume: Declared,
managedCompaction: Declared, restorePacketConfirmation: Declared, fork: Declared): ConnectionCapabilities =>
({ observe: { dialog, turnStart }, control: { submit }, nativeIdentity: { sessionId, resume },
continuity: { managedCompaction, restorePacketConfirmation, fork } });
/** What this build declares on each route. Turn starts aren't correlated to deliveries yet on any route. Managed compaction
* and restore-packet confirmation read Claude Code's own screen, so other runtimes don't have them. Codex's queue command
* is declared and not armed: nothing uses it until a route setting does, and what it would support is unknown until it's built. A hosted member's trigger and a person's
* gateway carry no terminal, so the terminal capabilities don't apply to them. */
export const ROUTE_DECLARATIONS: readonly RouteDeclaration[] = [
{ route: "typed", armed: true, capabilities: {
"claude-code": caps("supported", "not-supported", "supported", "supported", "supported", "supported", "supported", "supported"),
codex: caps("supported", "not-supported", "supported", "supported", "supported", "not-supported", "not-supported", "supported"),
"*": caps("not-supported", "not-supported", "supported", "unknown", "unknown", "not-supported", "not-supported", "unknown"),
} },
{ route: "codex-queue", armed: false, capabilities: {
codex: caps("not-applicable", "unknown", "unknown", "unknown", "unknown", "unknown", "not-applicable", "unknown"),
} },
{ route: "hosted-trigger", armed: true, capabilities: {
"*": caps("not-applicable", "unknown", "not-applicable", "unknown", "unknown", "not-applicable", "not-applicable", "not-applicable"),
} },
{ route: "gateway", armed: true, capabilities: {
"*": caps("not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable", "not-applicable"),
} },
];
/** The capabilities this build declares for a route and runtime: the runtime's own entry, else the route's `*` entry,
* else every capability unknown. */
export function declaredCapabilities(route: ConnectionRoute, runtime: string | null | undefined): ConnectionCapabilities {
const declared = ROUTE_DECLARATIONS.find((d) => d.route === route)?.capabilities;
return (runtime ? declared?.[runtime] : undefined) ?? declared?.["*"] ?? UNKNOWN_CAPABILITIES;
}
const UNKNOWN_CAPABILITIES = caps("unknown", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown");
/** One member's connection as the daemon resolves it: the route it's reached on and its runtime, with that route's
* declaration. `unavailable` marks what the route has but this member can't use now. Null route: the address names no
* member this daemon can reach, and every capability is unknown. */
export interface MemberConnection {
member: string;
route: ConnectionRoute | null;
runtime: string | null;
capabilities: ConnectionCapabilities;
/** Why the route is null, or why a capability is unavailable. */
reason?: string;
}
// A declaration says what a route can do, never that the member is ready now: `live` below is what OpenRig has recorded
// as the seat's current session, not an observed pane or process.
export function memberConnection(member: string, found: { kind: "person" } | { kind: "hosted" }
| { kind: "seat"; runtime: string | null; /** A session recorded as current, not an observed pane. */ live: boolean }
| { kind: "none"; reason: string }): MemberConnection {
if (found.kind === "person") return { member, route: "gateway", runtime: null, capabilities: declaredCapabilities("gateway", null) };
if (found.kind === "hosted") return { member, route: "hosted-trigger", runtime: null, capabilities: declaredCapabilities("hosted-trigger", null) };
if (found.kind === "none") return { member, route: null, runtime: null, capabilities: UNKNOWN_CAPABILITIES, reason: found.reason };
const declared = declaredCapabilities("typed", found.runtime);
if (found.live) return { member, route: "typed", runtime: found.runtime, capabilities: declared };
// A seat with no session recorded as current keeps its declaration; what needs its terminal can't be used until it has one.
const live = (value: Declared): Declared => value === "supported" ? "unavailable" : value;
return { member, route: "typed", runtime: found.runtime, reason: "it has no session recorded as current",
capabilities: { ...declared, observe: { dialog: live(declared.observe.dialog), turnStart: declared.observe.turnStart },
control: { submit: live(declared.control.submit) } } };
}
/** What a typed send's input reached, as the transport reads it from the send's result (`writtenFact`). */
export type WrittenFact = "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown";
/** What a typed send's own result says happened to it, in the shared words, read from what its input reached (`written`,
* the transport's own reading) so the two can't disagree. Submitted is `accepted` (with `renderVerified` when the screen
* showed it). Held by a guard is `held`. Text typed without Enter on a send that otherwise succeeded (a prompt answer
* sent without one) is `unknown`: whether the prompt took it isn't known. Input that may have landed on a failed send is
* `failed-effect-unknown`, and none written is `not-delivered`. Anything else is `unknown`. None of them means the
* member read the work. */
export function readSendOutcome(result: { ok: boolean; outcome?: string; verified?: boolean }, written: WrittenFact):
ReportedFact<DeliveryFact> & { route: "typed"; renderVerified?: true } {
const at = (fact: DeliveryFact, extra: { renderVerified?: true } = {}) => ({ fact, reporter: "openrig-observed" as const, route: "typed" as const, ...extra });
if (result.outcome === "retained") return at("held");
switch (written) {
case "submitted":
case "submit_only":
return result.outcome === "delivered" && result.verified === true ? at("accepted", { renderVerified: true }) : at("accepted");
case "text_only": return at(result.ok ? "unknown" : "failed-effect-unknown");
case "uncertain":
case "text_submit_unknown": return at("failed-effect-unknown");
case "none": return at("not-delivered");
default: return at("unknown");
}
}
@@ -0,0 +1,35 @@
import type Database from "better-sqlite3";
import { memberConnection, type MemberConnection } from "./connection-contract.js";
import { registeredPeople, resolveRegisteredHumanAddress } from "./gateway/human-registry.js";
import type { QueueRepository } from "./queue-repository.js";
import { resolveGuardTarget } from "./seat-delivery-guard.js";
import { isHumanSeatSessionRef, parseSessionName } from "./session-name.js";
import { resolveCurrentSessionAddress } from "./session-registry.js";
/** The connection an address is reached on, as this daemon resolves it: a person (the gateway), a hosted member (its
* trigger), else the seat it addresses, read by seat rather than by the newest session row with that name, and live
* when a session is recorded as its current one (a record, not an observed pane). A person is one the human registry
* resolves, or the reserved terminal person's seat (`human@host`, `human-<x>@kernel|host`); an address shaped like a
* person's (`<id>@external`) that the registry doesn't resolve isn't one. A person is resolved before a hosted member
* or seat, as the queue does, so when the registry can't be read, any spelling it could resolve to a person (an
* `@external` address or a canonical `member@rig`) reads unknown rather than as whatever else has that name. Nobody
* registered yet is a readable registry with no one in it. An address none of them has reads every capability
* unknown too, with its own reason. */
export function resolveMemberConnection(db: Database.Database, queue: Pick<QueueRepository, "humanRegistry" | "isHostedMember"> | undefined,
member: string): MemberConnection {
const parsed = parseSessionName(member);
const terminalPersonSeat = isHumanSeatSessionRef(member) && parsed.kind !== "external";
const people = queue ? registeredPeople(queue.humanRegistry()) : null;
if (terminalPersonSeat || (people && resolveRegisteredHumanAddress(member, people) !== null)) {
return memberConnection(member, { kind: "person" });
}
if (!people && (parsed.kind === "external" || parsed.kind === "canonical")) {
return memberConnection(member, { kind: "none", reason: "the person registry couldn't be read, so whether a person has this address is unknown" });
}
if (queue?.isHostedMember(member)) return memberConnection(member, { kind: "hosted" });
const target = resolveGuardTarget(db, member);
if (!target) return memberConnection(member, { kind: "none", reason: "no seat, hosted member or registered person has this address on this daemon" });
const node = db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(target.nodeId) as { runtime: string | null } | undefined;
return memberConnection(member, { kind: "seat", runtime: node?.runtime ?? null,
live: resolveCurrentSessionAddress(db, member)?.nodeId === target.nodeId });
}
@@ -31,6 +31,7 @@ import {
} from "./composer-prompts.js";
export { inspectStartupStagedText } from "./startup-submission-evidence.js";
import { OutboxHandler, OutboxHandlerError } from "./outbox-handler.js";
import { readSendOutcome, type WrittenFact } from "./connection-contract.js";
import { DraftAwareDelivery, type GuardedDeliverySummary, type QueueWakeFacts } from "./draft-aware-delivery.js";
import type Database from "better-sqlite3";
import type { RigRepository } from "./rig-repository.js";
@@ -177,7 +178,7 @@ function enterWithheld(reason: string | undefined): boolean {
* write that may have landed is uncertain. Text that was written reads text only when a pre-Enter check refused the
* Enter, and otherwise text with the submission unknown, since Enter may have been attempted. A send that wrote
* nothing says so. Anything else the result doesn't establish is unknown, never a guess. */
export function writtenFact(result: SendResult): "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown" {
export function writtenFact(result: SendResult): WrittenFact {
if (result.ok && result.outcome !== "retained") {
if (result.submitOnly) return "submit_only";
return result.promptInteraction === "unverified" ? "text_only" : "submitted";
@@ -2744,7 +2745,7 @@ export class SessionTransport {
pane: seat.pane, occupantGeneration: seat.occupant, messageId: opts?.deliveryId ?? outboxIds[0] ?? attemptId, attemptId,
admitted: admissionUnknown ? "unknown" : admitted.length > 0 ? { outboxIds: admitted } : "none", written: writtenFact(result),
terminalObserved: { outcome: result.outcome ?? null, verified: result.verified ?? null }, inputBoundary: "unknown", source: "send_result",
...wireFingerprint(wire),
...wireFingerprint(wire), outcome: readSendOutcome(result, writtenFact(result)),
});
} catch (err) {
if (!this.evidenceFailureLogged) {
@@ -1,11 +1,11 @@
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
import { TEAM_OPERATIONS_CONTRACT, type Support } from "./connection-contract.js";
/** The team-operations contract an app reads before relying on a job's fields: one contract version with per-job
* capabilities, distinct from the build label. A capability is supported, not-supported or unknown; an absent key reads
* unknown. */
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
export { TEAM_OPERATIONS_CONTRACT };
export type CapabilityValue = "supported" | "not-supported" | "unknown";
/** A team-operations capability: supported, not-supported or unknown; an absent key reads unknown. The contract an app
* reads before relying on a job's fields is one version with per-job capabilities, distinct from the build label. */
export type CapabilityValue = Support;
/** needs-me: see what needs me, answer as me, follow it through. This build reads it (what needs the person, each
* request's revision and what it's about, the answers returned to it, and a snapshot watermark). Answering under this
+4 -1
View File
@@ -1,4 +1,5 @@
import type { StartupSubmissionDiagnostic } from "./startup-submission-evidence.js";
import type { readSendOutcome } from "./connection-contract.js";
export interface Rig {
id: string;
@@ -197,7 +198,9 @@ export type RigEvent =
admitted: DeliveryEvidenceAdmitted;
written: "submitted" | "submit_only" | "text_only" | "text_submit_unknown" | "uncertain" | "none" | "unknown";
terminalObserved: DeliveryEvidenceObserved; inputBoundary: "unknown"; source: "send_result";
wireSha256: string | null; wireBytes: number | null; writtenAt: string | null }
wireSha256: string | null; wireBytes: number | null; writtenAt: string | null;
/** The attempt's outcome in the shared words, from the same result: which fact, who established it, and the route. */
outcome: ReturnType<typeof readSendOutcome> }
/** A harness's own prompt hook, as it reported: a fingerprint and byte length of the prompt it received, its own session
* and prompt or turn id, and the generation the hook ran as. A raw observation, never a message's attribution: neither
* harness reports a prompt's origin, so which message (if any) it was isn't established, and each delivery record's
+2 -1
View File
@@ -90,7 +90,8 @@ export function attentionRoutes(): Hono {
// A pairing request is also decided directly on this host's settings page, opened on this request (its row).
...(q.evidenceRef?.startsWith("pair-request:") ? { settingsLink: { category: "instance", focus: q.qitemId, label: "Open pairing settings (Approve or Deny directly)" } } : {}),
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}`,
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId] };
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId],
...(toPerson && deps.length > 100 ? { aboutTruncated: true as const } : {}) };
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
const notSent = unbound || quiet.has(q.qitemId);
const route = unanswered
+3 -1
View File
@@ -1558,8 +1558,10 @@ export function queueRoutes(): Hono {
...item,
// #2446: what a settling Mission Control act can be guarded with (expectedRevision).
decisionRevision: repo.decisionRevision(item),
// The answer rows returned to this row, read by their link rather than their text; empty when none came back.
// The answer rows returned to this row, read by their link rather than their text; empty when none came back here.
// Only this daemon's rows are read: an answer another host holds isn't in them, so empty isn't proof of none.
answers: repo.answersForAsk(qitemId),
answersFrom: "this-daemon",
// A closed row's outcome (its note and reason) lives only in the transition log; omitted while the row is open.
...(closing ? { closingTransition: {
transitionId: closing.transitionId,
+9
View File
@@ -39,6 +39,8 @@ import type { SelfAttachService } from "./domain/self-attach-service.js";
import { rigsRoutes } from "./routes/rigs.js";
import { OPERATION_CONTRACT, OPERATION_KINDS } from "./domain/operation-correlation.js";
import { teamOperationsCapabilities } from "./domain/team-operations.js";
import { MEMBER_CONNECTION_CONTRACT, ROUTE_DECLARATIONS } from "./domain/connection-contract.js";
import { resolveMemberConnection } from "./domain/member-connection.js";
import { sessionsRoutes, nodesRoutes, sessionAdminRoutes } from "./routes/sessions.js";
import { adaptersRoutes } from "./routes/adapters.js";
import { eventsRoute } from "./routes/events.js";
@@ -798,6 +800,13 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/rigs", rigsRoutes);
app.get("/api/operations/capabilities", (c) => c.json({ contract: OPERATION_CONTRACT, kinds: OPERATION_KINDS }));
app.get("/api/team-operations/capabilities", (c) => c.json(teamOperationsCapabilities()));
// What each delivery route declares it supports, and with ?session=, the route and declaration of the member that
// address names.
app.get("/api/member-connection/capabilities", (c) => {
const member = c.req.query("session")?.trim();
return c.json({ contract: MEMBER_CONNECTION_CONTRACT, routes: ROUTE_DECLARATIONS,
...(member ? { member: resolveMemberConnection(deps.rigRepo.db, deps.queueRepo, member) } : {}) });
});
app.route("/api/rigs/:rigId/sessions", sessionsRoutes);
// Slice 24 — per-rig CMUX workspace launcher.
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
@@ -0,0 +1,136 @@
// The member-connection.v1 declarations: what each delivery route says it supports, read from the running daemon's
// route and from the contract module the consumers read.
import { afterEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { resolveMemberConnection } from "../src/domain/member-connection.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import type { LoadResult } from "../src/domain/gateway/human-registry.js";
import { ROUTE_DECLARATIONS, declaredCapabilities, memberConnection, type ConnectionCapabilities } from "../src/domain/connection-contract.js";
const dbs: Database.Database[] = [];
afterEach(() => {
for (const db of dbs.splice(0)) db.close();
vi.restoreAllMocks();
});
const flatten = (c: ConnectionCapabilities) => Object.values(c).flatMap((group) => Object.values(group));
describe("declared capabilities", () => {
it("declares Codex's queue command as a route that isn't armed, and the typed route as armed", () => {
expect(ROUTE_DECLARATIONS.map((d) => [d.route, d.armed])).toEqual([["typed", true], ["codex-queue", false], ["hosted-trigger", true], ["gateway", true]]);
});
it("declares in the design's four groups, with what only Claude Code's screen gives limited to it", () => {
const claude = declaredCapabilities("typed", "claude-code"), codex = declaredCapabilities("typed", "codex");
expect(Object.keys(claude)).toEqual(["observe", "control", "nativeIdentity", "continuity"]);
expect(claude.continuity.restorePacketConfirmation).toBe("supported");
expect(codex.continuity.restorePacketConfirmation).toBe("not-supported");
expect(codex.continuity.managedCompaction).toBe("not-supported");
// No route correlates a turn start to a delivery yet.
expect(ROUTE_DECLARATIONS.flatMap((d) => Object.values(d.capabilities)).map((c) => c.observe.turnStart)).not.toContain("supported");
// Any other runtime: typing works; what it would need to read its screen isn't claimed.
expect(declaredCapabilities("typed", "some-new-runtime")).toMatchObject({ control: { submit: "supported" }, observe: { dialog: "not-supported" } });
});
it("a seat with no live terminal keeps its declaration, with what needs the terminal unavailable, never supported or idle", () => {
const stopped = memberConnection("dev-impl@r", { kind: "seat", runtime: "codex", live: false });
expect(stopped).toMatchObject({ route: "typed", runtime: "codex", reason: "it has no session recorded as current",
capabilities: { control: { submit: "unavailable" }, observe: { dialog: "unavailable" }, nativeIdentity: { resume: "supported" } } });
const nobody = memberConnection("ghost@r", { kind: "none", reason: "no one has it" });
expect(nobody).toMatchObject({ route: null, reason: "no one has it" });
expect(new Set(flatten(nobody.capabilities))).toEqual(new Set(["unknown"]));
});
});
describe("GET /api/member-connection/capabilities", () => {
function setup() {
const db = createFullTestDb();
dbs.push(db);
// The running daemon's queue, with nobody registered yet: a readable registry with no one in it.
const queueRepo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: false, error: "none", noHumans: true }) });
const app = createTestApp(db, { appDeps: { queueRepo } });
const get = async (query = "") => {
const res = await app.app.request(`/api/member-connection/capabilities${query}`);
return { status: res.status, body: await res.json() as Record<string, any> };
};
return { ...app, get };
}
it("answers every route's declaration, and the member an address names: its route, runtime and capabilities", async () => {
const t = setup();
const rig = t.rigRepo.createRig("team");
const live = t.rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
t.sessionRegistry.updateStatus(t.sessionRegistry.registerSession(live.id, "dev-impl@team").id, "running");
t.sessionRegistry.updateBinding(live.id, { tmuxSession: "dev-impl@team", tmuxPane: "%1" });
const stopped = t.rigRepo.addNode(rig.id, "dev.qa", { runtime: "codex" });
t.sessionRegistry.updateStatus(t.sessionRegistry.registerSession(stopped.id, "dev-qa@team").id, "exited");
const all = await t.get();
expect(all.status).toBe(200);
expect(all.body).toEqual({ contract: "member-connection.v1", routes: JSON.parse(JSON.stringify(ROUTE_DECLARATIONS)) });
expect((await t.get("?session=dev-impl@team")).body.member).toEqual({ member: "dev-impl@team", route: "typed", runtime: "claude-code",
capabilities: declaredCapabilities("typed", "claude-code") });
expect((await t.get("?session=dev-qa@team")).body.member).toMatchObject({ route: "typed", runtime: "codex",
capabilities: { control: { submit: "unavailable" } }, reason: "it has no session recorded as current" });
expect((await t.get("?session=human@host")).body.member).toMatchObject({ route: "gateway" });
const unknown = (await t.get("?session=nobody@nowhere")).body.member;
expect(unknown).toMatchObject({ route: null, runtime: null, reason: expect.stringContaining("no seat, hosted member or registered person") });
expect(new Set(flatten(unknown.capabilities))).toEqual(new Set(["unknown"]));
});
});
describe("resolveMemberConnection: a person is one the registry resolves, never an address's spelling", () => {
const ana = { entityId: "ana", class: "human" as const, displayName: "Ana", address: "ana@external", connectorBindings: [],
prefs: { deliveryClass: "A" as const } };
function resolver(registry: (() => LoadResult) | null) {
const db = createFullTestDb();
dbs.push(db);
// A recorded seat whose name is also a person's canonical spelling, when `ana` is registered.
const rigRepo = new RigRepository(db), sessions = new SessionRegistry(db);
const seat = rigRepo.addNode(rigRepo.createRig("team").id, "ana", { runtime: "claude-code" });
sessions.updateStatus(sessions.registerSession(seat.id, "ana@team").id, "running");
sessions.updateBinding(seat.id, { tmuxSession: "ana@team", tmuxPane: "%1" });
const queue = registry === null ? undefined
: new QueueRepository(db, new EventBus(db), { loadHumanRegistry: registry, isHostedMember: (address) => address === "assistant@team" });
return (member: string) => resolveMemberConnection(db, queue, member);
}
const unknown = (reason: string) => expect.objectContaining({ route: null, reason: expect.stringContaining(reason) });
it("an @external address nobody registered is unknown, not a person; a registered one is reached through the gateway", () => {
const empty = resolver(() => ({ ok: true as const, entities: [] }));
expect(empty("not-registered@external")).toEqual(unknown("no seat, hosted member or registered person"));
const registered = resolver(() => ({ ok: true as const, entities: [ana] }));
expect(registered("ana@external")).toMatchObject({ route: "gateway", capabilities: { control: { submit: "not-applicable" } } });
expect(registered("bob@external")).toEqual(unknown("no seat, hosted member or registered person"));
expect(registered("assistant@team")).toMatchObject({ route: "hosted-trigger",
capabilities: { observe: { dialog: "not-applicable" }, control: { submit: "not-applicable" } } });
});
it("with no registry to read, whether a person has the address is unknown; the reserved terminal person's seat is still one", () => {
const unreadable = resolver(() => ({ ok: false, error: "registry unreadable" }) as LoadResult);
expect(unreadable("ana@external")).toEqual(unknown("the person registry couldn't be read"));
const noQueue = resolver(null);
expect(noQueue("ana@external")).toEqual(unknown("the person registry couldn't be read"));
expect(noQueue("human@host")).toMatchObject({ route: "gateway" });
});
it("with no registry to read, a canonical spelling a person could have is unknown, never the seat or hosted member with that name", () => {
for (const lookup of [resolver(() => ({ ok: false, error: "registry unreadable" }) as LoadResult), resolver(null)]) {
expect(lookup("ana@team")).toEqual(unknown("the person registry couldn't be read"));
expect(lookup("assistant@team")).toEqual(unknown("the person registry couldn't be read"));
expect(lookup("nobody@team")).toEqual(unknown("the person registry couldn't be read"));
}
// Readable, the same names resolve: a person first, as the queue does, then the hosted member and the seat.
const registered = resolver(() => ({ ok: true as const, entities: [ana] }));
expect(registered("ana@team")).toMatchObject({ route: "gateway" });
expect(registered("assistant@team")).toMatchObject({ route: "hosted-trigger" });
const nobodyRegistered = resolver(() => ({ ok: false, error: "none", noHumans: true }) as LoadResult);
expect(nobodyRegistered("ana@team")).toMatchObject({ route: "typed", runtime: "claude-code" });
expect(nobodyRegistered("ana@external")).toEqual(unknown("no seat, hosted member or registered person"));
});
});
@@ -192,6 +192,21 @@ describe("needs-me.read: the attention snapshot", () => {
const item = (qitemId: string) => snapshot.items.find((i) => i.id === `queue:${qitemId}`);
expect(item(id)).toMatchObject({ revision: (await f.read(id)).decisionRevision, about: [waiting.qitemId] });
expect(item(park.qitemId)).toMatchObject({ revision: (await f.read(park.qitemId)).decisionRevision, about: [park.qitemId] });
expect(item(id)).not.toHaveProperty("aboutTruncated");
});
it("says when what it lists is partial: about past 100 dependents, and answers read from this daemon only", async () => {
const f = fixture(); const id = await f.ask();
const waiting: string[] = [];
for (let i = 0; i < 101; i++) {
waiting.push((await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: `Waits ${i}`, nudge: false })).qitemId);
}
f.db.prepare(`UPDATE queue_items SET state = 'blocked', blocked_on = ? WHERE qitem_id IN (${waiting.map(() => "?").join(",")})`).run(id, ...waiting);
const item = (await f.attention()).items.find((i) => i.id === `queue:${id}`);
expect(item?.about).toHaveLength(100);
expect(item).toMatchObject({ aboutTruncated: true });
// No answer here isn't proof of none: another host's answer isn't read.
expect(await f.read(id)).toMatchObject({ answers: [], answersFrom: "this-daemon" });
});
});
@@ -84,6 +84,10 @@ describe("explicit prompt answer delivery", () => {
expect(result.promptInteraction).toBe("unverified");
expect(result.warning).toContain("submission unverified");
expect(db.prepare("SELECT count(*) AS n FROM events WHERE type = 'transport.prompt_override'").get()).toEqual({ n: 1 });
// Its delivery record: the answer typed with no Enter, so whether the prompt took it is unknown, never accepted.
const recorded = (db.prepare("SELECT payload FROM events WHERE type = 'transport.delivery_evidence'").all() as Array<{ payload: string }>)
.map((row) => JSON.parse(row.payload));
expect(recorded).toEqual([expect.objectContaining({ written: "text_only", outcome: { fact: "unknown", reporter: "openrig-observed", route: "typed" } })]);
});
it.each(["claude-code", "codex"] as const)("uses a pending permission to deliver an explicit still-staged text answer for %s", async runtime => {
@@ -21,6 +21,7 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { classifyPaneActivity, classifyStyledFrame, draftDeliveryAllows, readDraftDeliverySetting, hasExpectedStagedText, inspectStartupStagedText, probeSessionActivity, promptTextIsFaint, SessionTransport, writtenFact, type TransportReadinessChecker } from "../src/domain/session-transport.js";
import { readSendOutcome } from "../src/domain/connection-contract.js";
import { SeatDeliveryGuard, resolveGuardTarget, type GuardTarget } from "../src/domain/seat-delivery-guard.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
@@ -2785,6 +2786,38 @@ describe("SessionTransport", () => {
expect(withEvidence.outcome).toBeUndefined();
expect(fact).toMatchObject({ written: "submitted", terminalObserved: { outcome: null, verified: null } });
expect(fact.messageId).toBe(fact.attemptId);
// In the shared words: the typed route took it, as OpenRig saw. Never that the member read it.
expect(fact.outcome).toEqual({ fact: "accepted", reporter: "openrig-observed", route: "typed" });
});
it("records a typed wake to a Codex seat with its own labelled outcome, on the typed route", async () => {
seedCanonicalRig("codex");
await createTransport(typing(), { eventBus: new EventBus(db) }).send("dev-impl@my-rig", "hello");
expect(evidence()).toEqual([expect.objectContaining({ sessionName: "dev-impl@my-rig",
outcome: { fact: "accepted", reporter: "openrig-observed", route: "typed" } })]);
});
it("reads each result shape's outcome in the shared words: unknown when the result doesn't establish one", () => {
const s = "dev-impl@my-rig";
expect([
{ ok: true, sessionName: s },
{ ok: true, sessionName: s, verified: true, outcome: "delivered" },
{ ok: true, sessionName: s, verified: false, outcome: "rendered-unconfirmed" },
{ ok: true, sessionName: s, outcome: "retained", sent: false, verified: false },
{ ok: false, sessionName: s, outcome: "failed", inputPhase: "attempted" },
{ ok: false, sessionName: s, sent: true, outcome: "failed", inputPhase: "written", reason: "submit_refused" },
{ ok: false, sessionName: s, sent: false, inputPhase: "not_reached" },
{ ok: false, sessionName: s, reason: "transport_unavailable" },
// A prompt answer typed without Enter: it succeeded, but whether the prompt took it isn't known.
{ ok: true, sessionName: s, sent: true, verified: false, outcome: "rendered-unconfirmed", promptInteraction: "unverified" },
// Text written and Enter refused before it was pressed: the text may sit in the input box.
{ ok: false, sessionName: s, sent: true, outcome: "failed", inputPhase: "written", reason: "draft_input_changed" },
].map((result) => {
const outcome = readSendOutcome(result, writtenFact(result as Parameters<typeof writtenFact>[0]));
return outcome.fact === "accepted" && outcome.renderVerified ? "accepted, render verified" : outcome.fact;
}))
.toEqual(["accepted", "accepted, render verified", "accepted", "held", "failed-effect-unknown", "failed-effect-unknown",
"not-delivered", "unknown", "unknown", "failed-effect-unknown"]);
});
it("uses the caller's delivery id as the message, and gives each attempt its own id", async () => {