Compare commits

...
Author SHA1 Message Date
dev60-review 8266b5bd9b Merge main into fix/operator-offers-registration 2026-11-12 08:32:39 +00:00
dev60-review c8c6f344b7 fix(setup): the kernel operator offers to register the person in its greeting
A person who talks to the kernel operator first, without a setup agent,
was never offered registration: the operator's role text asked for it,
but a fresh operator greeted and answered without making the offer.

The operator's startup context now has it run `rig gateway participants`
before greeting, beside `rig ps --json`, and put the offer in its greeting
when that output says no person is registered: ask the name, register
only on the person's yes, and on a no say questions can't reach anyone
yet. With a person registered, it doesn't offer.

`rig gateway participants` now ends with the same offer `rig daemon start`
prints when nobody is registered. Both read it from one module,
person-registration-offer.ts. Nothing is registered by either command, an
existing default is kept, and an unreadable registry prints no offer.
2026-11-12 04:32:39 +00:00
dev60-review 39a3b6e29b fix(setup): offer to register the person where the setup agent reads it
The rigs skill already tells an agent setting OpenRig up to offer
registration when no person is registered, but the step sits deep in the
handoff section, after the part an agent reads to install, and agents
that did read it went on without making the offer. OpenRig's questions to
a person, such as another machine asking to pair, then had no one to
reach.

`rig daemon start` now ends, when the person registry is readable and
holds nobody, with the offer and the exact command: ask the person, and
only on their yes run `rig gateway human add <id> --display-name "<name>"
--delivery-class B --default`. Nothing is registered by the start itself.
A registered person, or a registry that can't be read, prints nothing new,
and the start's result is unchanged. The skill's "Start OpenRig" step,
which agents read on the install path, now says to make that offer before
going on.
2026-11-12 02:19:04 +00:00
8 changed files with 120 additions and 5 deletions

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ import { acquireDaemonStartLock, bootedAtMs, currentBootId } from "../daemon-sta
import { formatDaemonHostForUrl } from "../client.js";
import { execFileSync, spawn } from "node:child_process";
import { fetchWithTimeout } from "../fetch-with-timeout.js";
import { personRegistrationOffer } from "../person-registration-offer.js";
import {
startDaemon,
stopDaemon,
@@ -309,6 +310,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
process.exitCode = 1;
}
}
if (process.exitCode !== 1) for (const line of await personRegistrationOffer()) console.log(line);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
+3
View File
@@ -1,3 +1,4 @@
import { nobodyRegistered, REGISTRATION_OFFER } from "../person-registration-offer.js";
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { resolveSenderSession, SENDER_FALLBACK } from "../sender-identity.js";
@@ -775,6 +776,8 @@ Removing a connector binding (binding.<n> unset):
console.log(defaultPerson.id ? `default: ${defaultPerson.id} (${defaultPerson.source === "chosen" ? "chosen" : "the only person"})` : `default: none — ${defaultPerson.reason}`);
for (const warning of view.warnings) console.log(`warning: ${warning}`);
if (view.sideFile.error) console.log(`participant file: ${view.sideFile.error}`);
// The same offer `rig daemon start` prints, for an agent meeting the person first (such as the kernel operator).
if (!opts.organization && nobodyRegistered(view.participants)) for (const line of REGISTRATION_OFFER) console.log(line);
});
cmd
@@ -0,0 +1,26 @@
// The offer to register a person, in one place: what `rig daemon start` and `rig gateway participants` tell an agent
// that's setting OpenRig up, or meeting the person first, when the person registry holds nobody.
/** The offer's lines. OpenRig registers no one by printing them; the agent asks, and registers only on a yes. */
export const REGISTRATION_OFFER: readonly string[] = [
"No person is registered yet, so OpenRig's questions to a person, such as another machine asking to pair, can't reach anyone.",
"If you're setting OpenRig up for someone, offer to register them now. Ask the name to use, and only on their yes run:",
' rig gateway human add <id> --display-name "<name>" --delivery-class B --default',
];
/** Whether a readable participants view holds no person (shared channels don't count). */
export function nobodyRegistered(participants: ReadonlyArray<{ kind: string }>): boolean {
return !participants.some((p) => p.kind === "person");
}
/** The offer, when the person registry is readable and holds nobody; empty otherwise, since an unreadable registry
* isn't evidence that nobody is registered. */
export async function personRegistrationOffer(): Promise<readonly string[]> {
try {
const { participantsView } = await import("@openrig/daemon/gateway-human-registry");
const view = participantsView();
return view.ok && nobodyRegistered(view.participants) ? REGISTRATION_OFFER : [];
} catch {
return [];
}
}
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { addHumanFragment } from "@openrig/daemon/gateway-human-registry";
import { startDaemon } from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
return { ...actual, getDaemonStatus: vi.fn(async () => ({ state: "stopped" })), startDaemon: vi.fn(async () => ({ port: 7433, pid: 4242, host: "127.0.0.1" })) };
});
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
const OFFER = [
"No person is registered yet, so OpenRig's questions to a person, such as another machine asking to pair, can't reach anyone.",
"If you're setting OpenRig up for someone, offer to register them now. Ask the name to use, and only on their yes run:",
' rig gateway human add <id> --display-name "<name>" --delivery-class B --default',
];
let home: string;
let logs: string[];
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "daemon-start-person-offer-"));
vi.stubEnv("OPENRIG_HOME", home);
logs = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { logs.push(args.join(" ")); });
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
const start = () => daemonCommand({} as never).parseAsync(["start"], { from: "user" });
it("with nobody registered, daemon start ends with the offer to register the person, and registers no one", async () => {
await start();
expect(startDaemon).toHaveBeenCalled();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)", ...OFFER]);
expect(process.exitCode).toBeUndefined();
const { participantsView } = await import("@openrig/daemon/gateway-human-registry");
const view = participantsView(home);
expect(view.ok && view.participants).toEqual([]);
});
it("with a person registered, daemon start makes no offer", async () => {
expect(addHumanFragment({ entityId: "ada", class: "human", displayName: "Ada", address: "ada@external", connectorBindings: [], prefs: { deliveryClass: "B" } }, home).ok).toBe(true);
await start();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)"]);
});
it("an unreadable registry is not read as nobody registered: no offer, and the start still succeeds", async () => {
mkdirSync(join(home, "gateway", "humans"), { recursive: true });
writeFileSync(join(home, "gateway", "humans", "ada.yaml"), "entityId: [unclosed\n");
await start();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)"]);
expect(process.exitCode).toBeUndefined();
});
@@ -7,6 +7,7 @@ import { createProgram } from "../src/index.js";
// it via the narrow @openrig/daemon/gateway-human-registry subpath (the C3/crash-cart rail).
import { humansDir, loadHumanRegistry } from "@openrig/daemon/gateway-human-registry";
import { gatewayRoutes } from "../../daemon/src/routes/gateway.js";
import { REGISTRATION_OFFER } from "../src/person-registration-offer.js";
// M1 A3 pt2 / A4b relocate — `rig gateway human add` verb integration. The verb lazy-imports
// the relocated daemon surface; these tests drive the real command end-to-end.
@@ -25,6 +26,23 @@ describe("rig gateway human add verb (post-relocate)", () => {
rmSync(home, { recursive: true, force: true });
});
// The kernel operator runs `rig gateway participants` before its greeting: with nobody registered, the output ends with
// the same offer `rig daemon start` prints; once someone is, it doesn't.
it("participants ends with the registration offer while nobody is registered, and drops it once someone is", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const empty = createProgram(); empty.exitOverride();
await empty.parseAsync(["node", "rig", "gateway", "participants"]);
const before = log.mock.calls.map((call) => String(call[0]));
expect(before.slice(-REGISTRATION_OFFER.length)).toEqual([...REGISTRATION_OFFER]);
expect(existsSync(humansDir(home))).toBe(false);
const add = createProgram(); add.exitOverride();
await add.parseAsync(["node", "rig", "gateway", "human", "add", "mike", "--display-name", "Mike", "--delivery-class", "B"]);
log.mockClear();
const after = createProgram(); after.exitOverride();
await after.parseAsync(["node", "rig", "gateway", "participants"]);
expect(log.mock.calls.map((call) => String(call[0]))).not.toContain(REGISTRATION_OFFER[0]);
});
it("participants --json and the daemon route expose the same filtered view", async () => {
const add = createProgram(); add.exitOverride();
await add.parseAsync(["node", "rig", "gateway", "human", "add", "mike", "--display-name", "Mike", "--delivery-class", "B"]);
+2 -1
View File
@@ -72,7 +72,8 @@ person needs in their project or home folder and name that path.
- **Start OpenRig:** `rig daemon status`, and `rig daemon start` if it isn't running. `rig preflight` and `rig doctor`
don't start it. Starting it also starts OpenRig's own team, the kernel, whose operator sets up the person's team.
Keep it.
Keep it. If its output says no person is registered, make the offer it names before you go on, and register only on
the person's yes ("Who answers OpenRig's questions" below).
- **If the person uses Codex:** now that OpenRig is running, make the one-time offer under "Codex: one offer, once,
after OpenRig starts" at the end of this step.
- **Show them OpenRig, the handoff:** once OpenRig is running, open the OpenRig TUI and operator for the person. Say
@@ -11,9 +11,17 @@ Run `rig whoami --json` to confirm identity.
written to you.** You are the first agent they talk to; the advisor does not
greet. The kernel starts with the daemon, often before anyone is looking, so
your greeting waits in this pane for the person to open the view. Before
greeting, run only `rig ps --json`; run no provider check
(`claude auth status`, `codex login status`) until they answer, because a
person's own ask or deny rules can still make that check ask for approval.
greeting, run only `rig ps --json` and `rig gateway participants`; run no
provider check (`claude auth status`, `codex login status`) until they answer,
because a person's own ask or deny rules can still make that check ask for
approval.
- **If `rig gateway participants` says no person is registered,** put its offer
in your greeting, after the welcome, 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, and register them only on their yes, with the command it prints. On a no or
not now, register nothing and say those questions can't reach anyone until
someone is registered. If a person is registered, don't offer.
- **No rig but `kernel`:** write a short welcome in plain words, for example:
+2 -1
View File
@@ -72,7 +72,8 @@ person needs in their project or home folder and name that path.
- **Start OpenRig:** `rig daemon status`, and `rig daemon start` if it isn't running. `rig preflight` and `rig doctor`
don't start it. Starting it also starts OpenRig's own team, the kernel, whose operator sets up the person's team.
Keep it.
Keep it. If its output says no person is registered, make the offer it names before you go on, and register only on
the person's yes ("Who answers OpenRig's questions" below).
- **If the person uses Codex:** now that OpenRig is running, make the one-time offer under "Codex: one offer, once,
after OpenRig starts" at the end of this step.
- **Show them OpenRig, the handoff:** once OpenRig is running, open the OpenRig TUI and operator for the person. Say