@orlan-maker/cli 0.1.0 → 0.2.0

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,7 +478,7 @@ 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) {
@@ -418,7 +494,7 @@ export const commands = {
418
494
  usage: "orlan auth logout",
419
495
  summary: "Revoke the CLI token and forget it.",
420
496
  details: [
421
- "The agents you connected keep their own tokens. Revoke them with `orlan mcp disconnect`,",
497
+ "The agents you connected keep their own tokens. Revoke them with `orlan disconnect`,",
422
498
  "or in Agents and tokens in the web app.",
423
499
  ],
424
500
  options: {},
@@ -442,9 +518,9 @@ export const commands = {
442
518
  },
443
519
  status: {
444
520
  usage: "orlan status [--json]",
445
- summary: "Show who you are, the server, and the connected agents, and test each agent's MCP connection.",
521
+ summary: "Show who you are, the server, and the connected agents, and test each agent's connection.",
446
522
  details: [
447
- "Each connected agent makes one MCP call (inbox brief) with its own token, so a failed",
523
+ "Each connected agent makes one plain HTTP call (the inbox brief) with its own token, so a failed",
448
524
  "connection shows here with its reason. Ends with exit code 1 when a check fails.",
449
525
  "",
450
526
  JSON_HELP,
@@ -466,19 +542,16 @@ export const commands = {
466
542
  const secret = await getSecret(agentAccount(config, agent));
467
543
  let check;
468
544
  if (!live || !secret) {
469
- check = { ok: false, error: "its token was revoked or replaced. Run `orlan mcp connect` again." };
545
+ check = { ok: false, error: "its token was revoked or replaced. Run `orlan connect` again." };
470
546
  }
471
547
  else {
472
- const client = new McpClient(config.server, secret, io.version);
473
548
  try {
474
- check = { ok: true, topics: (await client.call("inbox", { brief: true })).topics };
549
+ const api = new AgentApi(config.server, secret);
550
+ check = { ok: true, topics: (await api.call("inbox", { brief: true })).topics };
475
551
  }
476
552
  catch (error) {
477
553
  check = { ok: false, error: error.message };
478
554
  }
479
- finally {
480
- await client.close();
481
- }
482
555
  }
483
556
  if (!check.ok)
484
557
  failed = true;
@@ -486,9 +559,9 @@ export const commands = {
486
559
  }
487
560
  const elsewhere = status.agents.filter((each) => !Object.values(config.agents).some((local) => local.tokenId === each.id));
488
561
  const next = agents.length === 0
489
- ? "orlan mcp connect --agent <id>"
562
+ ? "orlan connect --agent <id>"
490
563
  : failed
491
- ? "orlan mcp connect --agent <id> for each failed agent"
564
+ ? "orlan connect --agent <id> for each failed agent"
492
565
  : "orlan brief (where the work stands)";
493
566
  if (values.json) {
494
567
  printJson(io, {
@@ -510,12 +583,12 @@ export const commands = {
510
583
  for (const { agent, token: local, role, check } of agents) {
511
584
  io.out(`${agentLabels[agent]} (${agent}): token ${local?.prefix}..., ${role ?? "revoked"}`);
512
585
  if (check.ok) {
513
- io.out(" MCP: ok. Topics it can reach:");
586
+ io.out(" Connection: ok. Topics it can reach:");
514
587
  for (const topic of check.topics)
515
588
  io.out(` - ${quote(topic.name)} (${topic.id})`);
516
589
  }
517
590
  else {
518
- io.out(` MCP: failed - ${check.error}`);
591
+ io.out(` Connection: failed - ${check.error}`);
519
592
  }
520
593
  }
521
594
  for (const each of elsewhere)
@@ -528,8 +601,9 @@ export const commands = {
528
601
  usage: "orlan skills add [--agent <id>]",
529
602
  summary: "Install the Orlan skills where the agent reads skills.",
530
603
  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.",
604
+ "Writes the skills orlan, orlan-review and orlan-versions. Without --agent, the CLI finds the agent that",
605
+ "runs it; when it finds none, it writes them in this project, in .agents/skills. `orlan connect` does",
606
+ "this too.",
533
607
  "",
534
608
  ` claude-code ${skillsDir("claude-code")}`,
535
609
  ` codex ${skillsDir("codex")}`,
@@ -547,24 +621,22 @@ export const commands = {
547
621
  io.out(`Installed the Orlan skills for ${agentLabels[agent]}:`);
548
622
  for (const folder of written)
549
623
  io.out(` ${folder}`);
550
- io.out(`next_step: orlan mcp connect --agent ${agent === "other" ? "<id>" : agent}`);
624
+ io.out(`next_step: orlan connect --agent ${agent === "other" ? "<id>" : agent}`);
551
625
  return 0;
552
626
  },
553
627
  },
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.",
628
+ connect: {
629
+ usage: "orlan connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
630
+ summary: "Connect an agent to Orlan: an agent token, the skills, and the plugin or the hooks.",
557
631
  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.",
632
+ "Makes an agent token with your CLI token and keeps it in the keychain. The agent then works on",
633
+ "Orlan with the orlan command. It installs the skills (as `orlan skills add` does), and removes the",
634
+ "Orlan server entry that an older CLI wrote. Every other entry in the agent's settings stays. A second",
635
+ "connect for the same agent replaces its token. Then it makes one plain HTTP call to test the token.",
562
636
  "Only an admin of the organisation connects agents.",
563
637
  "",
564
638
  `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.",
639
+ "the hooks that show the session's state on the board, and the skills.",
568
640
  "",
569
641
  "For Codex ($CODEX_HOME/hooks.json) and Cursor (~/.cursor/hooks.json) it writes the Orlan hooks: the",
570
642
  "session start gets the brief (`orlan hook start`), a turn that ends gets a waiting request",
@@ -575,125 +647,61 @@ export const commands = {
575
647
  "turn ends, it runs `orlan hook wait` and sends the request to the session as a new prompt, so the",
576
648
  "session wakes while OpenCode runs. The board shows the session's state.",
577
649
  "",
650
+ "For other it writes the skills in this project, in .agents/skills.",
651
+ "",
578
652
  AGENT_HELP,
579
653
  "--topic <id> A topic the agent can use. Repeat it for more. Default: the topics you chose at sign-in.",
580
654
  "--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.",
655
+ "--no-plugin Claude Code: do not install the plugin. Write the skills to ~/.claude/skills.",
582
656
  ],
583
- options: {
584
- ...agentOption,
585
- topic: { type: "string", multiple: true },
586
- role: { type: "string" },
587
- "no-plugin": { type: "boolean" },
588
- },
657
+ options: CONNECT_OPTIONS,
589
658
  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
- },
659
+ run: (values, _positionals, io) => connect(agentToConnect(values), values, io),
614
660
  },
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
- ],
661
+ disconnect: {
662
+ usage: "orlan disconnect [--agent <id>]",
663
+ summary: "Revoke the agent's token, and take out its Orlan plugin or hooks and any old Orlan server entry.",
664
+ details: ["The skills stay. `orlan connect` connects the agent again.", "", AGENT_HELP],
625
665
  options: agentOption,
626
666
  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;
667
+ run: (values, _positionals, io) => disconnect(agentToConnect(values), io),
668
+ },
669
+ "mcp connect": {
670
+ usage: "orlan mcp connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
671
+ summary: "The old name of orlan connect. Use orlan connect: this name goes away in the next release.",
672
+ details: ["It does what `orlan connect` does. Orlan has no MCP entry for the agent now (D088)."],
673
+ options: CONNECT_OPTIONS,
674
+ positionals: [0, 0],
675
+ run: (values, _positionals, io) => {
676
+ oldName("Use orlan connect: orlan mcp connect goes away in the next release.");
677
+ return connect(agentToConnect(values), values, io);
637
678
  },
638
679
  },
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.",
680
+ "mcp print": {
681
+ usage: "orlan mcp print [--agent <id>]",
682
+ summary: "The old way to connect another agent. Use orlan connect: this name goes away in the next release.",
642
683
  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.",
684
+ "Orlan has no MCP settings to print now (D088). It does what `orlan connect` does, for the agent",
685
+ "--agent names, else other: the token, and the skills in this project, in .agents/skills.",
647
686
  "",
648
- AGENT_TOKEN_HELP,
687
+ AGENT_HELP,
649
688
  ],
650
689
  options: agentOption,
651
690
  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;
691
+ run: (values, _positionals, io) => {
692
+ oldName("Use orlan connect: orlan mcp print goes away in the next release.");
693
+ return connect(typeof values.agent === "string" ? parseAgent(values.agent) : "other", values, io);
661
694
  },
662
695
  },
663
696
  "mcp disconnect": {
664
697
  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],
698
+ summary: "The old name of orlan disconnect. Use orlan disconnect: this name goes away in the next release.",
699
+ details: ["It does what `orlan disconnect` does.", "", AGENT_HELP],
667
700
  options: agentOption,
668
701
  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;
702
+ run: (values, _positionals, io) => {
703
+ oldName("Use orlan disconnect: orlan mcp disconnect goes away in the next release.");
704
+ return disconnect(agentToConnect(values), io);
697
705
  },
698
706
  },
699
707
  "topics list": {
@@ -716,38 +724,59 @@ export const commands = {
716
724
  }),
717
725
  },
718
726
  "files list": {
719
- usage: "orlan files list --topic <topic> [--agent <id>] [--json]",
727
+ usage: "orlan files list [--topic <topic>] [--agent <id>] [--json]",
720
728
  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],
729
+ details: [
730
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
731
+ AGENT_TOKEN_HELP,
732
+ JSON_HELP,
733
+ ],
722
734
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
723
735
  positionals: [0, 0],
724
736
  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" });
737
+ const topicId = await topicOrDefault(api, values);
738
+ const { files } = await api.call("read_board", { topicId, view: "files" });
729
739
  return finish(io, values, { files }, files.length === 0
730
740
  ? ["No files."]
731
741
  : 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
742
+ `${file.versions} version${file.versions === 1 ? "" : "s"}` +
743
+ (file.current.changed
744
+ ? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
745
+ : "")), files.length === 0
733
746
  ? "orlan files add <path> (a new file)"
734
747
  : "orlan files pull <file> --edit --out <path> (the file id or its name)");
735
748
  }),
736
749
  },
737
750
  "files add": {
738
- usage: "orlan files add <path> [--topic <topic>] [--agent <id>] [--json]",
751
+ usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
739
752
  summary: "Post a local file as a new file on a topic, and put it on the board.",
740
753
  details: [
741
754
  "One request sends the file and places it on the board, right of the other items. The server",
742
755
  "renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file, with the right extension.",
743
756
  "",
757
+ "--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
758
+ " place. `orlan board` shows where the items are.",
744
759
  "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
745
760
  AGENT_TOKEN_HELP,
746
761
  JSON_HELP,
747
762
  ],
748
- options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
763
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, at: { type: "string" } },
749
764
  positionals: [1, 1],
750
765
  run: (values, [filePath], io) => withAgent(values, async (api) => {
766
+ const at = numbersOption(values, "at", 2);
767
+ if (at) {
768
+ // A place on the board: the file goes up first, then the post places it.
769
+ const topicId = await topicOrDefault(api, values);
770
+ const uploadId = await api.upload(await readFile(filePath), { topic: topicId });
771
+ const posted = await api.call("post", { topicId, name: path.basename(filePath), uploadId, position: { x: at[0], y: at[1] } });
772
+ return finish(io, values, {
773
+ file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
774
+ boardItemId: posted.boardItemId,
775
+ }, [
776
+ `Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
777
+ `board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
778
+ ], "orlan wait (comments on the file come as requests)");
779
+ }
751
780
  const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: values.topic }, await readFile(filePath), "the new file");
752
781
  return finish(io, values, answer, [
753
782
  `Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
@@ -756,13 +785,20 @@ export const commands = {
756
785
  }),
757
786
  },
758
787
  "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).",
788
+ usage: "orlan files pull <file> [--version <n>] [--pages <n,n> | --since <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]] [--agent <id>] [--json]",
789
+ summary: "Download a version of a file (the current one by default), or images and text of its pages.",
761
790
  details: [
791
+ "With --pages or --since it gets pages, not the file: it saves the image of each page in the",
792
+ "--out folder as <name>-page-<n>.png, and prints the text of each page.",
793
+ "",
762
794
  "<file> The file id, or its name where it is unique on the topic.",
763
795
  "--version <n> A version number, for example 5 or v5. Default: the current version.",
796
+ "--pages <n,n> The pages to get, from 1, for example 3 or 2,5.",
797
+ "--since <n> The pages that changed since this older version, for example 4 or v4. With",
798
+ " --pages, only those of the pages that changed.",
764
799
  "--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.",
800
+ "--out <path> Where to write it. Default: the file's own name, in the folder you are in. With",
801
+ " --pages or --since: the folder of the page images. Default: the folder you are in.",
766
802
  "--edit You pull it to edit it: this puts your claim on the file, which other agents and",
767
803
  " people see. When another agent has a claim, you get it and no claim of your own.",
768
804
  " A push or respond ends your claim, and it takes the pulled version as its base.",
@@ -775,6 +811,8 @@ export const commands = {
775
811
  ...agentOption,
776
812
  ...jsonOption,
777
813
  version: { type: "string" },
814
+ pages: { type: "string" },
815
+ since: { type: "string" },
778
816
  topic: { type: "string" },
779
817
  out: { type: "string" },
780
818
  edit: { type: "boolean" },
@@ -787,12 +825,17 @@ export const commands = {
787
825
  throw new OrlanError("--shared and --ttl go with --edit.", undefined, "add --edit, or leave them out");
788
826
  }
789
827
  const version = versionNumber(values, "version");
790
- // One call: the file, the version (v5), and the claim. Orlan finds a file by its name too.
828
+ const since = versionNumber(values, "since");
829
+ const pages = pageList(values);
830
+ const pageMode = pages !== undefined || since !== undefined;
831
+ // One call: the file, the version (v5), the pages, and the claim. Orlan finds a file by its name too.
791
832
  const answer = await api.call("get_file", {
792
833
  artifactId: fileRef,
793
834
  ...topicInput(values),
794
835
  ...(version ? { versionId: `v${version}` } : {}),
795
- pageImages: "none",
836
+ ...(pages ? { pages } : {}),
837
+ ...(since !== undefined ? { changedSince: since } : {}),
838
+ pageImages: pageMode ? "url" : "none",
796
839
  ...(values.edit
797
840
  ? {
798
841
  edit: true,
@@ -801,6 +844,8 @@ export const commands = {
801
844
  }
802
845
  : {}),
803
846
  });
847
+ if (pageMode)
848
+ return pullPages(api, config, values, io, answer, since);
804
849
  const url = answer.originalUrl ?? answer.viewUrl;
805
850
  if (!url)
806
851
  throw new OrlanError("Orlan gave no address for this file.", undefined, "run the command again");
@@ -815,15 +860,10 @@ export const commands = {
815
860
  }
816
861
  const target = typeof values.out === "string" ? values.out : path.basename(answer.file.name);
817
862
  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
- }
863
+ const lines = [
864
+ `Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
865
+ ...claimLines(answer),
866
+ ];
827
867
  return finish(io, values, {
828
868
  file: { id: answer.file.id, name: answer.file.name },
829
869
  version: answer.version.number,
@@ -879,6 +919,44 @@ export const commands = {
879
919
  ], "orlan wait (comments on the version come as requests)");
880
920
  }),
881
921
  },
922
+ "files rollback": {
923
+ usage: 'orlan files rollback <file> --version <n> [--changelog "<why>"] [--topic <topic>] [--agent <id>] [--json]',
924
+ summary: "Post an older version of a file again, as its newest version.",
925
+ details: [
926
+ "The new version is a copy of the older one. It waits for a person to make it current, as a push does.",
927
+ "",
928
+ "<file> The file id, or its name where it is unique on the topic.",
929
+ "--version <n> The older version to post again, for example 3 or v3. Required.",
930
+ "--changelog <text> Why, for the people who review it.",
931
+ "--topic <topic> The topic of a file name, when two topics have a file with that name.",
932
+ AGENT_TOKEN_HELP,
933
+ JSON_HELP,
934
+ ],
935
+ options: {
936
+ ...agentOption,
937
+ ...jsonOption,
938
+ version: { type: "string" },
939
+ changelog: { type: "string" },
940
+ topic: { type: "string" },
941
+ },
942
+ positionals: [1, 1],
943
+ run: (values, [fileRef], io) => withAgent(values, async (api) => {
944
+ const version = versionNumber(values, "version");
945
+ if (!version) {
946
+ throw new OrlanError("Add --version <n>.", undefined, "orlan files list shows the versions");
947
+ }
948
+ const posted = await api.call("post", {
949
+ artifactId: fileRef,
950
+ ...topicInput(values),
951
+ versionId: `v${version}`,
952
+ ...(typeof values.changelog === "string" ? { changelog: values.changelog } : {}),
953
+ });
954
+ return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number, copyOf: version }, [
955
+ `Posted version ${posted.posted.number} of ${quote(posted.file.name)}, a copy of v${version}. ` +
956
+ "It waits for a person to make it current.",
957
+ ], "orlan wait (comments on the version come as requests)");
958
+ }),
959
+ },
882
960
  "comments list": {
883
961
  usage: "orlan comments list [--topic <topic>] [--status open|resolved|all] [--file <file>] [--json]",
884
962
  summary: "List the comment threads, newest first.",
@@ -979,16 +1057,7 @@ export const commands = {
979
1057
  },
980
1058
  positionals: [1, 2],
981
1059
  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
- };
1060
+ const numbers = (option, count) => numbersOption(values, option, count);
992
1061
  const [first, second] = positionals;
993
1062
  const body = (second ?? first);
994
1063
  const usage = "orlan comment --help (the three forms)";
@@ -1057,13 +1126,399 @@ export const commands = {
1057
1126
  return finish(io, values, { id: thread.thread, number: thread.number, status: "resolved" }, [`Resolved #${thread.number}.`], WAIT_STEP);
1058
1127
  }),
1059
1128
  },
1129
+ board: {
1130
+ usage: "orlan board [--topic <topic>] [--agent <id>] [--json]",
1131
+ summary: "Print the board: its items with their ids and places, the timer, the votes and the open tasks.",
1132
+ details: [
1133
+ "Each item is one line: its id, its type, its place (x,y) and size (w x h) in board coordinates, and",
1134
+ "its text. A file shows its name and pages, an arrow the items it links. People vote; an agent reads",
1135
+ "the vote and its result, it does not vote.",
1136
+ "",
1137
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1138
+ AGENT_TOKEN_HELP,
1139
+ JSON_HELP,
1140
+ ],
1141
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1142
+ positionals: [0, 0],
1143
+ run: (values, _positionals, io) => withAgent(values, async (api) => {
1144
+ const topicId = await topicOrDefault(api, values);
1145
+ // Two requests at the same time: the board, and the names of its files.
1146
+ const [board, { files }] = await Promise.all([
1147
+ api.call("read_board", { topicId, view: "board" }),
1148
+ api.call("read_board", { topicId, view: "files" }),
1149
+ ]);
1150
+ const names = new Map(files.map((file) => [file.id, file.name]));
1151
+ const items = board.items.map((item) => item.type === "file" ? { ...item, name: names.get(item.artifactId ?? "") ?? "" } : item);
1152
+ const { timer, vote, lastVote, tasks = [] } = board.session ?? {};
1153
+ const lines = [
1154
+ `Board of ${quote(board.topic.name)} (${board.topic.id}): ${items.length} item${items.length === 1 ? "" : "s"}.`,
1155
+ ];
1156
+ if (items.some((item) => item.text) || vote || tasks.length > 0)
1157
+ lines.push(QUOTED_TEXT);
1158
+ const round = (value) => Math.round(value);
1159
+ for (const item of items) {
1160
+ const place = `at ${round(item.x)},${round(item.y)}, ${round(item.w)}x${round(item.h)}`;
1161
+ const end = (id, x, y) => id ?? `${round(x)},${round(y)}`;
1162
+ lines.push(item.type === "arrow"
1163
+ ? `- ${item.id} arrow from ${end(item.from, item.x, item.y)} to ${end(item.to, item.x + item.w, item.y + item.h)}`
1164
+ : item.type === "file"
1165
+ ? `- ${item.id} file ${quote(item.name ?? "")} (${item.artifactId}) ${place}, ` +
1166
+ `${item.pages?.length ?? 0} page${item.pages?.length === 1 ? "" : "s"}`
1167
+ : `- ${item.id} ${item.type} ${place}${item.text === undefined ? "" : `: ${quote(item.text)}`}`);
1168
+ }
1169
+ if (timer) {
1170
+ lines.push(`timer: ${timer.left}s left of ${timer.of}s${timer.paused ? ", paused" : ""}, by ${quote(timer.by)}` +
1171
+ (timer.for ? `, for ${quote(timer.for)}` : ""));
1172
+ }
1173
+ if (vote)
1174
+ lines.push(`open vote: ${voteText(vote)} (people vote, you do not)`);
1175
+ if (lastVote)
1176
+ lines.push(`last vote: ${voteText(lastVote)}`);
1177
+ lines.push(`open tasks: ${tasks.length === 0 ? "none" : tasks.length}`);
1178
+ for (const task of tasks) {
1179
+ lines.push(`- ${task.id} ${quote(task.text)}${task.to ? ` for ${quote(task.to)}` : ""}` +
1180
+ (task.thread !== undefined ? ` from #${task.thread}` : ""));
1181
+ }
1182
+ 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>');
1183
+ }),
1184
+ },
1185
+ "board add": {
1186
+ usage: 'orlan board add note|text|frame ["<text>"] --at <x,y> [--size <w,h>] | arrow --from <item> --to <item> [--topic <topic>] [--agent <id>] [--json]',
1187
+ summary: "Add a note, a text, a frame or an arrow to the board.",
1188
+ details: [
1189
+ "A note, a text or a frame goes at --at, with its text (a frame's text is its title). An arrow",
1190
+ "links two items: --from and --to take their ids, from `orlan board`. People see it at once, and",
1191
+ "a person can undo it. To add several items in one step, use `orlan board edit`.",
1192
+ "",
1193
+ "--at <x,y> The top left corner, in board coordinates. A negative x goes with =: --at=-50,120.",
1194
+ "--size <w,h> The width and the height. Default: the size a person gets.",
1195
+ "--from <item> The item the arrow starts at.",
1196
+ "--to <item> The item the arrow points to.",
1197
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1198
+ AGENT_TOKEN_HELP,
1199
+ JSON_HELP,
1200
+ ],
1201
+ options: {
1202
+ ...agentOption,
1203
+ ...jsonOption,
1204
+ at: { type: "string" },
1205
+ size: { type: "string" },
1206
+ from: { type: "string" },
1207
+ to: { type: "string" },
1208
+ topic: { type: "string" },
1209
+ },
1210
+ positionals: [1, 2],
1211
+ run: (values, [type, text], io) => {
1212
+ const usage = "orlan board add --help";
1213
+ let edit;
1214
+ if (type === "arrow") {
1215
+ if (typeof values.from !== "string" || typeof values.to !== "string") {
1216
+ throw new OrlanError("An arrow needs --from <item> and --to <item>.", undefined, "orlan board (the item ids)");
1217
+ }
1218
+ if (text !== undefined)
1219
+ throw new OrlanError("An arrow has no text.", undefined, usage);
1220
+ edit = { op: "create", type, from: values.from, to: values.to };
1221
+ }
1222
+ else if (type === "note" || type === "text" || type === "frame") {
1223
+ const at = numbersOption(values, "at", 2);
1224
+ if (!at)
1225
+ throw new OrlanError(`A ${type} needs --at <x,y>.`, undefined, "orlan board (where the items are)");
1226
+ const size = numbersOption(values, "size", 2);
1227
+ edit = {
1228
+ op: "create",
1229
+ type,
1230
+ x: at[0],
1231
+ y: at[1],
1232
+ ...(size ? { w: size[0], h: size[1] } : {}),
1233
+ ...(text !== undefined ? { text } : {}),
1234
+ };
1235
+ }
1236
+ else {
1237
+ throw new OrlanError("The item is a note, a text, a frame or an arrow.", undefined, usage);
1238
+ }
1239
+ return withAgent(values, async (api) => {
1240
+ const { ids } = await editBoard(api, values, [edit]);
1241
+ const where = type === "arrow" ? `from ${values.from} to ${values.to}` : `at ${edit.x},${edit.y}`;
1242
+ return finish(io, values, { id: ids[0], type }, [`Added ${type} ${ids[0]} ${where}.`], type === "arrow"
1243
+ ? "orlan board (the board with the arrow)"
1244
+ : `orlan board add arrow --from ${ids[0]} --to <item> (to link it), or orlan board`);
1245
+ });
1246
+ },
1247
+ },
1248
+ "board change": {
1249
+ usage: 'orlan board change <item> [--text "<text>"] [--at <x,y>] [--size <w,h>] [--from <item>|none] [--to <item>|none] [--topic <topic>] [--agent <id>] [--json]',
1250
+ summary: "Change an item of the board: its text, place, size, or the ends of an arrow.",
1251
+ details: [
1252
+ "The changes are one step, which a person can undo. A file on the board only moves (--at).",
1253
+ "",
1254
+ "<item> The item id, from `orlan board`.",
1255
+ "--text <text> The new text of a note or a text, or the title of a frame.",
1256
+ "--at <x,y> Move the item: its new top left corner, in board coordinates. A negative x goes",
1257
+ " with =: --at=-50,120.",
1258
+ "--size <w,h> The new width and height.",
1259
+ "--from <item> The item the arrow starts at. none: the end stays where it is, on no item.",
1260
+ "--to <item> The item the arrow points to. none: the end stays where it is, on no item.",
1261
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1262
+ AGENT_TOKEN_HELP,
1263
+ JSON_HELP,
1264
+ ],
1265
+ options: {
1266
+ ...agentOption,
1267
+ ...jsonOption,
1268
+ text: { type: "string" },
1269
+ at: { type: "string" },
1270
+ size: { type: "string" },
1271
+ from: { type: "string" },
1272
+ to: { type: "string" },
1273
+ topic: { type: "string" },
1274
+ },
1275
+ positionals: [1, 1],
1276
+ run: (values, [id], io) => {
1277
+ const at = numbersOption(values, "at", 2);
1278
+ const size = numbersOption(values, "size", 2);
1279
+ const end = (option) => {
1280
+ const value = values[option];
1281
+ if (typeof value !== "string")
1282
+ return {};
1283
+ return { [option]: value === "none" ? null : value };
1284
+ };
1285
+ const update = {
1286
+ ...(typeof values.text === "string" ? { text: values.text } : {}),
1287
+ ...(size ? { w: size[0], h: size[1] } : {}),
1288
+ ...end("from"),
1289
+ ...end("to"),
1290
+ };
1291
+ const edits = [
1292
+ ...(at ? [{ op: "move", id, x: at[0], y: at[1] }] : []),
1293
+ ...(Object.keys(update).length > 0 ? [{ op: "update", id, ...update }] : []),
1294
+ ];
1295
+ if (edits.length === 0) {
1296
+ throw new OrlanError("Give --text, --at, --size, --from or --to.", undefined, `orlan board change ${id} --text "<text>"`);
1297
+ }
1298
+ return withAgent(values, async (api) => {
1299
+ await editBoard(api, values, edits);
1300
+ return finish(io, values, { id }, [`Changed ${id}.`], "orlan board (the board with the change)");
1301
+ });
1302
+ },
1303
+ },
1304
+ "board delete": {
1305
+ usage: "orlan board delete <item>... [--topic <topic>] [--agent <id>] [--json]",
1306
+ summary: "Delete items from the board: notes, texts, frames and arrows.",
1307
+ details: [
1308
+ "One step for all the items, which a person can undo. A file on the board is not deleted here: a",
1309
+ "person does that.",
1310
+ "",
1311
+ "<item> The item id, from `orlan board`. Give several to delete several.",
1312
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1313
+ AGENT_TOKEN_HELP,
1314
+ JSON_HELP,
1315
+ ],
1316
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1317
+ positionals: [1, 100],
1318
+ run: (values, ids, io) => withAgent(values, async (api) => {
1319
+ await editBoard(api, values, ids.map((id) => ({ op: "delete", id })));
1320
+ 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)");
1321
+ }),
1322
+ },
1323
+ "board edit": {
1324
+ usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
1325
+ summary: "Make several changes to the board in one step: all of them, or none.",
1326
+ details: [
1327
+ 'The edits are a JSON list of at most 100. A person can undo the step. "#n" is the item that edit n',
1328
+ "(from 0) made, so one step can add two notes and the arrow between them:",
1329
+ ' [{"op":"create","type":"note","x":0,"y":0,"text":"Web"},',
1330
+ ' {"op":"create","type":"note","x":400,"y":0,"text":"API"},',
1331
+ ' {"op":"create","type":"arrow","from":"#0","to":"#1"}]',
1332
+ "",
1333
+ "create type note, text or frame with x, y and text (and w, h); type arrow with from and to.",
1334
+ "move id, x, y.",
1335
+ "update id, and text, w, h, or from and to (null: on no item).",
1336
+ "delete id. A file on the board only moves.",
1337
+ "",
1338
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1339
+ AGENT_TOKEN_HELP,
1340
+ JSON_HELP,
1341
+ ],
1342
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1343
+ positionals: [1, 1],
1344
+ run: (values, [text], io) => {
1345
+ let edits;
1346
+ try {
1347
+ edits = JSON.parse(text);
1348
+ }
1349
+ catch {
1350
+ edits = undefined;
1351
+ }
1352
+ if (!Array.isArray(edits) || edits.length === 0) {
1353
+ throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
1354
+ }
1355
+ return withAgent(values, async (api) => {
1356
+ const { ids } = await editBoard(api, values, edits);
1357
+ 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)");
1358
+ });
1359
+ },
1360
+ },
1361
+ "tasks add": {
1362
+ usage: 'orlan tasks add "<text>" [--assignee me|user:<id>|agent:<id>] [--thread <thread>] [--done] [--topic <topic>] [--agent <id>] [--json]',
1363
+ summary: "Add a task to the topic's task list.",
1364
+ details: [
1365
+ "People see the task on the board at once. `orlan board` lists the open tasks.",
1366
+ "",
1367
+ "--assignee <who> me (this agent), user:<id> or agent:<id>. Default: nobody.",
1368
+ "--thread <t> The comment thread the task comes from: #12 or the thread id.",
1369
+ "--done The task is done already.",
1370
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1371
+ AGENT_TOKEN_HELP,
1372
+ JSON_HELP,
1373
+ ],
1374
+ options: {
1375
+ ...agentOption,
1376
+ ...jsonOption,
1377
+ assignee: { type: "string" },
1378
+ thread: { type: "string" },
1379
+ done: { type: "boolean" },
1380
+ topic: { type: "string" },
1381
+ },
1382
+ positionals: [1, 1],
1383
+ run: (values, [text], io) => withAgent(values, async (api, config) => {
1384
+ const assignee = assigneeOption(config, values);
1385
+ const task = await api.call("record", {
1386
+ kind: "task",
1387
+ topicId: await topicOrDefault(api, values),
1388
+ text,
1389
+ ...(assignee ? { assignee } : {}),
1390
+ ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
1391
+ ...(values.done ? { done: true } : {}),
1392
+ });
1393
+ return finish(io, values, { task }, [`Added task ${task.id}: ${taskText(task)}.`], `orlan tasks change ${task.id} --done (when it is done)`);
1394
+ }),
1395
+ },
1396
+ "tasks change": {
1397
+ usage: 'orlan tasks change <task id> [--text "<text>"] [--done | --open] [--assignee me|user:<id>|agent:<id>|none] [--agent <id>] [--json]',
1398
+ summary: "Change a task: its text, done or open, or its assignee.",
1399
+ details: [
1400
+ "<task id> The task id, from `orlan board` or `orlan brief`.",
1401
+ "--text <text> The new text.",
1402
+ "--done Mark it done.",
1403
+ "--open Mark it open again.",
1404
+ "--assignee <who> me (this agent), user:<id>, agent:<id>, or none.",
1405
+ AGENT_TOKEN_HELP,
1406
+ JSON_HELP,
1407
+ ],
1408
+ options: {
1409
+ ...agentOption,
1410
+ ...jsonOption,
1411
+ text: { type: "string" },
1412
+ done: { type: "boolean" },
1413
+ open: { type: "boolean" },
1414
+ assignee: { type: "string" },
1415
+ },
1416
+ positionals: [1, 1],
1417
+ run: (values, [taskId], io) => withAgent(values, async (api, config) => {
1418
+ if (values.done && values.open)
1419
+ throw new OrlanError("Give --done or --open, not both.");
1420
+ const assignee = assigneeOption(config, values);
1421
+ const change = {
1422
+ ...(typeof values.text === "string" ? { text: values.text } : {}),
1423
+ ...(values.done ? { done: true } : values.open ? { done: false } : {}),
1424
+ ...(assignee !== undefined ? { assignee } : {}),
1425
+ };
1426
+ if (Object.keys(change).length === 0) {
1427
+ throw new OrlanError("Give --text, --done, --open or --assignee.", undefined, `orlan tasks change ${taskId} --done`);
1428
+ }
1429
+ const task = await api.call("record", { kind: "task", taskId, ...change });
1430
+ return finish(io, values, { task }, [`Changed task ${task.id}: ${taskText(task)}.`], WAIT_STEP);
1431
+ }),
1432
+ },
1433
+ "decisions add": {
1434
+ usage: 'orlan decisions add "<title>" [--body "<text>"] [--thread <thread>] [--topic <topic>] [--agent <id>] [--json]',
1435
+ summary: "Record a decision in the topic's decision log.",
1436
+ details: [
1437
+ "Record a decision the people made, so it is not lost in a thread. `orlan search` finds it later.",
1438
+ "",
1439
+ "--body <text> The decision in full: what, and why.",
1440
+ "--thread <t> The comment thread the decision comes from: #12 or the thread id.",
1441
+ "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1442
+ AGENT_TOKEN_HELP,
1443
+ JSON_HELP,
1444
+ ],
1445
+ options: {
1446
+ ...agentOption,
1447
+ ...jsonOption,
1448
+ body: { type: "string" },
1449
+ thread: { type: "string" },
1450
+ topic: { type: "string" },
1451
+ },
1452
+ positionals: [1, 1],
1453
+ run: (values, [title], io) => withAgent(values, async (api) => {
1454
+ const decision = await api.call("record", {
1455
+ kind: "decision",
1456
+ topicId: await topicOrDefault(api, values),
1457
+ title,
1458
+ ...(typeof values.body === "string" ? { body: values.body } : {}),
1459
+ ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
1460
+ });
1461
+ return finish(io, values, { decision }, [`Recorded decision ${decision.id}: ${quote(decision.title)}.`], WAIT_STEP);
1462
+ }),
1463
+ },
1464
+ search: {
1465
+ usage: 'orlan search "<words>" [--topic <topic>] [--limit <n>] [--agent <id>] [--json]',
1466
+ summary: "Search the topics of the agent: topic and file names, page text, comments and decisions.",
1467
+ details: [
1468
+ "The matches are between « and », with some text around them. Orlan searches the current version",
1469
+ "of each file, and the newest version that was sent.",
1470
+ "",
1471
+ "--topic <topic> Only this topic: its id or its name. Default: every topic of the agent.",
1472
+ "--limit <n> At most this many results, from 1 to 100. Default: 20.",
1473
+ AGENT_TOKEN_HELP,
1474
+ JSON_HELP,
1475
+ ],
1476
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, limit: { type: "string" } },
1477
+ positionals: [1, 1],
1478
+ run: (values, [words], io) => withAgent(values, async (api) => {
1479
+ const limit = values.limit === undefined ? undefined : Number(values.limit);
1480
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
1481
+ throw new OrlanError("--limit takes a number from 1 to 100.", undefined, "run the command again with --limit 20");
1482
+ }
1483
+ const { results } = await api.call("search", {
1484
+ q: words,
1485
+ ...topicInput(values),
1486
+ ...(limit ? { limit } : {}),
1487
+ });
1488
+ const on = (result) => `on ${quote(result.topic.name)}`;
1489
+ const version = (result) => (result.version ? ` v${result.version.number}` : "");
1490
+ const lines = results.length === 0 ? [`No match for ${quote(words)}.`] : [QUOTED_TEXT];
1491
+ for (const result of results) {
1492
+ const { ids } = result;
1493
+ lines.push(result.kind === "topic"
1494
+ ? `- topic ${quote(result.title)} (${result.topic.id}): ${quote(result.snippet)}`
1495
+ : result.kind === "file"
1496
+ ? `- file ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
1497
+ : result.kind === "page"
1498
+ ? `- page ${ids.page} of ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
1499
+ : result.kind === "comment"
1500
+ ? `- comment in ${result.title} (thread ${ids.threadId}) ${on(result)}: ${quote(result.snippet)}`
1501
+ : `- decision ${quote(result.title)} (${ids.decisionId}) ${on(result)}: ${quote(result.snippet)}`);
1502
+ }
1503
+ const page = results.find((result) => result.kind === "page");
1504
+ const comment = results.find((result) => result.kind === "comment");
1505
+ return finish(io, values, { results }, lines, page
1506
+ ? `orlan files pull ${page.ids.artifactId} --pages ${page.ids.page} (the page of a match)`
1507
+ : comment
1508
+ ? `orlan comments list --topic ${comment.topic.id} --status all (the threads)`
1509
+ : results.length === 0
1510
+ ? "search again with other words"
1511
+ : "orlan brief (where the work stands)");
1512
+ }),
1513
+ },
1060
1514
  brief: {
1061
1515
  usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
1062
1516
  summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
1063
1517
  details: [
1064
- "For the start of a session: one short answer, with no MCP session. The topic is the one the agent",
1518
+ "For the start of a session: one short answer, in one plain HTTP call. The topic is the one the agent",
1065
1519
  "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.",
1520
+ "the files with their current versions, the open vote and the open tasks (one line each), and one",
1521
+ "next_step line. People vote; an agent reads the vote, it does not vote.",
1067
1522
  "",
1068
1523
  "--topic <topic> Another topic of the agent: its id or its name.",
1069
1524
  AGENT_TOKEN_HELP,
@@ -1311,9 +1766,9 @@ export const commands = {
1311
1766
  usage: "orlan hook start --agent codex|cursor",
1312
1767
  summary: "The session start hook of Codex and Cursor: report working and give the session the brief.",
1313
1768
  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.",
1769
+ "`orlan connect --agent codex|cursor` installs it. It prints `orlan brief`, what Orlan is and how",
1770
+ "to answer a request, in the JSON the harness adds to the session's context, and the board shows",
1771
+ "the session as working. With the agent not connected, it prints nothing.",
1317
1772
  "",
1318
1773
  "--agent <id> codex or cursor.",
1319
1774
  ],
@@ -1330,16 +1785,15 @@ export const commands = {
1330
1785
  apiCall(config.server, "/api/agent/brief?hook=true", { token }),
1331
1786
  reportQuietly(config.server, token, "working", fields.session),
1332
1787
  ]);
1333
- const context = [
1788
+ const lines = [
1334
1789
  ...brief.lines,
1790
+ await orlanIntro(),
1335
1791
  `No monitor wakes this ${agentLabels[agent]} session. A new Orlan request comes to you when your turn ends, ` +
1336
1792
  "from the Orlan stop hook. Do not run orlan wait yourself: it blocks your turn.",
1337
1793
  "Answer a request with the orlan command in few steps: orlan files pull <file id> --edit --out <path> && cat <path>; " +
1338
1794
  '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 }));
1795
+ ];
1796
+ io.out(sessionStartOutput(agent, lines));
1343
1797
  return 0;
1344
1798
  },
1345
1799
  },
@@ -1347,7 +1801,7 @@ export const commands = {
1347
1801
  usage: "orlan hook stop --agent codex|cursor",
1348
1802
  summary: "The stop hook of Codex and Cursor: give the session a waiting request, else report idle.",
1349
1803
  details: [
1350
- "`orlan mcp connect --agent codex|cursor` installs it. When the session's turn ends, it takes the",
1804
+ "`orlan connect --agent codex|cursor` installs it. When the session's turn ends, it takes the",
1351
1805
  "new requests of the agent (as `orlan inbox --new` does) and gives them to the session, so it works",
1352
1806
  "on at once: Codex gets decision block with the requests, Cursor a followup_message. The board",
1353
1807
  "shows the session as working. With no request, it prints nothing and the board shows it idle.",
@@ -1401,7 +1855,7 @@ export const commands = {
1401
1855
  usage: "orlan hook wait --agent opencode",
1402
1856
  summary: "The wait of the OpenCode plugin: wait for a request and print it as the prompt of a new turn.",
1403
1857
  details: [
1404
- "`orlan mcp connect --agent opencode` installs the plugin that runs it when a session's turn ends.",
1858
+ "`orlan connect --agent opencode` installs the plugin that runs it when a session's turn ends.",
1405
1859
  'It waits as `orlan wait` does, then prints one JSON line: {"prompt"} with the request, which the',
1406
1860
  'plugin sends to the session, or {"stopped"} when a person stopped the agent on the board. It ends',
1407
1861
  "when its standard input closes (OpenCode exited); a request it did not print stays in the inbox.",
@@ -1463,3 +1917,112 @@ export const commands = {
1463
1917
  },
1464
1918
  },
1465
1919
  };
1920
+ /** Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. */
1921
+ async function editBoard(api, values, edits) {
1922
+ return api.call("edit_board", { topicId: await topicOrDefault(api, values), edits });
1923
+ }
1924
+ /** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
1925
+ function assigneeOption(config, values) {
1926
+ const raw = values.assignee;
1927
+ if (typeof raw !== "string")
1928
+ return undefined;
1929
+ if (raw === "none")
1930
+ return null;
1931
+ if (raw === "me") {
1932
+ return { kind: "agent", id: config.agents[pickAgent(config, values)]?.tokenId };
1933
+ }
1934
+ const match = /^(user|agent):(\S+)$/.exec(raw);
1935
+ if (!match) {
1936
+ throw new OrlanError("--assignee takes me, user:<id>, agent:<id> or none.", undefined, "run the command again with --assignee me");
1937
+ }
1938
+ return { kind: match[1], id: match[2] };
1939
+ }
1940
+ /** A task in one line: its text, its assignee, and done. */
1941
+ const taskText = (task) => `${quote(task.text)}${task.assignee ? ` for ${quote(task.assignee.name)}` : ""}${task.done ? ", done" : ", open"}`;
1942
+ /** A vote in one line, as the brief shows it, with its result when people can see it. */
1943
+ function voteText(vote) {
1944
+ const result = vote.result
1945
+ ?.map((entry) => "option" in entry
1946
+ ? `${quote(entry.option)} ${entry.count}${entry.by ? ` (${entry.by.map(quote).join(", ")})` : ""}`
1947
+ : `${entry.on} ${entry.count}`)
1948
+ .join(", ");
1949
+ return (`${vote.kind} ${quote(vote.question)}` +
1950
+ (vote.options ? `, options ${vote.options.map(quote).join(", ")}` : `, ${vote.dots} dots each`) +
1951
+ `, ${vote.voters} voted` +
1952
+ (result ? `, result: ${result}` : ""));
1953
+ }
1954
+ /** --pages as page numbers: "3" or "2,5". */
1955
+ function pageList(values) {
1956
+ if (typeof values.pages !== "string")
1957
+ return undefined;
1958
+ const pages = values.pages.split(",").map((part) => Number(part.trim()));
1959
+ if (pages.length === 0 || pages.some((page) => !Number.isInteger(page) || page < 1)) {
1960
+ 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");
1961
+ }
1962
+ return pages;
1963
+ }
1964
+ /** The claim lines of a pull: the agent's own claim, and each other agent that edits the file. */
1965
+ function claimLines(answer) {
1966
+ const lines = [];
1967
+ if (answer.claim) {
1968
+ lines.push(`Your${answer.claim.shared ? " shared" : ""} claim is on the file from v${answer.claim.baseVersion}.`);
1969
+ }
1970
+ for (const other of answer.claims ?? []) {
1971
+ const minutes = Math.floor((Date.now() - new Date(other.since).getTime()) / 60_000);
1972
+ lines.push(`${quote(other.agent)} is editing it from v${other.baseVersion}${other.shared ? " (shared claim)" : ""} - ` +
1973
+ `${minutes < 1 ? "under 1 min" : `${minutes} min`}.`);
1974
+ }
1975
+ return lines;
1976
+ }
1977
+ /** The image types a page has, and the extension of each. */
1978
+ const PAGE_EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
1979
+ /** `orlan files pull --pages|--since`: saves the image of each page and prints its text. */
1980
+ async function pullPages(api, config, values, io, answer, since) {
1981
+ const folder = typeof values.out === "string" ? values.out : ".";
1982
+ const stem = path.basename(answer.file.name, path.extname(answer.file.name));
1983
+ const saved = [];
1984
+ for (const page of answer.pages ?? []) {
1985
+ const response = await fetch(page.imageUrl, {
1986
+ headers: { authorization: `Bearer ${api.token}` },
1987
+ }).catch((error) => {
1988
+ throw unreachable(config.server, error);
1989
+ });
1990
+ if (!response.ok) {
1991
+ throw new OrlanError(`The image of page ${page.index} from ${config.server} answered ${response.status}.`, undefined, "run the command again");
1992
+ }
1993
+ const type = response.headers.get("content-type")?.split(";")[0] ?? "";
1994
+ const image = path.join(folder, `${stem}-page-${page.index}.${PAGE_EXTENSIONS[type] ?? "png"}`);
1995
+ if (saved.length === 0)
1996
+ await mkdir(folder, { recursive: true });
1997
+ await writeFile(image, Buffer.from(await response.arrayBuffer()));
1998
+ saved.push({ index: page.index, image, text: page.text ?? "" });
1999
+ }
2000
+ const { number, renderStatus } = answer.version;
2001
+ const title = `Version ${number} of ${quote(answer.file.name)}`;
2002
+ const lines = [
2003
+ since !== undefined
2004
+ ? `${title}: ${changedText(saved.map((page) => page.index))} since v${since}.`
2005
+ : `${title}: ${saved.length} page${saved.length === 1 ? "" : "s"}.`,
2006
+ ...(renderStatus !== "done" && saved.length === 0
2007
+ ? [`The pages of v${number} are not rendered yet (${renderStatus}).`]
2008
+ : []),
2009
+ ...saved.map((page) => `Page ${page.index}: saved ${quote(page.image)}. Text: ${quote(page.text)}`),
2010
+ ...claimLines(answer),
2011
+ ];
2012
+ const first = saved[0];
2013
+ return finish(io, values, {
2014
+ file: { id: answer.file.id, name: answer.file.name },
2015
+ version: number,
2016
+ ...(since !== undefined ? { changedSince: since } : {}),
2017
+ pages: saved,
2018
+ ...(answer.claim !== undefined ? { claim: answer.claim } : {}),
2019
+ ...(answer.claims ? { claims: answer.claims } : {}),
2020
+ }, lines, answer.nextStep ??
2021
+ (first
2022
+ ? `look at the images, then orlan comment --file ${answer.file.id} --page ${first.index} "<text>" (a comment on a page)`
2023
+ : renderStatus !== "done"
2024
+ ? "run the command again in a minute"
2025
+ : `orlan files pull ${answer.file.id} (the whole file)`));
2026
+ }
2027
+ /** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
2028
+ const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;