fix(cli): the ACP launcher warns about another CLI version and runs it

- When module resolution finds an @openrig/cli other than the version
  @openrig/acp pins (only in a broken or pruned install), the launcher
  names the version it found, where it found it and the pinned version
  on stderr, then runs it as usual. A newer CLI very likely works, and
  one that doesn't fails visibly, so refusing protected nothing.
- The guide and the launcher's README give the full registration
  command, with the two options it needs.
- A reopened session replays its questions in the order they were
  asked. Cells now cover a question still open and one closed without
  an answer.
This commit is contained in:
dev-driver committed 2026-11-07 14:19:10 +00:00
1 parent e691399f34
commit a31cb3cb7a
6 files changed
+49 -16

No files matched your search

+2 -1
View File
@@ -221,7 +221,8 @@ app that starts the agent from the ACP registry passes no arguments, so that's h
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
reason and the fix. The agent talks only to the daemon on this computer, so start it first (`rig daemon start`). reason and the fix. The agent talks only to the daemon on this computer, so start it first (`rig daemon start`).
The team's questions reach you only as a registered person: an `<id>@external` address needs The team's questions reach you only as a registered person: an `<id>@external` address needs
`rig gateway human add <id>` first. Until then, the app says so at your first prompt, and your work is still sent. `rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
so at your first prompt, and your work is still sent.
**In Zed**, add the agent to `settings.json`: **In Zed**, add the agent to `settings.json`:
+4 -3
View File
@@ -4,8 +4,8 @@ OpenRig as an agent in apps that speak the [Agent Client Protocol](https://agent
Zed and JetBrains AI Assistant. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work Zed and JetBrains AI Assistant. Your prompt goes to your OpenRig team as a queue row. The app shows who has the work
and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply. and their notes as it moves, any question the team asks you as a choice, and the team's result as the reply.
This package has one command, `openrig-acp`. It starts `rig acp serve` from the exact `@openrig/cli` version it This package has one command, `openrig-acp`. It starts `rig acp serve` from the `@openrig/cli` version it depends on,
depends on, and stops with a message if it finds another, so ACP apps and the ACP registry can launch it with `npx`. and warns on stderr if it finds another, so ACP apps and the ACP registry can launch it with `npx`.
## What you need ## What you need
@@ -13,7 +13,8 @@ depends on, and stops with a message if it finds another, so ACP apps and the AC
- The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as - The seat that receives your team's new work, set once with `rig config set acp.to <member@rig>`, or passed as
`--to <member@rig>`. `--to <member@rig>`.
- You, as a person OpenRig knows: the default person (`rig gateway default <id>`), or `--as <address>`. The team's - You, as a person OpenRig knows: the default person (`rig gateway default <id>`), or `--as <address>`. The team's
questions reach an `<id>@external` address only once it's registered (`rig gateway human add <id>`). questions reach an `<id>@external` address only once it's registered:
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>`.
The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are. The app's sign-in step, "OpenRig on this machine", checks that the daemon answers and that it can tell who you are.
This agent talks only to the OpenRig daemon on this computer. This agent talks only to the OpenRig daemon on this computer.
+6 -6
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
// OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a // OpenRig as an agent in ACP apps. The ACP registry starts an agent with `npx <package>@<version>`, and npx needs a
// package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the exact @openrig/cli this // package with one executable: @openrig/cli has two. This one starts `rig acp serve` from the @openrig/cli this package
// package depends on (never a `rig` found elsewhere), with the app's stdin and stdout, arguments, environment and // depends on, as Node resolves it (never a `rig` on the PATH), with the app's stdin and stdout, arguments, environment
// working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to // and working directory, and exits as it exits. Only ACP messages may reach stdout, so anything this file says goes to
// stderr. // stderr.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -13,11 +13,11 @@ let rig;
try { try {
const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json"); const manifestPath = createRequire(import.meta.url).resolve("@openrig/cli/package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
// Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. // Module resolution can reach a different @openrig/cli further up when this package's own copy is missing. That copy
// most likely still works, and one that doesn't fails visibly, so say which version runs and run it.
const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"]; const wanted = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@openrig/cli"];
if (manifest.version !== wanted) { if (manifest.version !== wanted) {
console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, but this package runs exactly ${wanted}. Reinstall @openrig/acp.`); console.error(`openrig-acp: found @openrig/cli ${manifest.version} at ${dirname(manifestPath)}, not the ${wanted} this package pins; running it anyway. Reinstall @openrig/acp to get ${wanted}.`);
process.exit(1);
} }
rig = join(dirname(manifestPath), manifest.bin.rig); rig = join(dirname(manifestPath), manifest.bin.rig);
} catch (error) { } catch (error) {
+1 -1
View File
@@ -350,7 +350,7 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void
const roots = await sessionRoots(team, sessionId); const roots = await sessionRoots(team, sessionId);
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`); if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] }; const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
const asks = await team.queue.sessionAsks(team.caller, sessionId); const asks = (await team.queue.sessionAsks(team.caller, sessionId)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
// Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during // Replay: each prompt as the caller wrote it, then its work as it stands now, the questions the team asked during
// it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn. // it with what's recorded, and the reply. Nothing is asked again here; an open question comes up in the next turn.
for (const [i, row] of roots.entries()) { for (const [i, row] of roots.entries()) {
+6 -5
View File
@@ -134,12 +134,13 @@ describe("openrig-acp hands everything to rig acp serve", () => {
expect(app.err()).toMatch(/^openrig-acp: @openrig\/cli isn't installed beside this package/); expect(app.err()).toMatch(/^openrig-acp: @openrig\/cli isn't installed beside this package/);
}); });
it("an @openrig/cli of another version is refused on stderr, with nothing on stdout and exit 1", async () => { it("an @openrig/cli of another version is named on stderr and run anyway, with the arguments and exit code passed", async () => {
const app = run(install(true, "0.0.0"), []); const root = install(true, "0.0.0");
const app = run(root, ["--to", "lead@team"], { FAKE_EXIT: "3" });
app.child.stdin.end(); app.child.stdin.end();
expect(await app.done).toEqual({ code: 1, signal: null }); expect(await app.done).toEqual({ code: 3, signal: null });
expect(app.out()).toBe(""); expect(JSON.parse(app.out().trim().split("\n")[0]!).argv).toEqual(["acp", "serve", "--to", "lead@team"]);
expect(app.err()).toMatch(/^openrig-acp: found @openrig\/cli 0\.0\.0 at /); expect(app.err()).toMatch(/^openrig-acp: found @openrig\/cli 0\.0\.0 at /);
expect(app.err()).toContain(`but this package runs exactly ${cli.version}. Reinstall @openrig/acp.`); expect(app.err()).toContain(`not the ${cli.version} this package pins; running it anyway. Reinstall @openrig/acp to get ${cli.version}.`);
}); });
}); });
+30
View File
@@ -541,6 +541,36 @@ describe("rig acp serve", () => {
expect(asked).toEqual([]); expect(asked).toEqual([]);
}); });
it("a reopened session shows, in the order asked, a question closed unanswered as closed and one still open as pending", async () => {
const first = connect({}, "asker8@team");
const sessionId = await open(first.client);
const turn = first.client.request("session/prompt", prompt(sessionId, "Plan the release"));
const task = await until(() => rowsTo("asker8@team")[0], "the work row");
await move("asker8@team", `/api/queue/${task.qitem_id}/claim`, {});
await move("asker8@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Planned." });
await turn;
// Asked after the turn ended, so no live turn puts them to the app.
const choices = [{ id: "y", label: "Yes" }, { id: "n", label: "No" }];
await move("asker8@team", "/api/queue/create", { qitemId: "qitem-ask-closed-unanswered", destinationSession: CALLER, body: "Freeze on Friday?", summary: "Freeze?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Freeze on Friday?", options: choices }] });
await move(CALLER, "/api/queue/qitem-ask-closed-unanswered/claim", {});
await move(CALLER, "/api/queue/qitem-ask-closed-unanswered/update", { state: "done", closureReason: "no-follow-on", resolution: "Moot now." });
await move("asker8@team", "/api/queue/create", { qitemId: "qitem-ask-still-open", destinationSession: CALLER, body: "Tag it 1.0?", summary: "Tag?", nudge: false,
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Tag it 1.0?", options: choices }] });
const second = connect({}, "asker8@team");
const asked: unknown[] = [];
second.client.onRequest("session/request_permission", async (params) => { asked.push(params); return { outcome: { outcome: "cancelled" } }; });
await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({});
const questions = second.updates.filter((u) => u.sessionUpdate === "tool_call" && String(u.toolCallId).includes("#"));
expect(questions.map((u) => [u.toolCallId, u.status, (u.content as Array<{ content: { text: string } }>).at(-1)!.content.text])).toEqual([
["qitem-ask-closed-unanswered#q1", "completed", "Not answered: qitem-ask-closed-unanswered was closed (done)."],
["qitem-ask-still-open#q1", "pending", "Not answered yet. It's still open in the OpenRig TUI and Slack."],
]);
expect(asked).toEqual([]);
});
it("a connection's notice is said once in each session, before its first prompt's work", async () => { it("a connection's notice is said once in each session, before its first prompt's work", async () => {
const team: TeamConnection = { queue: daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER })), caller: CALLER, receiver: "noticed@team", notice: "Your team can't ask you questions here yet." }; const team: TeamConnection = { queue: daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER })), caller: CALLER, receiver: "noticed@team", notice: "Your team can't ask you questions here yet." };
const { client, updates } = connect({ connect: async () => team }); const { client, updates } = connect({ connect: async () => team });