mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
e691399f34
commit
a31cb3cb7a
6 files changed
+49
-16
No files matched your search
@@ -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,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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
Reference in new issue
Block a user