@orlan-maker/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -6,11 +6,10 @@ import { createHash } from "node:crypto";
6
6
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import path from "node:path";
9
- import { addMcp, agentIds, agentLabels, CLAUDE_PLUGIN, detectAgent, installClaudePlugin, MCP_NAME, parseAgent, removeMcp, STOP_LOOP_LIMIT, setHooks, setOpencodePlugin, skillsDir, } from "./agents.js";
9
+ import { agentIds, agentLabels, CLAUDE_PLUGIN, detectAgent, installClaudePlugin, orlanIntro, parseAgent, removeMcp, STOP_LOOP_LIMIT, sessionStartOutput, setHooks, setOpencodePlugin, skillsDir, uninstallClaudePlugin, } from "./agents.js";
10
10
  import { openBrowser } from "./browser.js";
11
- import { configDir, DEFAULT_SERVER, readConfig, serverOrigin, writeConfig } from "./config.js";
11
+ import { configDir, readConfig, serverOrigin, writeConfig } from "./config.js";
12
12
  import { AgentApi, agentTokenRefused, apiCall, OrlanError, unreachable } from "./http.js";
13
- import { McpClient } from "./mcp.js";
14
13
  import { account, deleteSecret, getSecret, setSecret } from "./secrets.js";
15
14
  import { writeSkills } from "./skills.js";
16
15
  const agentOption = { agent: { type: "string" } };
@@ -36,6 +35,15 @@ function namedAgent(values) {
36
35
  * else the only connected agent.
37
36
  */
38
37
  async function agentSecret(config, values) {
38
+ const agent = pickAgent(config, values);
39
+ const secret = await getSecret(agentAccount(config, agent));
40
+ if (!secret) {
41
+ throw new OrlanError(`The token of ${agent} is missing.`, undefined, `orlan connect --agent ${agent}`);
42
+ }
43
+ return secret;
44
+ }
45
+ /** The connected agent a daily command works as: see agentSecret. */
46
+ function pickAgent(config, values) {
39
47
  const connected = Object.keys(config.agents);
40
48
  let agent = typeof values.agent === "string" ? parseAgent(values.agent) : undefined;
41
49
  if (!agent) {
@@ -44,19 +52,15 @@ async function agentSecret(config, values) {
44
52
  }
45
53
  if (!agent) {
46
54
  throw connected.length === 0
47
- ? new OrlanError("No agent is connected.", undefined, "orlan mcp connect --agent <id>")
55
+ ? new OrlanError("No agent is connected.", undefined, "orlan connect --agent <id>")
48
56
  : new OrlanError(`Several agents are connected (${connected.join(", ")}). Add --agent <id>.`, undefined, `run the command again with --agent ${connected[0]}`);
49
57
  }
50
58
  if (!config.agents[agent]) {
51
- throw new OrlanError(`${agentLabels[agent]} is not connected.`, undefined, `orlan mcp connect --agent ${agent}`);
59
+ throw new OrlanError(`${agentLabels[agent]} is not connected.`, undefined, `orlan connect --agent ${agent}`);
52
60
  }
53
- const secret = await getSecret(agentAccount(config, agent));
54
- if (!secret) {
55
- throw new OrlanError(`The token of ${agent} is missing.`, undefined, `orlan mcp connect --agent ${agent}`);
56
- }
57
- return secret;
61
+ return agent;
58
62
  }
59
- /** Runs the work with the stateless agent API of the agent token (F038): no MCP session. */
63
+ /** Runs the work with the stateless agent API of the agent token (F038): one plain HTTP request a call. */
60
64
  async function withAgent(values, work) {
61
65
  const config = await readConfig();
62
66
  return work(new AgentApi(config.server, await agentSecret(config, values)), config);
@@ -77,6 +81,19 @@ function finish(io, values, data, lines, next) {
77
81
  }
78
82
  /** --topic as input: a topic id or a topic name. It also tells Orlan where to look up a name or #12. */
79
83
  const topicInput = (values) => (typeof values.topic === "string" ? { topicId: values.topic } : {});
84
+ /**
85
+ * The topic of a command that needs one: --topic (an id or a name), else the topic the brief names (the
86
+ * one the agent used last, else its only topic).
87
+ */
88
+ async function topicOrDefault(api, values) {
89
+ if (typeof values.topic === "string")
90
+ return values.topic;
91
+ const { topic } = await api.get("/api/agent/brief", {}, "the brief");
92
+ if (!topic) {
93
+ throw new OrlanError("The agent has several topics and used none yet. Add --topic <topic>.", "topic_missing");
94
+ }
95
+ return topic.id;
96
+ }
80
97
  const WAIT_STEP = "orlan wait (the next request)";
81
98
  /**
82
99
  * A text people wrote, quoted on one line as `orlan wait` quotes it (B029): JSON quotes, with the line
@@ -106,6 +123,17 @@ function versionNumber(values, option) {
106
123
  }
107
124
  return number;
108
125
  }
126
+ /** An option of numbers with commas: --at 300,200, --size 200,120, --region 0.1,0.2,0.3,0.1. */
127
+ function numbersOption(values, option, count) {
128
+ const raw = values[option];
129
+ if (typeof raw !== "string")
130
+ return undefined;
131
+ const parts = raw.split(",").map((part) => Number(part.trim()));
132
+ if (parts.length !== count || parts.some((part) => !Number.isFinite(part))) {
133
+ throw new OrlanError(`--${option} takes ${count} numbers with commas.`, undefined, `run the command again with --${option} ${count === 2 ? "300,200" : "0.1,0.2,0.3,0.1"}`);
134
+ }
135
+ return parts;
136
+ }
109
137
  const baseOption = (values) => {
110
138
  const number = versionNumber(values, "base");
111
139
  return number === undefined ? {} : { baseVersion: number };
@@ -299,20 +327,19 @@ async function* sseData(body) {
299
327
  }
300
328
  }
301
329
  }
302
- /** What `orlan mcp print` shows: the settings any MCP client takes. */
303
- function mcpSettings(server, secret) {
304
- const url = new URL("/mcp", server).toString();
305
- return [
306
- `MCP server: ${url} (Streamable HTTP)`,
307
- `Header: Authorization: Bearer ${secret}`,
308
- "",
309
- "As JSON, for an mcp.json file:",
310
- JSON.stringify({ mcpServers: { [MCP_NAME]: { url, headers: { Authorization: `Bearer ${secret}` } } } }, null, 2),
311
- ];
312
- }
313
- /** Makes an agent token for the agent, keeps it, and writes the agent's MCP settings. */
314
- async function connect(config, agent, values, io) {
330
+ /**
331
+ * Connects the agent to Orlan with the orlan command (D088): makes an agent token and keeps it, installs
332
+ * the skills and the agent's plugin or hooks, takes out the Orlan server entry that an older CLI wrote in
333
+ * the agent's settings, and tests the token with one plain HTTP call.
334
+ */
335
+ async function connect(agent, values, io) {
336
+ const config = await readConfig();
315
337
  const token = await cliToken(config);
338
+ // First, before a token or a file is made: an old entry goes also when the plan refuses a new agent, and
339
+ // a settings file the CLI cannot edit stops the connect before it changes anything.
340
+ if (agent !== "other" && (await removeMcp(agent))) {
341
+ io.out("Removed the old Orlan server entry from the agent's settings. The agent uses the orlan command.");
342
+ }
316
343
  const topicIds = values.topic;
317
344
  const made = await apiCall(config.server, "/api/cli/agents", {
318
345
  token,
@@ -324,22 +351,19 @@ async function connect(config, agent, values, io) {
324
351
  const role = made.token.role[0]?.toUpperCase() + made.token.role.slice(1);
325
352
  io.out(`Made an agent token for ${agentLabels[agent]}: ${made.token.prefix}..., ${role} on ` +
326
353
  `${made.token.topics.map((topic) => quote(topic.name)).join(", ")}. It is in the ${where === "keychain" ? "keychain" : "credentials file"}.`);
354
+ // The Claude Code plugin carries the skills: a second copy would show each skill twice.
355
+ let plugin = false;
327
356
  if (agent === "claude-code" && !values["no-plugin"]) {
328
- if (await installClaudePlugin()) {
329
- io.out("Installed the Orlan plugin in Claude Code: the orlan command, a monitor that wakes the session on each " +
330
- "request, the hooks that show its state on the board, the skills, and the MCP server.");
331
- if (config.server === DEFAULT_SERVER) {
332
- // The plugin's MCP server gets the token from the keychain: one entry, never a stale token.
333
- await removeMcp(agent);
334
- return made.secret;
335
- }
336
- io.out(`The plugin's MCP server goes to $ORLAN_SERVER, else ${DEFAULT_SERVER}. Set ORLAN_SERVER=${config.server} ` +
337
- "where you start Claude Code. Until then, the MCP entry below stays.");
338
- }
339
- else {
340
- io.out(`Claude Code did not install the Orlan plugin (${CLAUDE_PLUGIN}). The MCP server works without it, ` +
341
- "but no monitor wakes the session. Run `claude plugin list` to see why.");
342
- }
357
+ plugin = await installClaudePlugin();
358
+ io.out(plugin
359
+ ? "Installed the Orlan plugin in Claude Code: the orlan command, the hooks that show the session's state " +
360
+ "on the board, and the skills."
361
+ : `Claude Code did not install the Orlan plugin (${CLAUDE_PLUGIN}). The orlan command and the skills work ` +
362
+ "without it, but the board does not show the session's state. Run `claude plugin list` to see why.");
363
+ }
364
+ if (!plugin) {
365
+ await writeSkills(agent);
366
+ io.out(`Installed the Orlan skills orlan, orlan-review and orlan-versions in ${skillsDir(agent)}.`);
343
367
  }
344
368
  if (agent === "codex" || agent === "cursor") {
345
369
  io.out(`Added the Orlan hooks to ${await setHooks(agent, true)}: at the session start the brief goes into the ` +
@@ -357,10 +381,62 @@ async function connect(config, agent, values, io) {
357
381
  io.out("A request wakes a session only while OpenCode runs (the TUI or opencode serve). Restart a running " +
358
382
  "OpenCode to load the plugin.");
359
383
  }
360
- if (agent !== "other")
361
- io.out(`Added the Orlan MCP server to ${await addMcp(agent, new URL("/mcp", config.server).toString(), made.secret)}.`);
362
- return made.secret;
384
+ const { topics } = await new AgentApi(config.server, made.secret).call("inbox", {
385
+ brief: true,
386
+ });
387
+ io.out(`Test: ok. The agent can reach ${topics.map((topic) => quote(topic.name)).join(", ")}.`);
388
+ io.out(agent === "other"
389
+ ? "next_step: tell your agent to read the orlan skill in .agents/skills, then run orlan brief"
390
+ : `next_step: start a new ${agentLabels[agent]} session, then run orlan brief`);
391
+ return 0;
363
392
  }
393
+ /**
394
+ * Revokes the agent's token, and takes out what a connect installed: the plugin or the hooks, and an
395
+ * Orlan server entry that an older CLI wrote. The skills stay.
396
+ */
397
+ async function disconnect(agent, io) {
398
+ const config = await readConfig();
399
+ const connected = config.agents[agent];
400
+ if (connected) {
401
+ await apiCall(config.server, `/api/cli/agents/${connected.tokenId}`, {
402
+ token: await cliToken(config),
403
+ method: "DELETE",
404
+ }).catch((error) => {
405
+ if (!(error instanceof OrlanError) || error.code !== "not_found")
406
+ throw error;
407
+ });
408
+ }
409
+ const removed = agent !== "other" && (await removeMcp(agent));
410
+ if (agent === "claude-code")
411
+ await uninstallClaudePlugin();
412
+ if (agent === "codex" || agent === "cursor")
413
+ await setHooks(agent, false);
414
+ if (agent === "opencode")
415
+ await setOpencodePlugin(false);
416
+ await deleteSecret(agentAccount(config, agent));
417
+ delete config.agents[agent];
418
+ await writeConfig(config);
419
+ const parts = { "claude-code": "plugin", codex: "hooks", cursor: "hooks", opencode: "plugin", other: "" }[agent];
420
+ io.out(`Disconnected ${agentLabels[agent]}: its token is revoked` +
421
+ `${parts ? `, and its Orlan ${parts} ${removed ? "and the old Orlan server entry are" : "is"} gone` : ""}. The skills stay.`);
422
+ io.out(`next_step: orlan connect --agent ${agent} (to connect it again)`);
423
+ return 0;
424
+ }
425
+ /** The agent that connect and disconnect are for: --agent, else the agent that runs the command. */
426
+ function agentToConnect(values) {
427
+ const agent = namedAgent(values);
428
+ if (!agent)
429
+ throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
430
+ return agent;
431
+ }
432
+ /** The notice of an old command name, on standard error: the output stays what the new name prints. */
433
+ const oldName = (text) => process.stderr.write(`orlan: ${text}\n`);
434
+ const CONNECT_OPTIONS = {
435
+ ...agentOption,
436
+ topic: { type: "string", multiple: true },
437
+ role: { type: "string" },
438
+ "no-plugin": { type: "boolean" },
439
+ };
364
440
  // ---------- the commands ----------
365
441
  export const commands = {
366
442
  "auth login": {
@@ -402,11 +478,13 @@ export const commands = {
402
478
  await writeConfig(next);
403
479
  io.out(`Signed in to ${server} as ${quote(signed.user.name)} (${signed.user.email ?? "guest account"}), organisation ${quote(signed.org.name)}.`);
404
480
  io.out(where === "keychain" ? "The CLI token is in the OS keychain." : "The CLI token is in the credentials file.");
405
- io.out("next_step: orlan mcp connect --agent <id>");
481
+ io.out("next_step: orlan connect --agent <id>");
406
482
  return 0;
407
483
  }
408
484
  catch (error) {
409
- if (!(error instanceof OrlanError) || error.code !== "login_pending")
485
+ // A short outage of the server, for example a deploy, does not end the wait (found on orlan.app).
486
+ const waiting = error instanceof OrlanError && (error.code === "login_pending" || error.code === "unreachable");
487
+ if (!waiting)
410
488
  throw error;
411
489
  if (Date.now() > until)
412
490
  throw new OrlanError("The code expired.", "login_expired");
@@ -418,7 +496,7 @@ export const commands = {
418
496
  usage: "orlan auth logout",
419
497
  summary: "Revoke the CLI token and forget it.",
420
498
  details: [
421
- "The agents you connected keep their own tokens. Revoke them with `orlan mcp disconnect`,",
499
+ "The agents you connected keep their own tokens. Revoke them with `orlan disconnect`,",
422
500
  "or in Agents and tokens in the web app.",
423
501
  ],
424
502
  options: {},
@@ -442,9 +520,9 @@ export const commands = {
442
520
  },
443
521
  status: {
444
522
  usage: "orlan status [--json]",
445
- summary: "Show who you are, the server, and the connected agents, and test each agent's MCP connection.",
523
+ summary: "Show who you are, the server, and the connected agents, and test each agent's connection.",
446
524
  details: [
447
- "Each connected agent makes one MCP call (inbox brief) with its own token, so a failed",
525
+ "Each connected agent makes one plain HTTP call (the inbox brief) with its own token, so a failed",
448
526
  "connection shows here with its reason. Ends with exit code 1 when a check fails.",
449
527
  "",
450
528
  JSON_HELP,
@@ -466,19 +544,16 @@ export const commands = {
466
544
  const secret = await getSecret(agentAccount(config, agent));
467
545
  let check;
468
546
  if (!live || !secret) {
469
- check = { ok: false, error: "its token was revoked or replaced. Run `orlan mcp connect` again." };
547
+ check = { ok: false, error: "its token was revoked or replaced. Run `orlan connect` again." };
470
548
  }
471
549
  else {
472
- const client = new McpClient(config.server, secret, io.version);
473
550
  try {
474
- check = { ok: true, topics: (await client.call("inbox", { brief: true })).topics };
551
+ const api = new AgentApi(config.server, secret);
552
+ check = { ok: true, topics: (await api.call("inbox", { brief: true })).topics };
475
553
  }
476
554
  catch (error) {
477
555
  check = { ok: false, error: error.message };
478
556
  }
479
- finally {
480
- await client.close();
481
- }
482
557
  }
483
558
  if (!check.ok)
484
559
  failed = true;
@@ -486,9 +561,9 @@ export const commands = {
486
561
  }
487
562
  const elsewhere = status.agents.filter((each) => !Object.values(config.agents).some((local) => local.tokenId === each.id));
488
563
  const next = agents.length === 0
489
- ? "orlan mcp connect --agent <id>"
564
+ ? "orlan connect --agent <id>"
490
565
  : failed
491
- ? "orlan mcp connect --agent <id> for each failed agent"
566
+ ? "orlan connect --agent <id> for each failed agent"
492
567
  : "orlan brief (where the work stands)";
493
568
  if (values.json) {
494
569
  printJson(io, {
@@ -510,12 +585,12 @@ export const commands = {
510
585
  for (const { agent, token: local, role, check } of agents) {
511
586
  io.out(`${agentLabels[agent]} (${agent}): token ${local?.prefix}..., ${role ?? "revoked"}`);
512
587
  if (check.ok) {
513
- io.out(" MCP: ok. Topics it can reach:");
588
+ io.out(" Connection: ok. Topics it can reach:");
514
589
  for (const topic of check.topics)
515
590
  io.out(` - ${quote(topic.name)} (${topic.id})`);
516
591
  }
517
592
  else {
518
- io.out(` MCP: failed - ${check.error}`);
593
+ io.out(` Connection: failed - ${check.error}`);
519
594
  }
520
595
  }
521
596
  for (const each of elsewhere)
@@ -528,8 +603,9 @@ export const commands = {
528
603
  usage: "orlan skills add [--agent <id>]",
529
604
  summary: "Install the Orlan skills where the agent reads skills.",
530
605
  details: [
531
- "Writes the skills orlan-review and orlan-versions. Without --agent, the CLI finds the agent that",
532
- "runs it; when it finds none, it writes them in this project, in .agents/skills.",
606
+ "Writes the skills orlan, orlan-review and orlan-versions. Without --agent, the CLI finds the agent that",
607
+ "runs it; when it finds none, it writes them in this project, in .agents/skills. `orlan connect` does",
608
+ "this too.",
533
609
  "",
534
610
  ` claude-code ${skillsDir("claude-code")}`,
535
611
  ` codex ${skillsDir("codex")}`,
@@ -547,24 +623,22 @@ export const commands = {
547
623
  io.out(`Installed the Orlan skills for ${agentLabels[agent]}:`);
548
624
  for (const folder of written)
549
625
  io.out(` ${folder}`);
550
- io.out(`next_step: orlan mcp connect --agent ${agent === "other" ? "<id>" : agent}`);
626
+ io.out(`next_step: orlan connect --agent ${agent === "other" ? "<id>" : agent}`);
551
627
  return 0;
552
628
  },
553
629
  },
554
- "mcp connect": {
555
- usage: "orlan mcp connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
556
- summary: "Make an agent token and add the Orlan MCP server to the agent.",
630
+ connect: {
631
+ usage: "orlan connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
632
+ summary: "Connect an agent to Orlan: an agent token, the skills, and the plugin or the hooks.",
557
633
  details: [
558
- "Makes an agent token with your CLI token, keeps it in the keychain, and writes the MCP entry:",
559
- "Claude Code with `claude mcp add --scope user`, Codex in its config.toml, Cursor in",
560
- "~/.cursor/mcp.json, OpenCode in its opencode.json. For --agent other it prints the settings.",
561
- "A second connect for the same agent replaces its token. Then it makes one MCP call to test it.",
634
+ "Makes an agent token with your CLI token and keeps it in the keychain. The agent then works on",
635
+ "Orlan with the orlan command. It installs the skills (as `orlan skills add` does), and removes the",
636
+ "Orlan server entry that an older CLI wrote. Every other entry in the agent's settings stays. A second",
637
+ "connect for the same agent replaces its token. Then it makes one plain HTTP call to test the token.",
562
638
  "Only an admin of the organisation connects agents.",
563
639
  "",
564
640
  `For Claude Code it installs the Orlan plugin (${CLAUDE_PLUGIN}) from this package: the orlan command,`,
565
- "a monitor that runs `orlan wait --follow` and wakes the session on each request, the hooks that",
566
- "show the session's state on the board, the skills, and the MCP server. Then the MCP entry is not",
567
- "needed. On a server other than https://orlan.app, the entry stays until you set ORLAN_SERVER.",
641
+ "the hooks that show the session's state on the board, and the skills.",
568
642
  "",
569
643
  "For Codex ($CODEX_HOME/hooks.json) and Cursor (~/.cursor/hooks.json) it writes the Orlan hooks: the",
570
644
  "session start gets the brief (`orlan hook start`), a turn that ends gets a waiting request",
@@ -575,125 +649,61 @@ export const commands = {
575
649
  "turn ends, it runs `orlan hook wait` and sends the request to the session as a new prompt, so the",
576
650
  "session wakes while OpenCode runs. The board shows the session's state.",
577
651
  "",
652
+ "For other it writes the skills in this project, in .agents/skills.",
653
+ "",
578
654
  AGENT_HELP,
579
655
  "--topic <id> A topic the agent can use. Repeat it for more. Default: the topics you chose at sign-in.",
580
656
  "--role <role> editor, commenter or viewer. Default: the role you chose at sign-in.",
581
- "--no-plugin Claude Code: add only the MCP entry, not the plugin.",
657
+ "--no-plugin Claude Code: do not install the plugin. Write the skills to ~/.claude/skills.",
582
658
  ],
583
- options: {
584
- ...agentOption,
585
- topic: { type: "string", multiple: true },
586
- role: { type: "string" },
587
- "no-plugin": { type: "boolean" },
588
- },
659
+ options: CONNECT_OPTIONS,
589
660
  positionals: [0, 0],
590
- run: async (values, _positionals, io) => {
591
- const agent = namedAgent(values);
592
- if (!agent)
593
- throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
594
- const config = await readConfig();
595
- const secret = await connect(config, agent, values, io);
596
- const client = new McpClient(config.server, secret, io.version);
597
- try {
598
- const { topics } = await client.call("inbox", { brief: true });
599
- io.out(`MCP test: ok. The agent can reach ${topics.map((topic) => quote(topic.name)).join(", ")}.`);
600
- }
601
- finally {
602
- await client.close();
603
- }
604
- if (agent === "other") {
605
- for (const line of mcpSettings(config.server, secret))
606
- io.out(line);
607
- io.out("next_step: put the address and the header in your agent's MCP settings, then run orlan brief");
608
- }
609
- else {
610
- io.out(`next_step: start a new ${agentLabels[agent]} session to load the Orlan MCP tools, then run orlan brief`);
611
- }
612
- return 0;
613
- },
661
+ run: (values, _positionals, io) => connect(agentToConnect(values), values, io),
614
662
  },
615
- "mcp print": {
616
- usage: "orlan mcp print [--agent <id>]",
617
- summary: "Print the MCP server address and the agent token, for an agent the CLI does not set up.",
618
- details: [
619
- "Uses the token of the agent (default: other). When that agent is not connected yet, it",
620
- "connects it first. Put the address and the header in your agent's MCP settings.",
621
- "The token is a secret: keep it out of chats, files you share, and repositories.",
622
- "",
623
- AGENT_HELP,
624
- ],
663
+ disconnect: {
664
+ usage: "orlan disconnect [--agent <id>]",
665
+ summary: "Revoke the agent's token, and take out its Orlan plugin or hooks and any old Orlan server entry.",
666
+ details: ["The skills stay. `orlan connect` connects the agent again.", "", AGENT_HELP],
625
667
  options: agentOption,
626
668
  positionals: [0, 0],
627
- run: async (values, _positionals, io) => {
628
- const agent = typeof values.agent === "string" ? parseAgent(values.agent) : "other";
629
- const config = await readConfig();
630
- let secret = config.agents[agent] ? await getSecret(agentAccount(config, agent)) : undefined;
631
- if (!secret)
632
- secret = await connect(config, agent, values, io);
633
- for (const line of mcpSettings(config.server, secret))
634
- io.out(line);
635
- io.out("next_step: put the address and the header in your agent's MCP settings");
636
- return 0;
669
+ run: (values, _positionals, io) => disconnect(agentToConnect(values), io),
670
+ },
671
+ "mcp connect": {
672
+ usage: "orlan mcp connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
673
+ summary: "The old name of orlan connect. Use orlan connect: this name goes away in the next release.",
674
+ details: ["It does what `orlan connect` does. Orlan has no MCP entry for the agent now (D088)."],
675
+ options: CONNECT_OPTIONS,
676
+ positionals: [0, 0],
677
+ run: (values, _positionals, io) => {
678
+ oldName("Use orlan connect: orlan mcp connect goes away in the next release.");
679
+ return connect(agentToConnect(values), values, io);
637
680
  },
638
681
  },
639
- "mcp headers": {
640
- usage: "orlan mcp headers [--agent <id>]",
641
- summary: "Print the MCP authorization header as JSON, for a headersHelper. The Claude Code plugin runs it.",
682
+ "mcp print": {
683
+ usage: "orlan mcp print [--agent <id>]",
684
+ summary: "The old way to connect another agent. Use orlan connect: this name goes away in the next release.",
642
685
  details: [
643
- 'Prints {"Authorization": "Bearer <agent token>"} with the token from the keychain, so the MCP',
644
- "settings hold no secret and a new connect needs no new settings. When Claude Code gives the MCP",
645
- "address (CLAUDE_CODE_MCP_SERVER_URL) and it is not the server you signed in to, it prints nothing",
646
- "and exits with code 1.",
686
+ "Orlan has no MCP settings to print now (D088). It does what `orlan connect` does, for the agent",
687
+ "--agent names, else other: the token, and the skills in this project, in .agents/skills.",
647
688
  "",
648
- AGENT_TOKEN_HELP,
689
+ AGENT_HELP,
649
690
  ],
650
691
  options: agentOption,
651
692
  positionals: [0, 0],
652
- run: async (values, _positionals, io) => {
653
- const config = await readConfig();
654
- const asked = process.env.CLAUDE_CODE_MCP_SERVER_URL;
655
- if (asked && serverOrigin(asked) !== config.server) {
656
- throw new OrlanError(`The MCP server is ${serverOrigin(asked)}, but you signed in to ${config.server}. ` +
657
- `Set ORLAN_SERVER=${config.server} where you start Claude Code.`);
658
- }
659
- io.out(JSON.stringify({ Authorization: `Bearer ${await agentSecret(config, values)}` }));
660
- return 0;
693
+ run: (values, _positionals, io) => {
694
+ oldName("Use orlan connect: orlan mcp print goes away in the next release.");
695
+ return connect(typeof values.agent === "string" ? parseAgent(values.agent) : "other", values, io);
661
696
  },
662
697
  },
663
698
  "mcp disconnect": {
664
699
  usage: "orlan mcp disconnect [--agent <id>]",
665
- summary: "Revoke the agent's token and take the Orlan MCP server out of its settings.",
666
- details: [AGENT_HELP],
700
+ summary: "The old name of orlan disconnect. Use orlan disconnect: this name goes away in the next release.",
701
+ details: ["It does what `orlan disconnect` does.", "", AGENT_HELP],
667
702
  options: agentOption,
668
703
  positionals: [0, 0],
669
- run: async (values, _positionals, io) => {
670
- const agent = namedAgent(values);
671
- if (!agent)
672
- throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
673
- const config = await readConfig();
674
- const connected = config.agents[agent];
675
- if (connected) {
676
- await apiCall(config.server, `/api/cli/agents/${connected.tokenId}`, {
677
- token: await cliToken(config),
678
- method: "DELETE",
679
- }).catch((error) => {
680
- if (!(error instanceof OrlanError) || error.code !== "not_found")
681
- throw error;
682
- });
683
- }
684
- if (agent !== "other")
685
- await removeMcp(agent);
686
- if (agent === "codex" || agent === "cursor")
687
- await setHooks(agent, false);
688
- if (agent === "opencode")
689
- await setOpencodePlugin(false);
690
- await deleteSecret(agentAccount(config, agent));
691
- delete config.agents[agent];
692
- await writeConfig(config);
693
- io.out(`Disconnected ${agentLabels[agent]}: its token is revoked and its Orlan MCP entry` +
694
- `${agent === "codex" || agent === "cursor" ? " and hooks are" : agent === "opencode" ? " and plugin are" : " is"} gone.`);
695
- io.out(`next_step: orlan mcp connect --agent ${agent} (to connect it again)`);
696
- return 0;
704
+ run: (values, _positionals, io) => {
705
+ oldName("Use orlan disconnect: orlan mcp disconnect goes away in the next release.");
706
+ return disconnect(agentToConnect(values), io);
697
707
  },
698
708
  },
699
709
  "topics list": {
@@ -716,38 +726,59 @@ export const commands = {
716
726
  }),
717
727
  },
718
728
  "files list": {
719
- usage: "orlan files list --topic <topic> [--agent <id>] [--json]",
729
+ usage: "orlan files list [--topic <topic>] [--agent <id>] [--json]",
720
730
  summary: "List the files of a topic with their current versions.",
721
- details: ["--topic <topic> The topic id or its name.", AGENT_TOKEN_HELP, JSON_HELP],
731
+ details: [
732
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
733
+ AGENT_TOKEN_HELP,
734
+ JSON_HELP,
735
+ ],
722
736
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
723
737
  positionals: [0, 0],
724
738
  run: (values, _positionals, io) => withAgent(values, async (api) => {
725
- if (typeof values.topic !== "string") {
726
- throw new OrlanError("Add --topic <topic>.", undefined, "orlan topics list (the topics of the agent)");
727
- }
728
- const { files } = await api.call("read_board", { ...topicInput(values), view: "files" });
739
+ const topicId = await topicOrDefault(api, values);
740
+ const { files } = await api.call("read_board", { topicId, view: "files" });
729
741
  return finish(io, values, { files }, files.length === 0
730
742
  ? ["No files."]
731
743
  : files.map((file) => `${file.id} ${quote(file.name)} ${file.kind} current v${file.current.number} (${file.current.status}), ` +
732
- `${file.versions} version${file.versions === 1 ? "" : "s"}`), files.length === 0
744
+ `${file.versions} version${file.versions === 1 ? "" : "s"}` +
745
+ (file.current.changed
746
+ ? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
747
+ : "")), files.length === 0
733
748
  ? "orlan files add <path> (a new file)"
734
749
  : "orlan files pull <file> --edit --out <path> (the file id or its name)");
735
750
  }),
736
751
  },
737
752
  "files add": {
738
- usage: "orlan files add <path> [--topic <topic>] [--agent <id>] [--json]",
753
+ usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
739
754
  summary: "Post a local file as a new file on a topic, and put it on the board.",
740
755
  details: [
741
756
  "One request sends the file and places it on the board, right of the other items. The server",
742
757
  "renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file, with the right extension.",
743
758
  "",
759
+ "--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
760
+ " place. `orlan board` shows where the items are.",
744
761
  "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
745
762
  AGENT_TOKEN_HELP,
746
763
  JSON_HELP,
747
764
  ],
748
- options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
765
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, at: { type: "string" } },
749
766
  positionals: [1, 1],
750
767
  run: (values, [filePath], io) => withAgent(values, async (api) => {
768
+ const at = numbersOption(values, "at", 2);
769
+ if (at) {
770
+ // A place on the board: the file goes up first, then the post places it.
771
+ const topicId = await topicOrDefault(api, values);
772
+ const uploadId = await api.upload(await readFile(filePath), { topic: topicId });
773
+ const posted = await api.call("post", { topicId, name: path.basename(filePath), uploadId, position: { x: at[0], y: at[1] } });
774
+ return finish(io, values, {
775
+ file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
776
+ boardItemId: posted.boardItemId,
777
+ }, [
778
+ `Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
779
+ `board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
780
+ ], "orlan wait (comments on the file come as requests)");
781
+ }
751
782
  const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: values.topic }, await readFile(filePath), "the new file");
752
783
  return finish(io, values, answer, [
753
784
  `Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
@@ -756,13 +787,20 @@ export const commands = {
756
787
  }),
757
788
  },
758
789
  "files pull": {
759
- usage: "orlan files pull <file> [--version <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]] [--agent <id>] [--json]",
760
- summary: "Download a version of a file (the current one by default).",
790
+ usage: "orlan files pull <file> [--version <n>] [--pages <n,n> | --since <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]] [--agent <id>] [--json]",
791
+ summary: "Download a version of a file (the current one by default), or images and text of its pages.",
761
792
  details: [
793
+ "With --pages or --since it gets pages, not the file: it saves the image of each page in the",
794
+ "--out folder as <name>-page-<n>.png, and prints the text of each page.",
795
+ "",
762
796
  "<file> The file id, or its name where it is unique on the topic.",
763
797
  "--version <n> A version number, for example 5 or v5. Default: the current version.",
798
+ "--pages <n,n> The pages to get, from 1, for example 3 or 2,5.",
799
+ "--since <n> The pages that changed since this older version, for example 4 or v4. With",
800
+ " --pages, only those of the pages that changed.",
764
801
  "--topic <t> The topic of a file name, when two topics have a file with that name.",
765
- "--out <path> Where to write it. Default: the file's own name, in the folder you are in.",
802
+ "--out <path> Where to write it. Default: the file's own name, in the folder you are in. With",
803
+ " --pages or --since: the folder of the page images. Default: the folder you are in.",
766
804
  "--edit You pull it to edit it: this puts your claim on the file, which other agents and",
767
805
  " people see. When another agent has a claim, you get it and no claim of your own.",
768
806
  " A push or respond ends your claim, and it takes the pulled version as its base.",
@@ -775,6 +813,8 @@ export const commands = {
775
813
  ...agentOption,
776
814
  ...jsonOption,
777
815
  version: { type: "string" },
816
+ pages: { type: "string" },
817
+ since: { type: "string" },
778
818
  topic: { type: "string" },
779
819
  out: { type: "string" },
780
820
  edit: { type: "boolean" },
@@ -787,12 +827,17 @@ export const commands = {
787
827
  throw new OrlanError("--shared and --ttl go with --edit.", undefined, "add --edit, or leave them out");
788
828
  }
789
829
  const version = versionNumber(values, "version");
790
- // One call: the file, the version (v5), and the claim. Orlan finds a file by its name too.
830
+ const since = versionNumber(values, "since");
831
+ const pages = pageList(values);
832
+ const pageMode = pages !== undefined || since !== undefined;
833
+ // One call: the file, the version (v5), the pages, and the claim. Orlan finds a file by its name too.
791
834
  const answer = await api.call("get_file", {
792
835
  artifactId: fileRef,
793
836
  ...topicInput(values),
794
837
  ...(version ? { versionId: `v${version}` } : {}),
795
- pageImages: "none",
838
+ ...(pages ? { pages } : {}),
839
+ ...(since !== undefined ? { changedSince: since } : {}),
840
+ pageImages: pageMode ? "url" : "none",
796
841
  ...(values.edit
797
842
  ? {
798
843
  edit: true,
@@ -801,6 +846,8 @@ export const commands = {
801
846
  }
802
847
  : {}),
803
848
  });
849
+ if (pageMode)
850
+ return pullPages(api, config, values, io, answer, since);
804
851
  const url = answer.originalUrl ?? answer.viewUrl;
805
852
  if (!url)
806
853
  throw new OrlanError("Orlan gave no address for this file.", undefined, "run the command again");
@@ -815,15 +862,10 @@ export const commands = {
815
862
  }
816
863
  const target = typeof values.out === "string" ? values.out : path.basename(answer.file.name);
817
864
  await writeFile(target, Buffer.from(await response.arrayBuffer()));
818
- const lines = [`Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`];
819
- if (answer.claim) {
820
- lines.push(`Your${answer.claim.shared ? " shared" : ""} claim is on the file from v${answer.claim.baseVersion}.`);
821
- }
822
- for (const other of answer.claims ?? []) {
823
- const minutes = Math.floor((Date.now() - new Date(other.since).getTime()) / 60_000);
824
- lines.push(`${quote(other.agent)} is editing it from v${other.baseVersion}${other.shared ? " (shared claim)" : ""} - ` +
825
- `${minutes < 1 ? "under 1 min" : `${minutes} min`}.`);
826
- }
865
+ const lines = [
866
+ `Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
867
+ ...claimLines(answer),
868
+ ];
827
869
  return finish(io, values, {
828
870
  file: { id: answer.file.id, name: answer.file.name },
829
871
  version: answer.version.number,
@@ -879,6 +921,44 @@ export const commands = {
879
921
  ], "orlan wait (comments on the version come as requests)");
880
922
  }),
881
923
  },
924
+ "files rollback": {
925
+ usage: 'orlan files rollback <file> --version <n> [--changelog "<why>"] [--topic <topic>] [--agent <id>] [--json]',
926
+ summary: "Post an older version of a file again, as its newest version.",
927
+ details: [
928
+ "The new version is a copy of the older one. It waits for a person to make it current, as a push does.",
929
+ "",
930
+ "<file> The file id, or its name where it is unique on the topic.",
931
+ "--version <n> The older version to post again, for example 3 or v3. Required.",
932
+ "--changelog <text> Why, for the people who review it.",
933
+ "--topic <topic> The topic of a file name, when two topics have a file with that name.",
934
+ AGENT_TOKEN_HELP,
935
+ JSON_HELP,
936
+ ],
937
+ options: {
938
+ ...agentOption,
939
+ ...jsonOption,
940
+ version: { type: "string" },
941
+ changelog: { type: "string" },
942
+ topic: { type: "string" },
943
+ },
944
+ positionals: [1, 1],
945
+ run: (values, [fileRef], io) => withAgent(values, async (api) => {
946
+ const version = versionNumber(values, "version");
947
+ if (!version) {
948
+ throw new OrlanError("Add --version <n>.", undefined, "orlan files list shows the versions");
949
+ }
950
+ const posted = await api.call("post", {
951
+ artifactId: fileRef,
952
+ ...topicInput(values),
953
+ versionId: `v${version}`,
954
+ ...(typeof values.changelog === "string" ? { changelog: values.changelog } : {}),
955
+ });
956
+ return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number, copyOf: version }, [
957
+ `Posted version ${posted.posted.number} of ${quote(posted.file.name)}, a copy of v${version}. ` +
958
+ "It waits for a person to make it current.",
959
+ ], "orlan wait (comments on the version come as requests)");
960
+ }),
961
+ },
882
962
  "comments list": {
883
963
  usage: "orlan comments list [--topic <topic>] [--status open|resolved|all] [--file <file>] [--json]",
884
964
  summary: "List the comment threads, newest first.",
@@ -979,16 +1059,7 @@ export const commands = {
979
1059
  },
980
1060
  positionals: [1, 2],
981
1061
  run: (values, positionals, io) => {
982
- const numbers = (option, count) => {
983
- const raw = values[option];
984
- if (typeof raw !== "string")
985
- return undefined;
986
- const parts = raw.split(",").map((part) => Number(part.trim()));
987
- if (parts.length !== count || parts.some((part) => !Number.isFinite(part))) {
988
- throw new OrlanError(`--${option} takes ${count} numbers with commas.`, undefined, `run the command again with --${option} ${count === 2 ? "300,200" : "0.1,0.2,0.3,0.1"}`);
989
- }
990
- return parts;
991
- };
1062
+ const numbers = (option, count) => numbersOption(values, option, count);
992
1063
  const [first, second] = positionals;
993
1064
  const body = (second ?? first);
994
1065
  const usage = "orlan comment --help (the three forms)";
@@ -1057,13 +1128,399 @@ export const commands = {
1057
1128
  return finish(io, values, { id: thread.thread, number: thread.number, status: "resolved" }, [`Resolved #${thread.number}.`], WAIT_STEP);
1058
1129
  }),
1059
1130
  },
1131
+ board: {
1132
+ usage: "orlan board [--topic <topic>] [--agent <id>] [--json]",
1133
+ summary: "Print the board: its items with their ids and places, the timer, the votes and the open tasks.",
1134
+ details: [
1135
+ "Each item is one line: its id, its type, its place (x,y) and size (w x h) in board coordinates, and",
1136
+ "its text. A file shows its name and pages, an arrow the items it links. People vote; an agent reads",
1137
+ "the vote and its result, it does not vote.",
1138
+ "",
1139
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1140
+ AGENT_TOKEN_HELP,
1141
+ JSON_HELP,
1142
+ ],
1143
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1144
+ positionals: [0, 0],
1145
+ run: (values, _positionals, io) => withAgent(values, async (api) => {
1146
+ const topicId = await topicOrDefault(api, values);
1147
+ // Two requests at the same time: the board, and the names of its files.
1148
+ const [board, { files }] = await Promise.all([
1149
+ api.call("read_board", { topicId, view: "board" }),
1150
+ api.call("read_board", { topicId, view: "files" }),
1151
+ ]);
1152
+ const names = new Map(files.map((file) => [file.id, file.name]));
1153
+ const items = board.items.map((item) => item.type === "file" ? { ...item, name: names.get(item.artifactId ?? "") ?? "" } : item);
1154
+ const { timer, vote, lastVote, tasks = [] } = board.session ?? {};
1155
+ const lines = [
1156
+ `Board of ${quote(board.topic.name)} (${board.topic.id}): ${items.length} item${items.length === 1 ? "" : "s"}.`,
1157
+ ];
1158
+ if (items.some((item) => item.text) || vote || tasks.length > 0)
1159
+ lines.push(QUOTED_TEXT);
1160
+ const round = (value) => Math.round(value);
1161
+ for (const item of items) {
1162
+ const place = `at ${round(item.x)},${round(item.y)}, ${round(item.w)}x${round(item.h)}`;
1163
+ const end = (id, x, y) => id ?? `${round(x)},${round(y)}`;
1164
+ lines.push(item.type === "arrow"
1165
+ ? `- ${item.id} arrow from ${end(item.from, item.x, item.y)} to ${end(item.to, item.x + item.w, item.y + item.h)}`
1166
+ : item.type === "file"
1167
+ ? `- ${item.id} file ${quote(item.name ?? "")} (${item.artifactId}) ${place}, ` +
1168
+ `${item.pages?.length ?? 0} page${item.pages?.length === 1 ? "" : "s"}`
1169
+ : `- ${item.id} ${item.type} ${place}${item.text === undefined ? "" : `: ${quote(item.text)}`}`);
1170
+ }
1171
+ if (timer) {
1172
+ lines.push(`timer: ${timer.left}s left of ${timer.of}s${timer.paused ? ", paused" : ""}, by ${quote(timer.by)}` +
1173
+ (timer.for ? `, for ${quote(timer.for)}` : ""));
1174
+ }
1175
+ if (vote)
1176
+ lines.push(`open vote: ${voteText(vote)} (people vote, you do not)`);
1177
+ if (lastVote)
1178
+ lines.push(`last vote: ${voteText(lastVote)}`);
1179
+ lines.push(`open tasks: ${tasks.length === 0 ? "none" : tasks.length}`);
1180
+ for (const task of tasks) {
1181
+ lines.push(`- ${task.id} ${quote(task.text)}${task.to ? ` for ${quote(task.to)}` : ""}` +
1182
+ (task.thread !== undefined ? ` from #${task.thread}` : ""));
1183
+ }
1184
+ return finish(io, values, { topic: board.topic, items, ...(board.session ? { session: board.session } : {}) }, lines, 'orlan board add note "<text>" --at <x,y> (a note), orlan board change <item>, or orlan board delete <item>');
1185
+ }),
1186
+ },
1187
+ "board add": {
1188
+ usage: 'orlan board add note|text|frame ["<text>"] --at <x,y> [--size <w,h>] | arrow --from <item> --to <item> [--topic <topic>] [--agent <id>] [--json]',
1189
+ summary: "Add a note, a text, a frame or an arrow to the board.",
1190
+ details: [
1191
+ "A note, a text or a frame goes at --at, with its text (a frame's text is its title). An arrow",
1192
+ "links two items: --from and --to take their ids, from `orlan board`. People see it at once, and",
1193
+ "a person can undo it. To add several items in one step, use `orlan board edit`.",
1194
+ "",
1195
+ "--at <x,y> The top left corner, in board coordinates. A negative x goes with =: --at=-50,120.",
1196
+ "--size <w,h> The width and the height. Default: the size a person gets.",
1197
+ "--from <item> The item the arrow starts at.",
1198
+ "--to <item> The item the arrow points to.",
1199
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1200
+ AGENT_TOKEN_HELP,
1201
+ JSON_HELP,
1202
+ ],
1203
+ options: {
1204
+ ...agentOption,
1205
+ ...jsonOption,
1206
+ at: { type: "string" },
1207
+ size: { type: "string" },
1208
+ from: { type: "string" },
1209
+ to: { type: "string" },
1210
+ topic: { type: "string" },
1211
+ },
1212
+ positionals: [1, 2],
1213
+ run: (values, [type, text], io) => {
1214
+ const usage = "orlan board add --help";
1215
+ let edit;
1216
+ if (type === "arrow") {
1217
+ if (typeof values.from !== "string" || typeof values.to !== "string") {
1218
+ throw new OrlanError("An arrow needs --from <item> and --to <item>.", undefined, "orlan board (the item ids)");
1219
+ }
1220
+ if (text !== undefined)
1221
+ throw new OrlanError("An arrow has no text.", undefined, usage);
1222
+ edit = { op: "create", type, from: values.from, to: values.to };
1223
+ }
1224
+ else if (type === "note" || type === "text" || type === "frame") {
1225
+ const at = numbersOption(values, "at", 2);
1226
+ if (!at)
1227
+ throw new OrlanError(`A ${type} needs --at <x,y>.`, undefined, "orlan board (where the items are)");
1228
+ const size = numbersOption(values, "size", 2);
1229
+ edit = {
1230
+ op: "create",
1231
+ type,
1232
+ x: at[0],
1233
+ y: at[1],
1234
+ ...(size ? { w: size[0], h: size[1] } : {}),
1235
+ ...(text !== undefined ? { text } : {}),
1236
+ };
1237
+ }
1238
+ else {
1239
+ throw new OrlanError("The item is a note, a text, a frame or an arrow.", undefined, usage);
1240
+ }
1241
+ return withAgent(values, async (api) => {
1242
+ const { ids } = await editBoard(api, values, [edit]);
1243
+ const where = type === "arrow" ? `from ${values.from} to ${values.to}` : `at ${edit.x},${edit.y}`;
1244
+ return finish(io, values, { id: ids[0], type }, [`Added ${type} ${ids[0]} ${where}.`], type === "arrow"
1245
+ ? "orlan board (the board with the arrow)"
1246
+ : `orlan board add arrow --from ${ids[0]} --to <item> (to link it), or orlan board`);
1247
+ });
1248
+ },
1249
+ },
1250
+ "board change": {
1251
+ usage: 'orlan board change <item> [--text "<text>"] [--at <x,y>] [--size <w,h>] [--from <item>|none] [--to <item>|none] [--topic <topic>] [--agent <id>] [--json]',
1252
+ summary: "Change an item of the board: its text, place, size, or the ends of an arrow.",
1253
+ details: [
1254
+ "The changes are one step, which a person can undo. A file on the board only moves (--at).",
1255
+ "",
1256
+ "<item> The item id, from `orlan board`.",
1257
+ "--text <text> The new text of a note or a text, or the title of a frame.",
1258
+ "--at <x,y> Move the item: its new top left corner, in board coordinates. A negative x goes",
1259
+ " with =: --at=-50,120.",
1260
+ "--size <w,h> The new width and height.",
1261
+ "--from <item> The item the arrow starts at. none: the end stays where it is, on no item.",
1262
+ "--to <item> The item the arrow points to. none: the end stays where it is, on no item.",
1263
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1264
+ AGENT_TOKEN_HELP,
1265
+ JSON_HELP,
1266
+ ],
1267
+ options: {
1268
+ ...agentOption,
1269
+ ...jsonOption,
1270
+ text: { type: "string" },
1271
+ at: { type: "string" },
1272
+ size: { type: "string" },
1273
+ from: { type: "string" },
1274
+ to: { type: "string" },
1275
+ topic: { type: "string" },
1276
+ },
1277
+ positionals: [1, 1],
1278
+ run: (values, [id], io) => {
1279
+ const at = numbersOption(values, "at", 2);
1280
+ const size = numbersOption(values, "size", 2);
1281
+ const end = (option) => {
1282
+ const value = values[option];
1283
+ if (typeof value !== "string")
1284
+ return {};
1285
+ return { [option]: value === "none" ? null : value };
1286
+ };
1287
+ const update = {
1288
+ ...(typeof values.text === "string" ? { text: values.text } : {}),
1289
+ ...(size ? { w: size[0], h: size[1] } : {}),
1290
+ ...end("from"),
1291
+ ...end("to"),
1292
+ };
1293
+ const edits = [
1294
+ ...(at ? [{ op: "move", id, x: at[0], y: at[1] }] : []),
1295
+ ...(Object.keys(update).length > 0 ? [{ op: "update", id, ...update }] : []),
1296
+ ];
1297
+ if (edits.length === 0) {
1298
+ throw new OrlanError("Give --text, --at, --size, --from or --to.", undefined, `orlan board change ${id} --text "<text>"`);
1299
+ }
1300
+ return withAgent(values, async (api) => {
1301
+ await editBoard(api, values, edits);
1302
+ return finish(io, values, { id }, [`Changed ${id}.`], "orlan board (the board with the change)");
1303
+ });
1304
+ },
1305
+ },
1306
+ "board delete": {
1307
+ usage: "orlan board delete <item>... [--topic <topic>] [--agent <id>] [--json]",
1308
+ summary: "Delete items from the board: notes, texts, frames and arrows.",
1309
+ details: [
1310
+ "One step for all the items, which a person can undo. A file on the board is not deleted here: a",
1311
+ "person does that.",
1312
+ "",
1313
+ "<item> The item id, from `orlan board`. Give several to delete several.",
1314
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1315
+ AGENT_TOKEN_HELP,
1316
+ JSON_HELP,
1317
+ ],
1318
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1319
+ positionals: [1, 100],
1320
+ run: (values, ids, io) => withAgent(values, async (api) => {
1321
+ await editBoard(api, values, ids.map((id) => ({ op: "delete", id })));
1322
+ return finish(io, values, { deleted: ids }, [`Deleted ${ids.length === 1 ? "1 item" : `${ids.length} items`}: ${ids.join(", ")}. A person can undo it.`], "orlan board (the board now)");
1323
+ }),
1324
+ },
1325
+ "board edit": {
1326
+ usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
1327
+ summary: "Make several changes to the board in one step: all of them, or none.",
1328
+ details: [
1329
+ 'The edits are a JSON list of at most 100. A person can undo the step. "#n" is the item that edit n',
1330
+ "(from 0) made, so one step can add two notes and the arrow between them:",
1331
+ ' [{"op":"create","type":"note","x":0,"y":0,"text":"Web"},',
1332
+ ' {"op":"create","type":"note","x":400,"y":0,"text":"API"},',
1333
+ ' {"op":"create","type":"arrow","from":"#0","to":"#1"}]',
1334
+ "",
1335
+ "create type note, text or frame with x, y and text (and w, h); type arrow with from and to.",
1336
+ "move id, x, y.",
1337
+ "update id, and text, w, h, or from and to (null: on no item).",
1338
+ "delete id. A file on the board only moves.",
1339
+ "",
1340
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1341
+ AGENT_TOKEN_HELP,
1342
+ JSON_HELP,
1343
+ ],
1344
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1345
+ positionals: [1, 1],
1346
+ run: (values, [text], io) => {
1347
+ let edits;
1348
+ try {
1349
+ edits = JSON.parse(text);
1350
+ }
1351
+ catch {
1352
+ edits = undefined;
1353
+ }
1354
+ if (!Array.isArray(edits) || edits.length === 0) {
1355
+ throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
1356
+ }
1357
+ return withAgent(values, async (api) => {
1358
+ const { ids } = await editBoard(api, values, edits);
1359
+ return finish(io, values, { ids }, [`Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step. The items: ${ids.join(", ")}.`], "orlan board (the board now)");
1360
+ });
1361
+ },
1362
+ },
1363
+ "tasks add": {
1364
+ usage: 'orlan tasks add "<text>" [--assignee me|user:<id>|agent:<id>] [--thread <thread>] [--done] [--topic <topic>] [--agent <id>] [--json]',
1365
+ summary: "Add a task to the topic's task list.",
1366
+ details: [
1367
+ "People see the task on the board at once. `orlan board` lists the open tasks.",
1368
+ "",
1369
+ "--assignee <who> me (this agent), user:<id> or agent:<id>. Default: nobody.",
1370
+ "--thread <t> The comment thread the task comes from: #12 or the thread id.",
1371
+ "--done The task is done already.",
1372
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1373
+ AGENT_TOKEN_HELP,
1374
+ JSON_HELP,
1375
+ ],
1376
+ options: {
1377
+ ...agentOption,
1378
+ ...jsonOption,
1379
+ assignee: { type: "string" },
1380
+ thread: { type: "string" },
1381
+ done: { type: "boolean" },
1382
+ topic: { type: "string" },
1383
+ },
1384
+ positionals: [1, 1],
1385
+ run: (values, [text], io) => withAgent(values, async (api, config) => {
1386
+ const assignee = assigneeOption(config, values);
1387
+ const task = await api.call("record", {
1388
+ kind: "task",
1389
+ topicId: await topicOrDefault(api, values),
1390
+ text,
1391
+ ...(assignee ? { assignee } : {}),
1392
+ ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
1393
+ ...(values.done ? { done: true } : {}),
1394
+ });
1395
+ return finish(io, values, { task }, [`Added task ${task.id}: ${taskText(task)}.`], `orlan tasks change ${task.id} --done (when it is done)`);
1396
+ }),
1397
+ },
1398
+ "tasks change": {
1399
+ usage: 'orlan tasks change <task id> [--text "<text>"] [--done | --open] [--assignee me|user:<id>|agent:<id>|none] [--agent <id>] [--json]',
1400
+ summary: "Change a task: its text, done or open, or its assignee.",
1401
+ details: [
1402
+ "<task id> The task id, from `orlan board` or `orlan brief`.",
1403
+ "--text <text> The new text.",
1404
+ "--done Mark it done.",
1405
+ "--open Mark it open again.",
1406
+ "--assignee <who> me (this agent), user:<id>, agent:<id>, or none.",
1407
+ AGENT_TOKEN_HELP,
1408
+ JSON_HELP,
1409
+ ],
1410
+ options: {
1411
+ ...agentOption,
1412
+ ...jsonOption,
1413
+ text: { type: "string" },
1414
+ done: { type: "boolean" },
1415
+ open: { type: "boolean" },
1416
+ assignee: { type: "string" },
1417
+ },
1418
+ positionals: [1, 1],
1419
+ run: (values, [taskId], io) => withAgent(values, async (api, config) => {
1420
+ if (values.done && values.open)
1421
+ throw new OrlanError("Give --done or --open, not both.");
1422
+ const assignee = assigneeOption(config, values);
1423
+ const change = {
1424
+ ...(typeof values.text === "string" ? { text: values.text } : {}),
1425
+ ...(values.done ? { done: true } : values.open ? { done: false } : {}),
1426
+ ...(assignee !== undefined ? { assignee } : {}),
1427
+ };
1428
+ if (Object.keys(change).length === 0) {
1429
+ throw new OrlanError("Give --text, --done, --open or --assignee.", undefined, `orlan tasks change ${taskId} --done`);
1430
+ }
1431
+ const task = await api.call("record", { kind: "task", taskId, ...change });
1432
+ return finish(io, values, { task }, [`Changed task ${task.id}: ${taskText(task)}.`], WAIT_STEP);
1433
+ }),
1434
+ },
1435
+ "decisions add": {
1436
+ usage: 'orlan decisions add "<title>" [--body "<text>"] [--thread <thread>] [--topic <topic>] [--agent <id>] [--json]',
1437
+ summary: "Record a decision in the topic's decision log.",
1438
+ details: [
1439
+ "Record a decision the people made, so it is not lost in a thread. `orlan search` finds it later.",
1440
+ "",
1441
+ "--body <text> The decision in full: what, and why.",
1442
+ "--thread <t> The comment thread the decision comes from: #12 or the thread id.",
1443
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1444
+ AGENT_TOKEN_HELP,
1445
+ JSON_HELP,
1446
+ ],
1447
+ options: {
1448
+ ...agentOption,
1449
+ ...jsonOption,
1450
+ body: { type: "string" },
1451
+ thread: { type: "string" },
1452
+ topic: { type: "string" },
1453
+ },
1454
+ positionals: [1, 1],
1455
+ run: (values, [title], io) => withAgent(values, async (api) => {
1456
+ const decision = await api.call("record", {
1457
+ kind: "decision",
1458
+ topicId: await topicOrDefault(api, values),
1459
+ title,
1460
+ ...(typeof values.body === "string" ? { body: values.body } : {}),
1461
+ ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
1462
+ });
1463
+ return finish(io, values, { decision }, [`Recorded decision ${decision.id}: ${quote(decision.title)}.`], WAIT_STEP);
1464
+ }),
1465
+ },
1466
+ search: {
1467
+ usage: 'orlan search "<words>" [--topic <topic>] [--limit <n>] [--agent <id>] [--json]',
1468
+ summary: "Search the topics of the agent: topic and file names, page text, comments and decisions.",
1469
+ details: [
1470
+ "The matches are between « and », with some text around them. Orlan searches the current version",
1471
+ "of each file, and the newest version that was sent.",
1472
+ "",
1473
+ "--topic <topic> Only this topic: its id or its name. Default: every topic of the agent.",
1474
+ "--limit <n> At most this many results, from 1 to 100. Default: 20.",
1475
+ AGENT_TOKEN_HELP,
1476
+ JSON_HELP,
1477
+ ],
1478
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, limit: { type: "string" } },
1479
+ positionals: [1, 1],
1480
+ run: (values, [words], io) => withAgent(values, async (api) => {
1481
+ const limit = values.limit === undefined ? undefined : Number(values.limit);
1482
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
1483
+ throw new OrlanError("--limit takes a number from 1 to 100.", undefined, "run the command again with --limit 20");
1484
+ }
1485
+ const { results } = await api.call("search", {
1486
+ q: words,
1487
+ ...topicInput(values),
1488
+ ...(limit ? { limit } : {}),
1489
+ });
1490
+ const on = (result) => `on ${quote(result.topic.name)}`;
1491
+ const version = (result) => (result.version ? ` v${result.version.number}` : "");
1492
+ const lines = results.length === 0 ? [`No match for ${quote(words)}.`] : [QUOTED_TEXT];
1493
+ for (const result of results) {
1494
+ const { ids } = result;
1495
+ lines.push(result.kind === "topic"
1496
+ ? `- topic ${quote(result.title)} (${result.topic.id}): ${quote(result.snippet)}`
1497
+ : result.kind === "file"
1498
+ ? `- file ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
1499
+ : result.kind === "page"
1500
+ ? `- page ${ids.page} of ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
1501
+ : result.kind === "comment"
1502
+ ? `- comment in ${result.title} (thread ${ids.threadId}) ${on(result)}: ${quote(result.snippet)}`
1503
+ : `- decision ${quote(result.title)} (${ids.decisionId}) ${on(result)}: ${quote(result.snippet)}`);
1504
+ }
1505
+ const page = results.find((result) => result.kind === "page");
1506
+ const comment = results.find((result) => result.kind === "comment");
1507
+ return finish(io, values, { results }, lines, page
1508
+ ? `orlan files pull ${page.ids.artifactId} --pages ${page.ids.page} (the page of a match)`
1509
+ : comment
1510
+ ? `orlan comments list --topic ${comment.topic.id} --status all (the threads)`
1511
+ : results.length === 0
1512
+ ? "search again with other words"
1513
+ : "orlan brief (where the work stands)");
1514
+ }),
1515
+ },
1060
1516
  brief: {
1061
1517
  usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
1062
1518
  summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
1063
1519
  details: [
1064
- "For the start of a session: one short answer, with no MCP session. The topic is the one the agent",
1520
+ "For the start of a session: one short answer, in one plain HTTP call. The topic is the one the agent",
1065
1521
  "used last, else its only topic. It lists the open requests (`orlan inbox` prints them in full),",
1066
- "the files with their current versions, and one next_step line.",
1522
+ "the files with their current versions, the open vote and the open tasks (one line each), and one",
1523
+ "next_step line. People vote; an agent reads the vote, it does not vote.",
1067
1524
  "",
1068
1525
  "--topic <topic> Another topic of the agent: its id or its name.",
1069
1526
  AGENT_TOKEN_HELP,
@@ -1311,9 +1768,9 @@ export const commands = {
1311
1768
  usage: "orlan hook start --agent codex|cursor",
1312
1769
  summary: "The session start hook of Codex and Cursor: report working and give the session the brief.",
1313
1770
  details: [
1314
- "`orlan mcp connect --agent codex|cursor` installs it. It prints `orlan brief` and how to answer a",
1315
- "request, in the JSON the harness adds to the session's context, and the board shows the session",
1316
- "as working. With the agent not connected, it prints nothing.",
1771
+ "`orlan connect --agent codex|cursor` installs it. It prints `orlan brief`, what Orlan is and how",
1772
+ "to answer a request, in the JSON the harness adds to the session's context, and the board shows",
1773
+ "the session as working. With the agent not connected, it prints nothing.",
1317
1774
  "",
1318
1775
  "--agent <id> codex or cursor.",
1319
1776
  ],
@@ -1330,16 +1787,15 @@ export const commands = {
1330
1787
  apiCall(config.server, "/api/agent/brief?hook=true", { token }),
1331
1788
  reportQuietly(config.server, token, "working", fields.session),
1332
1789
  ]);
1333
- const context = [
1790
+ const lines = [
1334
1791
  ...brief.lines,
1792
+ await orlanIntro(),
1335
1793
  `No monitor wakes this ${agentLabels[agent]} session. A new Orlan request comes to you when your turn ends, ` +
1336
1794
  "from the Orlan stop hook. Do not run orlan wait yourself: it blocks your turn.",
1337
1795
  "Answer a request with the orlan command in few steps: orlan files pull <file id> --edit --out <path> && cat <path>; " +
1338
1796
  'edit the file; orlan respond <request id> --file <path> [--to <file id>] --message "<what changed>".',
1339
- ].join("\n");
1340
- io.out(JSON.stringify(agent === "codex"
1341
- ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }
1342
- : { additional_context: context }));
1797
+ ];
1798
+ io.out(sessionStartOutput(agent, lines));
1343
1799
  return 0;
1344
1800
  },
1345
1801
  },
@@ -1347,7 +1803,7 @@ export const commands = {
1347
1803
  usage: "orlan hook stop --agent codex|cursor",
1348
1804
  summary: "The stop hook of Codex and Cursor: give the session a waiting request, else report idle.",
1349
1805
  details: [
1350
- "`orlan mcp connect --agent codex|cursor` installs it. When the session's turn ends, it takes the",
1806
+ "`orlan connect --agent codex|cursor` installs it. When the session's turn ends, it takes the",
1351
1807
  "new requests of the agent (as `orlan inbox --new` does) and gives them to the session, so it works",
1352
1808
  "on at once: Codex gets decision block with the requests, Cursor a followup_message. The board",
1353
1809
  "shows the session as working. With no request, it prints nothing and the board shows it idle.",
@@ -1401,7 +1857,7 @@ export const commands = {
1401
1857
  usage: "orlan hook wait --agent opencode",
1402
1858
  summary: "The wait of the OpenCode plugin: wait for a request and print it as the prompt of a new turn.",
1403
1859
  details: [
1404
- "`orlan mcp connect --agent opencode` installs the plugin that runs it when a session's turn ends.",
1860
+ "`orlan connect --agent opencode` installs the plugin that runs it when a session's turn ends.",
1405
1861
  'It waits as `orlan wait` does, then prints one JSON line: {"prompt"} with the request, which the',
1406
1862
  'plugin sends to the session, or {"stopped"} when a person stopped the agent on the board. It ends',
1407
1863
  "when its standard input closes (OpenCode exited); a request it did not print stays in the inbox.",
@@ -1463,3 +1919,112 @@ export const commands = {
1463
1919
  },
1464
1920
  },
1465
1921
  };
1922
+ /** Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. */
1923
+ async function editBoard(api, values, edits) {
1924
+ return api.call("edit_board", { topicId: await topicOrDefault(api, values), edits });
1925
+ }
1926
+ /** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
1927
+ function assigneeOption(config, values) {
1928
+ const raw = values.assignee;
1929
+ if (typeof raw !== "string")
1930
+ return undefined;
1931
+ if (raw === "none")
1932
+ return null;
1933
+ if (raw === "me") {
1934
+ return { kind: "agent", id: config.agents[pickAgent(config, values)]?.tokenId };
1935
+ }
1936
+ const match = /^(user|agent):(\S+)$/.exec(raw);
1937
+ if (!match) {
1938
+ throw new OrlanError("--assignee takes me, user:<id>, agent:<id> or none.", undefined, "run the command again with --assignee me");
1939
+ }
1940
+ return { kind: match[1], id: match[2] };
1941
+ }
1942
+ /** A task in one line: its text, its assignee, and done. */
1943
+ const taskText = (task) => `${quote(task.text)}${task.assignee ? ` for ${quote(task.assignee.name)}` : ""}${task.done ? ", done" : ", open"}`;
1944
+ /** A vote in one line, as the brief shows it, with its result when people can see it. */
1945
+ function voteText(vote) {
1946
+ const result = vote.result
1947
+ ?.map((entry) => "option" in entry
1948
+ ? `${quote(entry.option)} ${entry.count}${entry.by ? ` (${entry.by.map(quote).join(", ")})` : ""}`
1949
+ : `${entry.on} ${entry.count}`)
1950
+ .join(", ");
1951
+ return (`${vote.kind} ${quote(vote.question)}` +
1952
+ (vote.options ? `, options ${vote.options.map(quote).join(", ")}` : `, ${vote.dots} dots each`) +
1953
+ `, ${vote.voters} voted` +
1954
+ (result ? `, result: ${result}` : ""));
1955
+ }
1956
+ /** --pages as page numbers: "3" or "2,5". */
1957
+ function pageList(values) {
1958
+ if (typeof values.pages !== "string")
1959
+ return undefined;
1960
+ const pages = values.pages.split(",").map((part) => Number(part.trim()));
1961
+ if (pages.length === 0 || pages.some((page) => !Number.isInteger(page) || page < 1)) {
1962
+ throw new OrlanError("--pages takes page numbers from 1 with commas, for example 3 or 2,5.", undefined, "run the command again with --pages 1");
1963
+ }
1964
+ return pages;
1965
+ }
1966
+ /** The claim lines of a pull: the agent's own claim, and each other agent that edits the file. */
1967
+ function claimLines(answer) {
1968
+ const lines = [];
1969
+ if (answer.claim) {
1970
+ lines.push(`Your${answer.claim.shared ? " shared" : ""} claim is on the file from v${answer.claim.baseVersion}.`);
1971
+ }
1972
+ for (const other of answer.claims ?? []) {
1973
+ const minutes = Math.floor((Date.now() - new Date(other.since).getTime()) / 60_000);
1974
+ lines.push(`${quote(other.agent)} is editing it from v${other.baseVersion}${other.shared ? " (shared claim)" : ""} - ` +
1975
+ `${minutes < 1 ? "under 1 min" : `${minutes} min`}.`);
1976
+ }
1977
+ return lines;
1978
+ }
1979
+ /** The image types a page has, and the extension of each. */
1980
+ const PAGE_EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
1981
+ /** `orlan files pull --pages|--since`: saves the image of each page and prints its text. */
1982
+ async function pullPages(api, config, values, io, answer, since) {
1983
+ const folder = typeof values.out === "string" ? values.out : ".";
1984
+ const stem = path.basename(answer.file.name, path.extname(answer.file.name));
1985
+ const saved = [];
1986
+ for (const page of answer.pages ?? []) {
1987
+ const response = await fetch(page.imageUrl, {
1988
+ headers: { authorization: `Bearer ${api.token}` },
1989
+ }).catch((error) => {
1990
+ throw unreachable(config.server, error);
1991
+ });
1992
+ if (!response.ok) {
1993
+ throw new OrlanError(`The image of page ${page.index} from ${config.server} answered ${response.status}.`, undefined, "run the command again");
1994
+ }
1995
+ const type = response.headers.get("content-type")?.split(";")[0] ?? "";
1996
+ const image = path.join(folder, `${stem}-page-${page.index}.${PAGE_EXTENSIONS[type] ?? "png"}`);
1997
+ if (saved.length === 0)
1998
+ await mkdir(folder, { recursive: true });
1999
+ await writeFile(image, Buffer.from(await response.arrayBuffer()));
2000
+ saved.push({ index: page.index, image, text: page.text ?? "" });
2001
+ }
2002
+ const { number, renderStatus } = answer.version;
2003
+ const title = `Version ${number} of ${quote(answer.file.name)}`;
2004
+ const lines = [
2005
+ since !== undefined
2006
+ ? `${title}: ${changedText(saved.map((page) => page.index))} since v${since}.`
2007
+ : `${title}: ${saved.length} page${saved.length === 1 ? "" : "s"}.`,
2008
+ ...(renderStatus !== "done" && saved.length === 0
2009
+ ? [`The pages of v${number} are not rendered yet (${renderStatus}).`]
2010
+ : []),
2011
+ ...saved.map((page) => `Page ${page.index}: saved ${quote(page.image)}. Text: ${quote(page.text)}`),
2012
+ ...claimLines(answer),
2013
+ ];
2014
+ const first = saved[0];
2015
+ return finish(io, values, {
2016
+ file: { id: answer.file.id, name: answer.file.name },
2017
+ version: number,
2018
+ ...(since !== undefined ? { changedSince: since } : {}),
2019
+ pages: saved,
2020
+ ...(answer.claim !== undefined ? { claim: answer.claim } : {}),
2021
+ ...(answer.claims ? { claims: answer.claims } : {}),
2022
+ }, lines, answer.nextStep ??
2023
+ (first
2024
+ ? `look at the images, then orlan comment --file ${answer.file.id} --page ${first.index} "<text>" (a comment on a page)`
2025
+ : renderStatus !== "done"
2026
+ ? "run the command again in a minute"
2027
+ : `orlan files pull ${answer.file.id} (the whole file)`));
2028
+ }
2029
+ /** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
2030
+ const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;