@agentcomms/whatsapp 0.13.0 → 0.13.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.
@@ -1,19 +1,34 @@
1
1
  import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
2
2
  import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, utimes } from "node:fs/promises";
3
- import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
- import { styleText } from "node:util";
8
7
  import { homedir, platform, userInfo } from "node:os";
9
- import { constants as constants$1 } from "node:fs";
8
+ import { styleText } from "node:util";
9
+ import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$1 = "0.13.0";
14
+ const VERSION$1 = "0.13.1";
15
+ //#endregion
16
+ //#region src/caller.ts
17
+ /** This package's name, as its manifest has it. */
18
+ const PACKAGE_NAME = "@agentcomms/whatsapp";
19
+ /**
20
+ * This package as core locates its commands from (CONTRIBUTING.md, "Telling a person what to run"; design 2026-10-04,
21
+ * D1): a module of its own, whose nearest manifest is this package's. Run from a checkout's source it is this file, and
22
+ * a command runs `src/cli.ts`; built, it is a file of `dist`, and a command runs the manifest's bin. Either way the
23
+ * program is this process's Node, which has to be in this package's own `engines.node` range — narrower than core's —
24
+ * or there is no command, and the reason names the range.
25
+ */
26
+ const CALLER = Object.freeze({
27
+ url: import.meta.url,
28
+ packageName: PACKAGE_NAME
29
+ });
15
30
  //#endregion
16
- //#region ../core/dist/update-check-C8sNjCez.mjs
31
+ //#region ../core/dist/update-check-BPwwmUCi.mjs
17
32
  /**
18
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
19
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -804,7 +819,7 @@ const CHANNEL_SNAPSHOT = [
804
819
  guarantee: {
805
820
  ceiling: "code",
806
821
  floor: "code",
807
- why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by agent-resend's own code. A sending-access key can only send, and Resend enforces that."
822
+ why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by this package's own code. A sending-access key can only send, and Resend enforces that."
808
823
  }
809
824
  },
810
825
  narrowing: [{
@@ -924,25 +939,6 @@ function manifestOf(channel) {
924
939
  function accountChannels() {
925
940
  return MANIFESTS.filter((manifest) => manifest.accounts !== void 0);
926
941
  }
927
- /**
928
- * Words as a list a person reads: `a`, `a or b`, `a, b or c` — with `oxford`, `a, b, or c` once there are three.
929
- */
930
- function listed(words, conjunction, options = {}) {
931
- if (words.length <= 1) return words.join("");
932
- const last = words.at(-1);
933
- const rest = words.slice(0, -1);
934
- return `${rest.join(", ")}${options.oxford && rest.length > 1 ? "," : ""} ${conjunction} ${last}`;
935
- }
936
- /**
937
- * Every channel's own approve command, quoted: "`agent-gmail approve` or `agent-slack approve`".
938
- *
939
- * `sending` keeps only the channels whose accounts can be in `send`: the commands that can have prepared a send. A
940
- * channel that never sends — WhatsApp — has an approve command for the changes its `mcp install` and `mcp prune`
941
- * make, and naming it where the approval in hand is a send would send a person to a command that never prepared one.
942
- */
943
- function channelApproveCommands(options = {}) {
944
- return listed(accountChannels().filter((manifest) => options.sending !== true || manifest.accounts?.modes.includes("send") === true).flatMap((manifest) => manifest.approve ? [`\`${manifest.approve}\``] : []), "or");
945
- }
946
942
  /** The noun for one account on `channel` — `mailbox`, `workspace` — or `account` for a channel that names none. */
947
943
  function accountNoun(channel) {
948
944
  return manifestOf(channel)?.accounts?.noun ?? "account";
@@ -960,106 +956,20 @@ function narrowingOwner(option) {
960
956
  return MANIFESTS.find((manifest) => manifest.narrowing?.some((narrowing) => narrowing.option === option));
961
957
  }
962
958
  /**
963
- * The command that connects a mailbox, for a hint about there being none: the `inbox add` of the channel whose
964
- * accounts are mailboxes, in the `inboxes` map.
959
+ * How a mailbox is connected, for a hint about there being none: the `inbox add` of the channel whose accounts are
960
+ * mailboxes, in the `inboxes` map — named in words, "Gmail's inbox add", never as a command. Core does not have that
961
+ * channel's CLI, and finds it only among this machine's registrations (CUE-403); a hint made where none were read must
962
+ * not invent the command from the manifest's binary.
965
963
  */
966
- function connectMailboxCommand() {
964
+ function connectMailboxWords() {
967
965
  const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
968
- return mail ? `${mail.binary} inbox add` : void 0;
969
- }
970
- /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
971
- const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
972
- function randomString(length) {
973
- const bytes = randomBytes(length);
974
- let out = "";
975
- for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
976
- return out;
977
- }
978
- /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
979
- const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
980
- const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
981
- function newApprovalId() {
982
- return `ap_${randomString(26)}`;
983
- }
984
- function newPlanToken() {
985
- return `pl_${randomString(26)}`;
986
- }
987
- const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
988
- /** A short challenge a human types back to approve: letters only, no look-alikes. */
989
- function newChallenge(length = 4) {
990
- let out = "";
991
- for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
992
- return out;
993
- }
994
- /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
995
- function hashChallenge(challenge) {
996
- return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
997
- }
998
- /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
999
- function challengeMatches(answer, storedHash) {
1000
- const a = Buffer.from(hashChallenge(answer), "hex");
1001
- const b = Buffer.from(storedHash, "hex");
1002
- return a.length === b.length && timingSafeEqual(a, b);
1003
- }
1004
- function okEnvelope(data) {
1005
- return {
1006
- ok: true,
1007
- schemaVersion: 1,
1008
- data
1009
- };
1010
- }
1011
- function errorEnvelope(error) {
1012
- const body = {
1013
- code: error.code,
1014
- message: error.message
1015
- };
1016
- if (error.hint !== void 0) body.hint = error.hint;
1017
- if (error.details !== void 0) body.details = error.details;
1018
- return {
1019
- ok: false,
1020
- schemaVersion: 1,
1021
- error: body
1022
- };
1023
- }
1024
- const defaultStreams = {
1025
- stdout: process.stdout,
1026
- stderr: process.stderr,
1027
- stdin: process.stdin
1028
- };
1029
- function colorEnabled(env, stream, flag) {
1030
- if (flag === false) return false;
1031
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1032
- if (env.TERM === "dumb") return false;
1033
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1034
- return Boolean(stream.isTTY);
1035
- }
1036
- /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1037
- function canPrompt(env, streams, options) {
1038
- if (options.json || options.noInput) return false;
1039
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1040
- return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1041
- }
1042
- /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1043
- const AGENT_MARKERS = [
1044
- "CLAUDECODE",
1045
- "CLAUDE_CODE_ENTRYPOINT",
1046
- "CODEX_SANDBOX",
1047
- "CODEX_HOME",
1048
- "CURSOR_AGENT",
1049
- "GEMINI_CLI",
1050
- "AGENT_COMMS_AGENT"
1051
- ];
1052
- function agentMarker(env) {
1053
- for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1054
- return null;
1055
- }
1056
- function paint(color, format, text) {
1057
- return color ? styleText(format, text, { validateStream: false }) : text;
966
+ return mail ? `${mail.label}'s inbox add` : void 0;
1058
967
  }
1059
968
  /**
1060
969
  * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
1061
- * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
1062
- * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
970
+ * Every command this suite prints to be run — a change to run again with its approval, a folder to take out, an entry
971
+ * to register again or remove — is quoted here, when the locator or `externalCommand` makes it, so no printer quotes
972
+ * for a shell of its own.
1063
973
  *
1064
974
  * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
1065
975
  * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
@@ -1098,31 +1008,58 @@ function paint(color, format, text) {
1098
1008
  * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
1099
1009
  * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
1100
1010
  *
1011
+ * - The program itself has to be left as it is. Quoted, it is a string to PowerShell rather than a command — PowerShell
1012
+ * runs a quoted program only after `&`, which cmd.exe reads as the end of a command — so a program whose path needs
1013
+ * quotes, as `C:\Program Files\nodejs\node.exe` does, leaves the command with no line either (CUE-403).
1014
+ *
1101
1015
  * Everywhere else there is always a line: single quotes make any word safe.
1016
+ *
1017
+ * Not a way to make a command for a person: what it returns is not one, and nothing that shows a command takes it. One
1018
+ * of this suite's own commands comes from `locateCliCommand`, any other program's from `externalCommand`, and each of
1019
+ * them quotes its words here.
1102
1020
  */
1103
- function shellCommand(words, platform = process.platform) {
1021
+ function quoteCommand(words, platform) {
1104
1022
  if (platform !== "win32") return {
1105
1023
  words,
1106
1024
  line: words.map(posixShellWord).join(" "),
1107
1025
  platform
1108
1026
  };
1109
1027
  const printed = words.map(windowsShellWord);
1028
+ const programBare = words.length === 0 || printed[0] === words[0];
1110
1029
  return {
1111
1030
  words,
1112
- line: printed.includes(null) ? null : printed.join(" "),
1031
+ line: printed.includes(null) || !programBare ? null : printed.join(" "),
1113
1032
  platform
1114
1033
  };
1115
1034
  }
1116
- /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
1117
- function withWords(command, ...more) {
1118
- return shellCommand([...command.words, ...more], command.platform);
1035
+ /**
1036
+ * A printed command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written,
1037
+ * never quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
1038
+ * command has no line.
1039
+ */
1040
+ function quotedLineWithWordsToFill(command, ...toFill) {
1041
+ if (command.line === null) return null;
1042
+ const sentinel = command.words.indexOf("--");
1043
+ if (sentinel < 0) return [command.line, ...toFill].join(" ");
1044
+ const word = command.platform === "win32" ? windowsShellWord : posixShellWord;
1045
+ const printed = command.words.map((each) => word(each));
1046
+ return [
1047
+ ...printed.slice(0, sentinel),
1048
+ ...toFill,
1049
+ ...printed.slice(sentinel)
1050
+ ].join(" ");
1051
+ }
1052
+ /** Whether a Windows command has no line because its program's path needs quotes, which PowerShell would not run. */
1053
+ function programNeedsQuotes(command) {
1054
+ const [program] = command.words;
1055
+ return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
1119
1056
  }
1120
1057
  function posixShellWord(word) {
1121
1058
  return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1122
1059
  }
1123
1060
  /**
1124
1061
  * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
1125
- * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1062
+ * does (see `quoteCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1126
1063
  */
1127
1064
  function windowsShellWord(word) {
1128
1065
  if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
@@ -1160,140 +1097,189 @@ function commandAsJson(words) {
1160
1097
  }
1161
1098
  /** What is said beside a command shown as JSON. */
1162
1099
  const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
1163
- /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1164
- function inlineCommand(command) {
1165
- return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
1166
- }
1167
- /** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
1168
- function commandText(command) {
1169
- return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
1100
+ /** What is said beside a command shown as JSON because its program's path needs quotes. */
1101
+ const TO_TYPE_PROGRAM = "the command's words, written as JSON: the program's path needs quotes, and PowerShell runs a quoted program only after `&`, which cmd.exe does not accept, so type the command yourself, with each word quoted for the shell you use and, in PowerShell, `&` before the program";
1102
+ function toType(command) {
1103
+ return programNeedsQuotes(command) ? TO_TYPE_PROGRAM : TO_TYPE;
1170
1104
  }
1171
- /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1172
- function writeResult(data, options, human, streams = defaultStreams) {
1173
- if (options.json) {
1174
- streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1175
- return;
1176
- }
1177
- const text = human(data);
1178
- if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1179
- }
1180
- /** Writes an error and returns the exit code to use. */
1181
- function writeError(error, options, streams = defaultStreams) {
1182
- const commsError = toCommsError(error);
1183
- if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1184
- else {
1185
- streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1186
- if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1187
- }
1188
- return commsError.exitCode;
1105
+ /** Quoted words in backticks, for a sentence — as words in JSON, saying it has to be typed, when there is no line. */
1106
+ function inlineQuoted(command) {
1107
+ return command.line === null ? `\`${commandAsJson(command.words)}\` (${toType(command)})` : `\`${command.line}\``;
1189
1108
  }
1190
- /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1191
- async function runCommand(options, body, streams = defaultStreams) {
1192
- try {
1193
- await body();
1194
- return EXIT_CODES.OK;
1195
- } catch (error) {
1196
- return writeError(error, options, streams);
1197
- }
1109
+ /** Quoted words as text of their own — a list's line, a field's value — or as words in JSON, saying to type them. */
1110
+ function quotedText(command) {
1111
+ return command.line === null ? `${commandAsJson(command.words)} (${toType(command)})` : command.line;
1198
1112
  }
1199
1113
  /**
1200
- * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1201
- * whose code is not its own to make up.
1202
- *
1203
- * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1204
- * but it refuses in exactly the same order and words, because it is the same gate.
1114
+ * The same command with words the agent is to fill in — a placeholder such as `<folder>` — before its first `--`: for
1115
+ * showing as words in JSON when the command has no line. With a line, `quotedLineWithWordsToFill` prints them as they
1116
+ * are written instead, never quoted as a word.
1205
1117
  */
1206
- function refuseUnlessPerson(env, streams, gate) {
1207
- const marker = agentMarker(env);
1208
- if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1209
- hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
1210
- details: { marker }
1211
- });
1212
- const prompting = {};
1213
- if (gate.json !== void 0) prompting.json = gate.json;
1214
- if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1215
- if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
1118
+ function quotedWithWordsToFill(command, toFill) {
1119
+ const sentinel = command.words.indexOf("--");
1120
+ const at = sentinel < 0 ? command.words.length : sentinel;
1121
+ return quoteCommand([
1122
+ ...command.words.slice(0, at),
1123
+ ...toFill,
1124
+ ...command.words.slice(at)
1125
+ ], command.platform);
1216
1126
  }
1217
- /**
1218
- * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1219
- * and the preview renderer (which makes them visible), so the two can never disagree.
1220
- */
1221
- /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
1222
- const INVISIBLE_RANGES = [
1223
- [173, 173],
1224
- [847, 847],
1225
- [1564, 1564],
1226
- [4447, 4448],
1227
- [6068, 6069],
1228
- [6155, 6159],
1229
- [8203, 8207],
1230
- [8232, 8233],
1231
- [8234, 8238],
1232
- [8288, 8292],
1233
- [8294, 8303],
1234
- [12644, 12644],
1235
- [65024, 65039],
1236
- [65279, 65279],
1237
- [65440, 65440],
1238
- [917504, 917631]
1239
- ];
1240
- function isInvisible(codePoint) {
1241
- for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
1242
- return false;
1127
+ /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
1128
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1129
+ function randomString(length) {
1130
+ const bytes = randomBytes(length);
1131
+ let out = "";
1132
+ for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
1133
+ return out;
1243
1134
  }
1244
- /**
1245
- * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
1246
- * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
1247
- */
1248
- function isControl(codePoint) {
1249
- if (codePoint === 9 || codePoint === 10) return false;
1250
- return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
1135
+ /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
1136
+ const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
1137
+ const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
1138
+ function newApprovalId() {
1139
+ return `ap_${randomString(26)}`;
1251
1140
  }
1252
- function isDangerous(codePoint) {
1253
- return isControl(codePoint) || isInvisible(codePoint);
1141
+ function newPlanToken() {
1142
+ return `pl_${randomString(26)}`;
1254
1143
  }
1255
- /**
1256
- * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
1257
- * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1258
- *
1259
- * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
1260
- * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
1261
- * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1262
- */
1263
- function stripInvisible(text) {
1264
- let removed = 0;
1144
+ const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
1145
+ /** A short challenge a human types back to approve: letters only, no look-alikes. */
1146
+ function newChallenge(length = 4) {
1265
1147
  let out = "";
1266
- for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1267
- else out += char;
1148
+ for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
1149
+ return out;
1150
+ }
1151
+ /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
1152
+ function hashChallenge(challenge) {
1153
+ return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
1154
+ }
1155
+ /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
1156
+ function challengeMatches(answer, storedHash) {
1157
+ const a = Buffer.from(hashChallenge(answer), "hex");
1158
+ const b = Buffer.from(storedHash, "hex");
1159
+ return a.length === b.length && timingSafeEqual(a, b);
1160
+ }
1161
+ function okEnvelope(data) {
1268
1162
  return {
1269
- text: out,
1270
- removed
1163
+ ok: true,
1164
+ schemaVersion: 1,
1165
+ data
1166
+ };
1167
+ }
1168
+ function errorEnvelope(error) {
1169
+ const body = {
1170
+ code: error.code,
1171
+ message: error.message
1172
+ };
1173
+ if (error.hint !== void 0) body.hint = error.hint;
1174
+ if (error.details !== void 0) body.details = error.details;
1175
+ return {
1176
+ ok: false,
1177
+ schemaVersion: 1,
1178
+ error: body
1271
1179
  };
1272
1180
  }
1273
1181
  const APP_DIR_NAME = "agent-communications";
1274
- /**
1275
- * Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
1276
- * where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
1277
- *
1278
- * On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
1279
- * the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
1280
- * exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
1281
- * someone who names a directory means that directory.
1282
- */
1182
+ /** The five suite directories a CLI can pin independently. */
1183
+ const PATH_OPTIONS = [
1184
+ {
1185
+ key: "configDir",
1186
+ option: "config-dir",
1187
+ flag: "--config-dir"
1188
+ },
1189
+ {
1190
+ key: "stateDir",
1191
+ option: "state-dir",
1192
+ flag: "--state-dir"
1193
+ },
1194
+ {
1195
+ key: "dataDir",
1196
+ option: "data-dir",
1197
+ flag: "--data-dir"
1198
+ },
1199
+ {
1200
+ key: "secretsDir",
1201
+ option: "secrets-dir",
1202
+ flag: "--secrets-dir"
1203
+ },
1204
+ {
1205
+ key: "downloadsDir",
1206
+ option: "downloads-dir",
1207
+ flag: "--downloads-dir"
1208
+ }
1209
+ ];
1210
+ /**
1211
+ * Words with the five path options and their values taken out where an option parser would read them — before the
1212
+ * first `--`, in both the `--x value` and `--x=value` forms — and every word from `--` on kept as it is.
1213
+ *
1214
+ * What a registered entry is recognised by. A pinned folder is a folder: `/tmp/@agentcomms/slack`, or one ending in
1215
+ * `packages/slack/src/cli.ts`, read as a package or an entry, made a Gmail registration Slack's. `dangling` says a
1216
+ * spaced option had no value before `--` or the end, which is a broken entry rather than one to guess about.
1217
+ */
1218
+ function withoutPathOptions(words) {
1219
+ const flags = PATH_OPTIONS.map(({ flag }) => flag);
1220
+ const kept = [];
1221
+ let dangling = false;
1222
+ for (let index = 0; index < words.length; index += 1) {
1223
+ const word = words[index];
1224
+ if (word === "--") {
1225
+ kept.push(...words.slice(index));
1226
+ break;
1227
+ }
1228
+ if (flags.includes(word)) {
1229
+ const value = words[index + 1];
1230
+ if (value === void 0 || value === "--") dangling = true;
1231
+ else index += 1;
1232
+ continue;
1233
+ }
1234
+ if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
1235
+ kept.push(word);
1236
+ }
1237
+ return {
1238
+ words: kept,
1239
+ dangling
1240
+ };
1241
+ }
1242
+ /**
1243
+ * Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
1244
+ * where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
1245
+ *
1246
+ * On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
1247
+ * the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
1248
+ * exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
1249
+ * someone who names a directory means that directory.
1250
+ */
1283
1251
  function resolvePaths(options = {}) {
1252
+ return resolvePathIdentity(options).paths;
1253
+ }
1254
+ /** Resolves the environment/default identity, then overlays each explicit pin without re-deriving any sibling. */
1255
+ function resolvePathIdentity(options = {}) {
1284
1256
  const env = options.env ?? process.env;
1285
1257
  const platform = options.platform ?? process.platform;
1286
1258
  const home = options.home ?? homeOf(env, platform);
1287
1259
  const configDir = resolve(env.AGENT_COMMS_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "agent-communications") : platform === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "agent-communications") : join(home, ".config", "agent-communications")));
1288
1260
  const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
1289
1261
  const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
1290
- return {
1262
+ const derived = {
1291
1263
  configDir,
1292
1264
  stateDir: resolve(env.AGENT_COMMS_STATE_DIR || join(localRoot, "state")),
1293
1265
  secretsDir: resolve(join(localRoot, "secrets")),
1294
1266
  dataDir: resolve(env.AGENT_COMMS_DATA_DIR || (platform === "win32" ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "agent-communications") : env.XDG_DATA_HOME ? join(env.XDG_DATA_HOME, "agent-communications") : join(home, ".local", "share", "agent-communications"))),
1295
1267
  downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
1296
1268
  };
1269
+ const pathOverrides = {};
1270
+ for (const { key } of PATH_OPTIONS) {
1271
+ const value = options.pathOverrides?.[key];
1272
+ if (value === void 0) continue;
1273
+ if (value.length === 0) throw new TypeError(`${key} cannot be empty`);
1274
+ pathOverrides[key] = resolve(value);
1275
+ }
1276
+ return {
1277
+ paths: {
1278
+ ...derived,
1279
+ ...pathOverrides
1280
+ },
1281
+ pathOverrides: Object.freeze(pathOverrides)
1282
+ };
1297
1283
  }
1298
1284
  /**
1299
1285
  * The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
@@ -1363,6 +1349,294 @@ function shortenHome(path, home, platform = process.platform) {
1363
1349
  function homeDirectory(env = process.env) {
1364
1350
  return env.HOME || env.USERPROFILE || homedir();
1365
1351
  }
1352
+ const defaultStreams = {
1353
+ stdout: process.stdout,
1354
+ stderr: process.stderr,
1355
+ stdin: process.stdin
1356
+ };
1357
+ /** Converts the shared parser's five global option values into independently applied core path pins. */
1358
+ function pathOverridesFromCliOptions(values) {
1359
+ const overrides = {};
1360
+ for (const { key, option, flag } of PATH_OPTIONS) {
1361
+ const value = values[option];
1362
+ if (value === void 0) continue;
1363
+ if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
1364
+ overrides[key] = value;
1365
+ }
1366
+ return overrides;
1367
+ }
1368
+ /** Inserts generated option words immediately before the first end-of-options sentinel. */
1369
+ function insertWordsBeforeSentinel(words, ...inserted) {
1370
+ const sentinel = words.indexOf("--");
1371
+ const at = sentinel < 0 ? words.length : sentinel;
1372
+ return [
1373
+ ...words.slice(0, at),
1374
+ ...inserted,
1375
+ ...words.slice(at)
1376
+ ];
1377
+ }
1378
+ /**
1379
+ * Removes the named options only where the CLI parser can see them: before the first `--`. Both `--x value` and
1380
+ * `--x=value` are removed. The sentinel and every positional word after it are byte-for-byte data.
1381
+ */
1382
+ function withoutOptionsBeforeSentinel(words, flags) {
1383
+ const kept = [];
1384
+ for (let index = 0; index < words.length; index += 1) {
1385
+ const word = words[index];
1386
+ if (word === "--") {
1387
+ kept.push(...words.slice(index));
1388
+ break;
1389
+ }
1390
+ const spaced = flags.find((flag) => word === flag);
1391
+ if (spaced !== void 0) {
1392
+ const value = words[index + 1];
1393
+ if (value === void 0 || value === "--") throw new CommsError("USAGE", `${spaced} needs a value before --`);
1394
+ index += 1;
1395
+ continue;
1396
+ }
1397
+ if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
1398
+ kept.push(word);
1399
+ }
1400
+ return kept;
1401
+ }
1402
+ /**
1403
+ * Replaces every pre-sentinel suite path option with one canonical pin per supplied directory. `insertAt` counts
1404
+ * words after the old pins are removed: zero puts global options after the executable held elsewhere, one puts them
1405
+ * after an entry/package word. Downloads are included only when the caller supplies that pin.
1406
+ */
1407
+ function normalizePathOptionWords(words, pins, insertAt) {
1408
+ const stripped = withoutOptionsBeforeSentinel(words, PATH_OPTIONS.map(({ flag }) => flag));
1409
+ const sentinel = stripped.indexOf("--");
1410
+ const optionsEnd = sentinel < 0 ? stripped.length : sentinel;
1411
+ if (!Number.isSafeInteger(insertAt) || insertAt < 0 || insertAt > optionsEnd) throw new TypeError(`path-option insertion point ${insertAt} is outside the option words`);
1412
+ const pathWords = PATH_OPTIONS.flatMap(({ key, flag }) => {
1413
+ const value = pins[key];
1414
+ if (value === void 0) return [];
1415
+ if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
1416
+ return [flag, resolve(value)];
1417
+ });
1418
+ return [
1419
+ ...stripped.slice(0, insertAt),
1420
+ ...pathWords,
1421
+ ...stripped.slice(insertAt)
1422
+ ];
1423
+ }
1424
+ function colorEnabled(env, stream, flag) {
1425
+ if (flag === false) return false;
1426
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1427
+ if (env.TERM === "dumb") return false;
1428
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1429
+ return Boolean(stream.isTTY);
1430
+ }
1431
+ /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1432
+ function canPrompt(env, streams, options) {
1433
+ if (options.json || options.noInput) return false;
1434
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1435
+ return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1436
+ }
1437
+ /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1438
+ const AGENT_MARKERS = [
1439
+ "CLAUDECODE",
1440
+ "CLAUDE_CODE_ENTRYPOINT",
1441
+ "CODEX_SANDBOX",
1442
+ "CODEX_HOME",
1443
+ "CURSOR_AGENT",
1444
+ "GEMINI_CLI",
1445
+ "AGENT_COMMS_AGENT"
1446
+ ];
1447
+ function agentMarker(env) {
1448
+ for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1449
+ return null;
1450
+ }
1451
+ function paint(color, format, text) {
1452
+ return color ? styleText(format, text, { validateStream: false }) : text;
1453
+ }
1454
+ /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1455
+ function inlineCommand(command) {
1456
+ return inlineQuoted(command);
1457
+ }
1458
+ /** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
1459
+ function commandText(command) {
1460
+ return quotedText(command);
1461
+ }
1462
+ /**
1463
+ * A command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written, never
1464
+ * quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
1465
+ * command has no line.
1466
+ */
1467
+ function lineWithWordsToFill(command, ...toFill) {
1468
+ return quotedLineWithWordsToFill(command, ...toFill);
1469
+ }
1470
+ /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1471
+ function writeResult(data, options, human, streams = defaultStreams) {
1472
+ if (options.json) {
1473
+ streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1474
+ return;
1475
+ }
1476
+ const text = human(data);
1477
+ if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1478
+ }
1479
+ /** Writes an error and returns the exit code to use. */
1480
+ function writeError(error, options, streams = defaultStreams) {
1481
+ const commsError = toCommsError(error);
1482
+ if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1483
+ else {
1484
+ streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1485
+ if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1486
+ }
1487
+ return commsError.exitCode;
1488
+ }
1489
+ /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1490
+ async function runCommand(options, body, streams = defaultStreams) {
1491
+ try {
1492
+ await body();
1493
+ return EXIT_CODES.OK;
1494
+ } catch (error) {
1495
+ return writeError(error, options, streams);
1496
+ }
1497
+ }
1498
+ /**
1499
+ * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1500
+ * whose code is not its own to make up.
1501
+ *
1502
+ * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1503
+ * but it refuses in exactly the same order and words, because it is the same gate.
1504
+ */
1505
+ function refuseUnlessPerson(env, streams, gate) {
1506
+ const marker = agentMarker(env);
1507
+ if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1508
+ hint: gateSentence(gate.command, (command) => `Ask the user to run ${command} in their own terminal.`),
1509
+ details: { marker }
1510
+ });
1511
+ const prompting = {};
1512
+ if (gate.json !== void 0) prompting.json = gate.json;
1513
+ if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1514
+ if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: gateSentence(gate.command, (command) => `Run ${command} directly in a terminal.`) });
1515
+ }
1516
+ /** A gate's command in a sentence: in backticks, or the sentence saying why there is none in its place. */
1517
+ function gateSentence(command, say) {
1518
+ return "message" in command ? command.message : say(inlineCommand(command));
1519
+ }
1520
+ /**
1521
+ * The handoffs a store or a deep function was given (`core.handoffs`, handed on by `openCore`), for the command one of
1522
+ * its refusals names. A programming error when there are none: the package that opened core did not give it its
1523
+ * caller (`openCore({ caller })`), and there is no command to print in its place — not a bare name, not a guess.
1524
+ */
1525
+ function requiredHandoffs(handoffs) {
1526
+ if (handoffs === void 0) throw new TypeError("core was opened without its caller, so it cannot locate a command: pass openCore({ caller })");
1527
+ return handoffs;
1528
+ }
1529
+ /** Whether a handoff is a command to run, rather than the sentence saying why there is none. */
1530
+ function isCommand(handoff) {
1531
+ return !("message" in handoff);
1532
+ }
1533
+ /**
1534
+ * A handoff as a value of its own — a list's line, a field's value: the command's line, or its words as JSON with what
1535
+ * to do when no line is safe in every Windows shell; or, with no command, the sentence saying why.
1536
+ */
1537
+ function handoffText(handoff) {
1538
+ return isCommand(handoff) ? commandText(handoff) : handoff.message;
1539
+ }
1540
+ /**
1541
+ * A remedy, a line per argument: one part, or the parts of one line in order — `[remove, ', then add it again']`.
1542
+ * A command is shown as `handoffText` shows it: its line, its words as JSON with what to do when no Windows line is
1543
+ * safe, or why there is none here.
1544
+ */
1545
+ function remedy(...lines) {
1546
+ const part = (each) => typeof each === "string" ? each : handoffText(each);
1547
+ return lines.map((line) => Array.isArray(line) ? line.map(part).join("") : part(line)).join("\n");
1548
+ }
1549
+ /** The sentence saying why there is no command, after another way when there is one. */
1550
+ function noCommand(handoff, options) {
1551
+ return options.instead === void 0 ? handoff.message : `${options.instead} ${handoff.message}`;
1552
+ }
1553
+ /**
1554
+ * A sentence that gives the command, in backticks, made by `say`; or, with no command, the sentence saying why there is
1555
+ * none — after `instead`, when given — never `say` with something else in the command's place.
1556
+ */
1557
+ function handoffSentence(handoff, say, options = {}) {
1558
+ return isCommand(handoff) ? say(inlineCommand(handoff)) : noCommand(handoff, options);
1559
+ }
1560
+ /**
1561
+ * As `handoffSentence`, for a command with words the agent is to fill in — a placeholder such as `<folder>`, printed as
1562
+ * written — before its first `--`. With no line to show them in, the command is shown as its words, placeholders among
1563
+ * them, saying it has to be typed.
1564
+ */
1565
+ function handoffSentenceToFill(handoff, toFill, say, options = {}) {
1566
+ if (!isCommand(handoff)) return noCommand(handoff, options);
1567
+ const line = lineWithWordsToFill(handoff, ...toFill);
1568
+ if (line !== null) return say(`\`${line}\``);
1569
+ return say(inlineQuoted(quotedWithWordsToFill(handoff, toFill)));
1570
+ }
1571
+ /**
1572
+ * Commands that each could do it — one per channel, say — as one phrase: the commands joined by "or", then, for each
1573
+ * that has none here, the sentence saying why. `none` is said when not one of them is a command.
1574
+ */
1575
+ function handoffChoices(handoffs, none, options = {}) {
1576
+ const commands = handoffs.filter((handoff) => isCommand(handoff)).map((handoff) => inlineCommand(handoff));
1577
+ const missing = handoffs.filter((handoff) => !isCommand(handoff));
1578
+ const reasons = [...new Set(missing.map((handoff) => handoff.message))].join(" ");
1579
+ if (commands.length === 0) return `${none} ${reasons}`.trim();
1580
+ const conjunction = options.conjunction ?? "or";
1581
+ const joined = commands.length === 1 ? commands[0] : `${commands.slice(0, -1).join(", ")} ${conjunction} ${commands.at(-1)}`;
1582
+ return reasons === "" ? joined : `${joined} (${reasons.replace(/\.$/, "")})`;
1583
+ }
1584
+ /**
1585
+ * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1586
+ * and the preview renderer (which makes them visible), so the two can never disagree.
1587
+ */
1588
+ /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
1589
+ const INVISIBLE_RANGES = [
1590
+ [173, 173],
1591
+ [847, 847],
1592
+ [1564, 1564],
1593
+ [4447, 4448],
1594
+ [6068, 6069],
1595
+ [6155, 6159],
1596
+ [8203, 8207],
1597
+ [8232, 8233],
1598
+ [8234, 8238],
1599
+ [8288, 8292],
1600
+ [8294, 8303],
1601
+ [12644, 12644],
1602
+ [65024, 65039],
1603
+ [65279, 65279],
1604
+ [65440, 65440],
1605
+ [917504, 917631]
1606
+ ];
1607
+ function isInvisible(codePoint) {
1608
+ for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
1609
+ return false;
1610
+ }
1611
+ /**
1612
+ * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
1613
+ * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
1614
+ */
1615
+ function isControl(codePoint) {
1616
+ if (codePoint === 9 || codePoint === 10) return false;
1617
+ return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
1618
+ }
1619
+ function isDangerous(codePoint) {
1620
+ return isControl(codePoint) || isInvisible(codePoint);
1621
+ }
1622
+ /**
1623
+ * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
1624
+ * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1625
+ *
1626
+ * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
1627
+ * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
1628
+ * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1629
+ */
1630
+ function stripInvisible(text) {
1631
+ let removed = 0;
1632
+ let out = "";
1633
+ for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1634
+ else out += char;
1635
+ return {
1636
+ text: out,
1637
+ removed
1638
+ };
1639
+ }
1366
1640
  /**
1367
1641
  * Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
1368
1642
  * drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
@@ -1833,10 +2107,20 @@ function parseConfig$1(text, source = "config.json") {
1833
2107
  var ConfigStore = class {
1834
2108
  path;
1835
2109
  #lockPath;
2110
+ #handoffs;
1836
2111
  #cache = null;
1837
- constructor(configDir) {
2112
+ /**
2113
+ * @param options.handoffs the printing package's handoffs (`core.handoffs`, CUE-403), for the command a refusal
2114
+ * names; left out, a refusal that has to name one is a programming error (`requiredHandoffs`).
2115
+ */
2116
+ constructor(configDir, options = {}) {
1838
2117
  this.path = join(configDir, "config.json");
1839
2118
  this.#lockPath = join(configDir, ".config.lock");
2119
+ this.#handoffs = options.handoffs;
2120
+ }
2121
+ /** "Run the names migration again", as core's command — located from whatever is printing — or the tool. */
2122
+ #namesMigrateAgain(why) {
2123
+ return handoffSentence(requiredHandoffs(this.#handoffs).core(["names", "migrate"]), (command) => `Run ${command} again ${why}.`, { instead: `Call comms_names_migrate again ${why}.` });
1840
2124
  }
1841
2125
  /** The current config; an empty one when the file does not exist yet. */
1842
2126
  async load() {
@@ -1869,7 +2153,7 @@ var ConfigStore = class {
1869
2153
  this.#cache = null;
1870
2154
  const current = structuredClone(await this.load());
1871
2155
  const next = await mutator(structuredClone(current));
1872
- if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only `agentcomms names migrate` changes the version. This is a bug — please report it." });
2156
+ if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only the names migration changes the version. This is a bug — please report it." });
1873
2157
  const parsed = schemaFor(current.version).safeParse(next);
1874
2158
  if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, current.version)}`);
1875
2159
  if (current.version === 2 && parsed.data.version === 2) {
@@ -1880,7 +2164,7 @@ var ConfigStore = class {
1880
2164
  const allowed = new Set(options.consent?.paths ?? []);
1881
2165
  const unconsented = loosened.filter((path) => !allowed.has(path));
1882
2166
  if (unconsented.length > 0) throw new CommsError("LOOSENING_REFUSED", `this change loosens a safety setting: ${unconsented.join(", ")}`, {
1883
- hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs `agentcomms approve <id>` at their own terminal (`confirm`), and the tool is called again with the id. At a terminal, run the matching command and approve the change it shows.",
2167
+ hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs the approve command the result gives at their own terminal (`confirm`), and the tool is called again with the id. At a terminal, run the matching command and approve the change it shows.",
1884
2168
  details: { paths: unconsented }
1885
2169
  });
1886
2170
  const approved = options.consent?.changes;
@@ -1940,13 +2224,13 @@ var ConfigStore = class {
1940
2224
  const raw = await readFileIfExists(this.path);
1941
2225
  const current = raw === null ? emptyConfig() : parseConfig$1(raw, this.path);
1942
2226
  if (current.version === 2) {
1943
- if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: "Run `agentcomms names migrate` again to see what they are called now." });
2227
+ if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: this.#namesMigrateAgain("to see what they are called now") });
1944
2228
  return {
1945
2229
  status: "already-migrated",
1946
2230
  config: current
1947
2231
  };
1948
2232
  }
1949
- if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: "Run `agentcomms names migrate` again to see the mapping for the configuration as it is now." });
2233
+ if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: this.#namesMigrateAgain("to see the mapping for the configuration as it is now") });
1950
2234
  const parsed = configV2Schema.safeParse(build(structuredClone(current)));
1951
2235
  if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, 2)}`);
1952
2236
  const unchanged = onlyKeysRenamed(current, parsed.data);
@@ -2504,13 +2788,40 @@ function cancelledClaim(record) {
2504
2788
  }
2505
2789
  });
2506
2790
  }
2791
+ /**
2792
+ * The refusals of a send's approval offered to a change. They name each sending channel's `approve`; another product's
2793
+ * is found only among the registrations read for it, which the store does not read under its lock — so a caller that
2794
+ * can read them says it again with them (`sendApprovesHint`).
2795
+ */
2796
+ const MISDIRECTED_SENDS = /* @__PURE__ */ new WeakSet();
2797
+ /**
2798
+ * What an agent is told when a change waits for a person at a terminal: the printing package's own `approve` with this
2799
+ * id, located, or why there is none here.
2800
+ */
2801
+ function approvePendingHint(handoffs, approvalId, platform) {
2802
+ const located = requiredHandoffs(handoffs);
2803
+ return handoffSentence(located.on(platform ?? located.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`);
2804
+ }
2805
+ /** Whether `error` is the refusal of a send's approval offered as a change's. */
2806
+ function isMisdirectedSend(error) {
2807
+ return error instanceof CommsError && MISDIRECTED_SENDS.has(error);
2808
+ }
2809
+ /**
2810
+ * What approves the send `approvalId`: the `approve` of every channel that sends, one of which prepared it — a send
2811
+ * record does not say which (D1). Each is a located command, or why it has none here.
2812
+ */
2813
+ function sendApprovesHint(maker, approvalId) {
2814
+ return `It is approved with the command that prepared it — ${handoffChoices(accountChannels().filter((manifest) => manifest.accounts?.modes.includes("send") === true).map((manifest) => maker.of(manifest.channel, ["approve", approvalId])), "and none is locatable here:")} — and permits only that send.`;
2815
+ }
2507
2816
  var ApprovalStore = class {
2508
2817
  directory;
2509
2818
  #now;
2510
2819
  #ttlMs;
2511
2820
  #downloadTtlMs;
2821
+ #handoffs;
2512
2822
  constructor(stateDir, options = {}) {
2513
2823
  this.directory = join(stateDir, "approvals");
2824
+ this.#handoffs = options.handoffs;
2514
2825
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
2515
2826
  this.#ttlMs = options.ttlMs ?? 6e5;
2516
2827
  this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
@@ -2616,17 +2927,17 @@ var ApprovalStore = class {
2616
2927
  * Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
2617
2928
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
2618
2929
  */
2619
- #requireKind(record, kind, platform) {
2930
+ #requireKind(record, kind, platform, handoffs = this.#handoffs) {
2931
+ const maker = () => requiredHandoffs(handoffs).on(platform);
2620
2932
  const actual = approvalKind(record);
2621
2933
  if (actual === kind) return;
2622
2934
  const id = record.approvalId;
2623
2935
  if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
2624
2936
  if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
2625
- throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
2626
- "agentcomms",
2627
- "approve",
2628
- id
2629
- ], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
2937
+ if (kind === "send") throw refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, handoffSentence(maker().own(["approve", id]), (command) => `A person approves it with ${command}, and it permits only the change it was prepared for.`));
2938
+ const misdirected = refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, sendApprovesHint(maker(), id));
2939
+ MISDIRECTED_SENDS.add(misdirected);
2940
+ throw misdirected;
2630
2941
  }
2631
2942
  /** Issues a new challenge to show a human; only its hash is kept. */
2632
2943
  async issueChallenge(approvalId, kind = "send", platform = process.platform) {
@@ -2709,7 +3020,7 @@ var ApprovalStore = class {
2709
3020
  async claimForSend(approvalId, live, options = {}) {
2710
3021
  let failure = null;
2711
3022
  const result = await this.#transition(approvalId, (current) => {
2712
- this.#requireKind(current, "send", options.platform ?? process.platform);
3023
+ this.#requireKind(current, "send", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
2713
3024
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2714
3025
  if (options.signal?.aborted) throw cancelledClaim(current);
2715
3026
  const voidWith = (code, reason) => {
@@ -2821,7 +3132,7 @@ var ApprovalStore = class {
2821
3132
  const digest = changeDigest(live.change);
2822
3133
  let failure = null;
2823
3134
  const result = await this.#transition(approvalId, (current) => {
2824
- this.#requireKind(current, "change", options.platform ?? process.platform);
3135
+ this.#requireKind(current, "change", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
2825
3136
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2826
3137
  if (options.signal?.aborted) throw cancelledClaim(current);
2827
3138
  const voidWith = (reason) => {
@@ -2837,11 +3148,7 @@ var ApprovalStore = class {
2837
3148
  };
2838
3149
  if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
2839
3150
  if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
2840
- if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run ${inlineCommand(shellCommand([
2841
- "agentcomms",
2842
- "approve",
2843
- approvalId
2844
- ], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
3151
+ if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? approvePendingHint(options.handoffs ?? this.#handoffs, approvalId, options.platform));
2845
3152
  if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
2846
3153
  }
2847
3154
  return {
@@ -2963,7 +3270,7 @@ var ApprovalStore = class {
2963
3270
  const digest = downloadDigest(live);
2964
3271
  let failure = null;
2965
3272
  const result = await this.#transition(approvalId, (current) => {
2966
- this.#requireKind(current, "download", options.platform ?? process.platform);
3273
+ this.#requireKind(current, "download", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
2967
3274
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2968
3275
  if (options.signal?.aborted) throw cancelledClaim(current);
2969
3276
  const voidWith = (reason) => {
@@ -3114,125 +3421,99 @@ var AuditLog = class {
3114
3421
  return out.reverse();
3115
3422
  }
3116
3423
  };
3117
- const MAP = {
3118
- inbox: "inboxes",
3119
- account: "accounts"
3120
- };
3424
+ /** Every command a manifest declares — each channel's `binary` and its server's other `bins` — lowercased. */
3425
+ const SUITE_COMMANDS = new Set(CHANNEL_SNAPSHOT.flatMap(({ manifest }) => [manifest.binary, ...manifest.server.bins ?? []]).map((name) => name.toLowerCase()));
3426
+ /** The extensions Windows finds an executable by: `agent-gmail.cmd` is `agent-gmail`. */
3427
+ const EXECUTABLE_EXTENSION = /\.(?:cmd|exe|bat|ps1|com)$/i;
3121
3428
  /**
3122
- * An own property only.
3123
- *
3124
- * A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
3125
- * of an inbox called `constructor`, which version 1 allows, found it.
3429
+ * The suite command a word names as a program — its last path part, without a Windows extension, compared without
3430
+ * case as Windows compares executable names — or null.
3126
3431
  */
3127
- function own$1(map, key) {
3128
- return Object.hasOwn(map, key) ? map[key] : void 0;
3129
- }
3130
- function resolveName(config, kind, name, notFound) {
3131
- if (kind === "inbox") {
3132
- const inbox = own$1(config.inboxes, name);
3133
- if (inbox) return {
3134
- alias: name,
3135
- inbox
3136
- };
3137
- } else {
3138
- const account = own$1(config.accounts, name);
3139
- if (account) return {
3140
- alias: name,
3141
- account
3142
- };
3143
- }
3144
- const renamed = formerNameRefusal(config, kind, name);
3145
- if (renamed) throw renamed;
3146
- throw notFound?.() ?? defaultNotFound(config, kind, name);
3432
+ function suiteCommandOf(word) {
3433
+ const name = (word.split(/[\\/]/).at(-1) ?? "").replace(EXECUTABLE_EXTENSION, "").toLowerCase();
3434
+ return SUITE_COMMANDS.has(name) ? name : null;
3147
3435
  }
3148
- /** What to say when there are no mailboxes at all: the command that connects one, from the mail channel's manifest. */
3149
- function noInboxesHint() {
3150
- const connect = connectMailboxCommand();
3151
- return connect ? `No inboxes yet: add one with \`${connect}\`.` : "No inboxes yet.";
3436
+ /** A parsed `package.json`, as an object, or null when there is none, it cannot be read, or it is not an object. */
3437
+ function readPackageManifest(dir) {
3438
+ const read = readPackageFile(dir);
3439
+ return read === "absent" ? null : read;
3152
3440
  }
3153
- function defaultNotFound(config, kind, name) {
3154
- if (kind === "inbox") {
3155
- const known = Object.keys(config.inboxes);
3156
- return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
3441
+ /** `absent` when the directory has no `package.json`; otherwise what it says, or null when that cannot be read. */
3442
+ function readPackageFile(dir) {
3443
+ let text;
3444
+ try {
3445
+ text = readFileSync(join(dir, "package.json"), "utf8");
3446
+ } catch (error) {
3447
+ const code = error.code;
3448
+ return code === "ENOENT" || code === "ENOTDIR" ? "absent" : null;
3449
+ }
3450
+ try {
3451
+ const parsed = JSON.parse(text);
3452
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
3453
+ } catch {
3454
+ return null;
3157
3455
  }
3158
- return new CommsError("NOT_FOUND", `no account called "${name}"`);
3159
- }
3160
- function lookupName(config, kind, name) {
3161
- return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
3162
3456
  }
3163
- function findById(config, kind, id) {
3164
- if (kind === "inbox") {
3165
- for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
3166
- alias,
3167
- inbox
3457
+ /**
3458
+ * The nearest package at or above `start`: the first directory with a `package.json`, and what it says (null when it
3459
+ * cannot be read or parsed). Not the nearest one with a particular name — the package a module belongs to is the
3460
+ * nearest one, as Node itself decides, and a caller that names a different one has to be told so.
3461
+ */
3462
+ function nearestPackage(start) {
3463
+ let dir = resolve(start);
3464
+ for (;;) {
3465
+ const manifest = readPackageFile(dir);
3466
+ if (manifest !== "absent") return {
3467
+ root: dir,
3468
+ manifest
3168
3469
  };
3169
- } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
3170
- alias,
3171
- account
3172
- };
3173
- return null;
3470
+ const parent = dirname(dir);
3471
+ if (parent === dir) return null;
3472
+ dir = parent;
3473
+ }
3174
3474
  }
3175
3475
  /**
3176
- * The refusal for a former name, or null when `name` is not one.
3177
- *
3178
- * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
3179
- * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
3180
- * name that now belongs to nothing.
3476
+ * Whether `path` is `root` or inside it, by whole path segments: `/x/core-evil` is not inside `/x/core`, which a
3477
+ * comparison of strings would say it was. Both are taken as given; a caller compares real paths with real paths.
3181
3478
  */
3182
- function formerNameRefusal(config, kind, name) {
3183
- if (config.version !== 2) return null;
3184
- const former = own$1(config.formerNames[MAP[kind]], name);
3185
- if (!former) return null;
3186
- const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
3187
- if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
3188
- formerName: name,
3189
- id: former.id
3190
- } });
3191
- return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
3192
- hint: `Use "${current.alias}".`,
3193
- details: {
3194
- formerName: name,
3195
- currentName: current.alias,
3196
- id: former.id
3197
- }
3198
- });
3479
+ function isWithin(root, path) {
3480
+ const between = relative(resolve(root), resolve(path));
3481
+ return between === "" || !isAbsolute(between) && between !== ".." && !/^\.\.[\\/]/.test(between);
3482
+ }
3483
+ /** The real path of `path`, or of its deepest ancestor that exists with the rest joined on; null when none does. */
3484
+ function realpathOfExisting(path) {
3485
+ let current = resolve(path);
3486
+ const rest = [];
3487
+ for (;;) try {
3488
+ const real = realpathSync(current);
3489
+ return rest.length === 0 ? real : join(real, ...rest.reverse());
3490
+ } catch {
3491
+ const parent = dirname(current);
3492
+ if (parent === current) return null;
3493
+ rest.push(basename(current));
3494
+ current = parent;
3495
+ }
3199
3496
  }
3200
3497
  /**
3201
- * Whether `name` may be given to a new or renamed account of this kind and platform.
3202
- *
3203
- * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
3204
- * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
3205
- * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
3206
- * either kind.
3207
- *
3208
- * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
3209
- * again for version 2, because anything can happen between asking and writing.
3498
+ * The root of the suite package `path` is in — itself or any ancestor whose `package.json` names a package of this
3499
+ * suite — as written and after resolving links, or null. Every ancestor is looked at, not only the nearest package:
3500
+ * a file in `packages/gmail/node_modules/x` is still inside Gmail's root.
3210
3501
  */
3211
- function nameAvailable(config, kind, name, platform) {
3212
- const refuse = (error) => ({
3213
- ok: false,
3214
- error
3215
- });
3216
- if (config.version === 1) {
3217
- if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
3218
- if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
3219
- if (kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.inboxes, name) ?? own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3220
- return { ok: true };
3221
- }
3222
- const problem = nameShapeProblem(name, platform);
3223
- if (problem) return refuse(new CommsError("USAGE", problem));
3224
- if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3225
- for (const map of ["inboxes", "accounts"]) {
3226
- const former = own$1(config.formerNames[map], name);
3227
- if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
3228
- hint: "A former name keeps pointing people at the account that had it. Choose another name.",
3229
- details: {
3230
- formerName: name,
3231
- id: former.id
3232
- }
3233
- }));
3502
+ function suitePackageRootOf(path) {
3503
+ const candidates = [resolve(path)];
3504
+ const real = realpathOfExisting(path);
3505
+ if (real !== null && real !== candidates[0]) candidates.push(real);
3506
+ for (const candidate of candidates) {
3507
+ let dir = candidate;
3508
+ for (;;) {
3509
+ const name = readPackageManifest(dir)?.name;
3510
+ if (typeof name === "string" && name.startsWith("@agentcomms/")) return dir;
3511
+ const parent = dirname(dir);
3512
+ if (parent === dir) break;
3513
+ dir = parent;
3514
+ }
3234
3515
  }
3235
- return { ok: true };
3516
+ return null;
3236
3517
  }
3237
3518
  /**
3238
3519
  * Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
@@ -3868,10 +4149,126 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
3868
4149
  };
3869
4150
  }
3870
4151
  function packageFrom(server) {
3871
- const line = [server.command, ...server.args].join(" ");
4152
+ return commandLinePackage(server.command, server.args);
4153
+ }
4154
+ /**
4155
+ * The npm package an entry's command line launches, when one can be read from it: `@scope/name`, or a bare `…-mcp`.
4156
+ * Read from the words left once the suite's path options are taken out (`withoutPathOptions`): a pinned folder such as
4157
+ * `/tmp/@agentcomms/slack` is not a package, and read as one it made a Gmail entry Slack's.
4158
+ */
4159
+ function commandLinePackage(command, args) {
4160
+ const line = [command, ...withoutPathOptions(args).words].join(" ");
3872
4161
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
3873
4162
  }
3874
4163
  /**
4164
+ * A command for a person to run that is not this suite's: `chmod`, or an MCP client's own `claude mcp …` and
4165
+ * `codex mcp …` (design 2026-10-04, D5).
4166
+ *
4167
+ * Every command printed for a person is one of two kinds. One of this suite's own CLIs is a `PrintedCommand`, which
4168
+ * only the locator makes (`cli-command.ts`): it names this Node and a checked file of the product, because a bare
4169
+ * `agent-gmail` is not on the PATH of most people it is printed for (CUE-403). Anything else is an `ExternalCommand`,
4170
+ * made here and nowhere else, with the reason that program is one a person may be told to run.
4171
+ *
4172
+ * So that this is not a way round the locator, `externalCommand` refuses words that would start one of this suite's
4173
+ * products by another route: a word containing one of the suite's commands — in any case, alone, as a path's last
4174
+ * part or inside a wrapper's payload (`sh -c "agent-gmail …"`) — a word naming a package of the suite
4175
+ * (`npx @agentcomms/gmail`), or a path inside the root of a suite package, as written or through a link
4176
+ * (`node …/packages/gmail/dist/cli.mjs`). Every word is looked at, not only the first: the program is often a wrapper.
4177
+ *
4178
+ * This module is a leaf: it reads the manifests' facts and the files a path names, and imports nothing that registers,
4179
+ * scans or locates, so nothing it refuses can be routed back through it.
4180
+ */
4181
+ /** The gate a constructor call has to pass: only this module holds it. */
4182
+ const GATE$1 = Symbol("externalCommand");
4183
+ /**
4184
+ * One reviewed command of a program that is not this suite's. Rendered as every printed command is — a line to paste,
4185
+ * or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`, `commandText`).
4186
+ *
4187
+ * A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
4188
+ * a spread copy of one with other words in it. Only the type is exported; the class is not.
4189
+ */
4190
+ var ExternalCommand = class {
4191
+ #reason;
4192
+ /** The words, as the program is to receive them. */
4193
+ words;
4194
+ /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
4195
+ line;
4196
+ platform;
4197
+ constructor(gate, words, platform, reason) {
4198
+ if (gate !== GATE$1) throw new TypeError("an external command is made by externalCommand, and only there");
4199
+ this.#reason = reason;
4200
+ this.words = Object.freeze([...words]);
4201
+ this.line = quoteCommand(this.words, platform).line;
4202
+ this.platform = platform;
4203
+ Object.freeze(this);
4204
+ }
4205
+ /** Why this program, which is not part of this suite, is one a person may be told to run. */
4206
+ get reason() {
4207
+ return this.#reason;
4208
+ }
4209
+ /** As JSON, the text it always was: its line, or its words as JSON with what to do (as a `PrintedCommand`). */
4210
+ toJSON() {
4211
+ return quotedText(this);
4212
+ }
4213
+ /** Never interpolated: rendered with `commandText` or `inlineCommand`. */
4214
+ [Symbol.toPrimitive]() {
4215
+ throw new TypeError("an external command is rendered with commandText or inlineCommand, never interpolated");
4216
+ }
4217
+ };
4218
+ /** A package specifier of the suite, with either separator: `@agentcomms/gmail`, `node_modules\@agentcomms\gmail`. */
4219
+ const SUITE_PACKAGE = /@agentcomms[\\/]/i;
4220
+ /**
4221
+ * The parts of a word a shell or a wrapper could take as a command name: split wherever a character could not be part
4222
+ * of one, so `sh -c "cd x && agent-gmail approve"`, `--run=agent-gmail` and `C:\npm\agent-gmail.cmd` each yield
4223
+ * `agent-gmail`. Compared without case, as Windows compares executable names; on a case-sensitive system that refuses
4224
+ * a little more than it must, and nothing that it should not.
4225
+ */
4226
+ function commandNames(word) {
4227
+ return word.split(/[^A-Za-z0-9._-]+/).map((part) => part.replace(/^\.+|\.+$/g, "").replace(EXECUTABLE_EXTENSION, "").toLowerCase()).filter(Boolean);
4228
+ }
4229
+ /**
4230
+ * The paths in a word: the word itself, and each part of it between spaces, quotes, `=` and a shell's separators, that
4231
+ * is absolute, `~/…`, or a `file:` URL. A relative path is not resolved: the folder a person pastes the command in is
4232
+ * not known here, and a server called `7/gmail` is a name, not a path.
4233
+ */
4234
+ function pathsIn(word) {
4235
+ const paths = [];
4236
+ for (const part of [word, ...word.split(/[\s"'`=;&|<>(),]+/)]) {
4237
+ if (part.length === 0) continue;
4238
+ if (/^file:/i.test(part)) try {
4239
+ paths.push(fileURLToPath(part));
4240
+ } catch {}
4241
+ else if (part === "~" || /^~[\\/]/.test(part)) paths.push(join(homedir(), part.slice(2)));
4242
+ else if (isAbsolute(part)) paths.push(part);
4243
+ }
4244
+ return paths;
4245
+ }
4246
+ /** Why these words must not be an external command, or null when they may be. */
4247
+ function refusal$1(words) {
4248
+ for (const [index, word] of words.entries()) {
4249
+ if (SUITE_PACKAGE.test(word)) return `word ${index} names a package of this suite`;
4250
+ for (const path of pathsIn(word)) {
4251
+ const root = suitePackageRootOf(path);
4252
+ if (root !== null) return `word ${index} is a path inside ${root}, a package of this suite`;
4253
+ }
4254
+ if (commandNames(word).some((name) => SUITE_COMMANDS.has(name))) return `word ${index} names a command of this suite`;
4255
+ }
4256
+ return null;
4257
+ }
4258
+ /**
4259
+ * The one way to make an `ExternalCommand`: `words` for a program that is not this suite's, and `reason`, why a person
4260
+ * may be told to run it. Throws a `TypeError` for words that would start a product of this suite (see the module), so
4261
+ * a call site whose words come from somewhere else — a server's name read from a client's file, a folder — says what
4262
+ * to do in prose instead when one is refused. `platform` is the shell it is quoted for.
4263
+ */
4264
+ function externalCommand(words, reason, platform = process.platform) {
4265
+ if (words.length === 0 || (words[0] ?? "").length === 0) throw new TypeError("an external command needs a program");
4266
+ if (reason.trim().length === 0) throw new TypeError("an external command needs the reason it may be run");
4267
+ const refused = refusal$1(words);
4268
+ if (refused !== null) throw new TypeError(`not an external command: ${refused}; a command of this suite comes from locateCliCommand`);
4269
+ return new ExternalCommand(GATE$1, words, platform, reason);
4270
+ }
4271
+ /**
3875
4272
  * The programs this package starts, named so that no folder it happens to be in can stand in for them.
3876
4273
  *
3877
4274
  * A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
@@ -4002,24 +4399,54 @@ async function resolveNode(env) {
4002
4399
  return await whichExecutable("node", env) ?? process.execPath;
4003
4400
  }
4004
4401
  function minimalEnv(context, node) {
4005
- const env = {
4006
- AGENT_COMMS_CONFIG_DIR: context.core.paths.configDir,
4007
- PATH: [
4008
- dirname(node),
4009
- "/usr/local/bin",
4010
- "/usr/bin",
4011
- "/bin"
4012
- ].join(delimiter)
4013
- };
4402
+ const env = { PATH: [
4403
+ dirname(node),
4404
+ "/usr/local/bin",
4405
+ "/usr/bin",
4406
+ "/bin"
4407
+ ].join(delimiter) };
4014
4408
  if (process.platform === "linux") for (const key of ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) {
4015
4409
  const value = context.env[key];
4016
4410
  if (value) env[key] = value;
4017
4411
  }
4018
4412
  return env;
4019
4413
  }
4414
+ /** The complete suite identity every installer-written server registration carries. Downloads are operation-local. */
4415
+ function registrationPathArgs(context) {
4416
+ const { configDir, stateDir, dataDir, secretsDir } = context.core.paths;
4417
+ return [
4418
+ "--config-dir",
4419
+ configDir,
4420
+ "--state-dir",
4421
+ stateDir,
4422
+ "--data-dir",
4423
+ dataDir,
4424
+ "--secrets-dir",
4425
+ secretsDir
4426
+ ];
4427
+ }
4020
4428
  function unscoped(packageName) {
4021
4429
  return packageName.split("/").at(-1) ?? packageName;
4022
4430
  }
4431
+ function escapeRegExp$2(text) {
4432
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4433
+ }
4434
+ /**
4435
+ * An MCP client's own command about one of its entries — `claude mcp get <name>`, `codex mcp remove <name>` — in
4436
+ * backticks for a hint, or, when there is none to give, `instead`.
4437
+ *
4438
+ * The client's program, so an external command (`externalCommand`), and the name is whatever the entry is called. A
4439
+ * name that is also one of this suite's commands — the core server's own default, `agentcomms`, is one — is refused
4440
+ * there, because such a word is how a suite command would get round the locator; the hint then says the same thing in
4441
+ * words rather than printing a command with that name in it.
4442
+ */
4443
+ function clientCommandHint(words, platform, instead) {
4444
+ try {
4445
+ return inlineCommand(externalCommand(words, "the MCP client's own command reads or removes an entry of its own configuration", platform));
4446
+ } catch {
4447
+ return instead;
4448
+ }
4449
+ }
4023
4450
  /**
4024
4451
  * The directory the managed launcher installs one product's exact version into: `<data>/runtime/<version>-<name>`.
4025
4452
  *
@@ -4037,6 +4464,29 @@ function managedRuntimeEntry(dataDir, packageName, version) {
4037
4464
  return join(managedRuntimeDir(dataDir, packageName, version), "node_modules", ...packageName.split("/"), "dist", "cli.mjs");
4038
4465
  }
4039
4466
  /**
4467
+ * The version a managed-runtime path pins, in either layout, or null when the path is not one.
4468
+ *
4469
+ * Both `runtime/0.4.0/…` (every Gmail install before the move) and `runtime/0.4.0-gmail/…` (every install
4470
+ * since) are read, and a prerelease keeps its own hyphen: `0.5.0-rc.1-gmail` is `0.5.0-rc.1`. Either separator,
4471
+ * and allowed to start the string, so a Windows or a relative path is not missed. On Windows (`platform`), whose
4472
+ * paths are not case-sensitive, the folder names are read without case; the version keeps the case it has.
4473
+ */
4474
+ function managedRuntimeVersion(path, packageName, platform) {
4475
+ const scope = packageName.split("/").map(escapeRegExp$2).join("[/\\\\]");
4476
+ return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$2(unscoped(packageName))})?[/\\\\]node_modules[/\\\\]${scope}[/\\\\]`, platform === "win32" ? "i" : "").exec(path)?.[1] ?? null;
4477
+ }
4478
+ /**
4479
+ * The version one argument of a registered entry pins, or null when it pins none.
4480
+ *
4481
+ * Two launchers pin, and they look nothing alike: `managed` writes a runtime path, `npx` a package spec. Reading
4482
+ * only the first reported an `npx`-pinned install as current for ever. `local` pins nothing and is ignored.
4483
+ */
4484
+ function pinnedVersion(argument, product, platform) {
4485
+ const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$2).join("|");
4486
+ const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)$`, platform === "win32" ? "i" : "");
4487
+ return managedRuntimeVersion(argument, product.packageName, platform) ?? spec.exec(argument)?.[1] ?? null;
4488
+ }
4489
+ /**
4040
4490
  * Whether a registered entry starts this product's server.
4041
4491
  *
4042
4492
  * Decides what `--force` may replace, so it errs towards "not ours". An npm package read off the command line
@@ -4044,11 +4494,20 @@ function managedRuntimeEntry(dataDir, packageName, version) {
4044
4494
  * by, compared as whole runs of segments — `@agentcomms/gmail-evil/dist/cli.mjs`, `@agentcomms/gmail/dist/
4045
4495
  * index.mjs` and `packages/gmail/src/nested/cli.ts` are all near misses, and each was once accepted by a looser
4046
4496
  * match somewhere in this repository.
4497
+ *
4498
+ * Only the entry's own words are read: the suite's path options and their values are taken out first, because a pinned
4499
+ * folder that ends in `packages/slack/src/cli.ts` is a folder, not Slack (`withoutPathOptions`). On Windows
4500
+ * (`platform`), whose file names are not case-sensitive, the command's name and every path segment are compared
4501
+ * without case, as Windows itself finds them; everywhere else exactly. Left out, it compares exactly.
4047
4502
  */
4048
- function isProductServer(server, product) {
4049
- const launched = (server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, "");
4050
- if (launched && [product.binary, ...product.bins ?? []].includes(launched)) return true;
4051
- if (server.packageName) return server.packageName === product.packageName || server.packageName === product.npxPackage;
4503
+ function isProductServer(server, product, platform) {
4504
+ const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
4505
+ const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
4506
+ if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
4507
+ if (server.packageName) {
4508
+ const named = fold(server.packageName);
4509
+ return named === fold(product.packageName) || named === fold(product.npxPackage);
4510
+ }
4052
4511
  const short = unscoped(product.packageName);
4053
4512
  const entries = [
4054
4513
  [
@@ -4071,9 +4530,9 @@ function isProductServer(server, product) {
4071
4530
  ],
4072
4531
  ...product.entryFiles ?? []
4073
4532
  ];
4074
- return [server.command, ...server.args].some((part) => {
4075
- const segments = part.split(/[\\/]+/).filter(Boolean);
4076
- return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === wanted));
4533
+ return [server.command, ...withoutPathOptions(server.args).words].some((part) => {
4534
+ const segments = part.split(/[\\/]+/).filter(Boolean).map(fold);
4535
+ return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === fold(wanted)));
4077
4536
  });
4078
4537
  }
4079
4538
  /**
@@ -4169,6 +4628,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4169
4628
  const launcher = options.launcher ?? "managed";
4170
4629
  const { node } = resolved;
4171
4630
  const env = minimalEnv(context, node);
4631
+ const pathArgs = registrationPathArgs(context);
4172
4632
  const serverArgs = product.serverArgs(options);
4173
4633
  if (launcher === "npx") return {
4174
4634
  entry: {
@@ -4176,6 +4636,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4176
4636
  args: [
4177
4637
  "-y",
4178
4638
  `${product.npxPackage}@${product.version}`,
4639
+ ...pathArgs,
4179
4640
  ...product.npxArgs ?? [],
4180
4641
  ...serverArgs
4181
4642
  ],
@@ -4192,6 +4653,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4192
4653
  args: [
4193
4654
  ...entryPath.endsWith(".ts") ? ["--experimental-strip-types", "--disable-warning=ExperimentalWarning"] : [],
4194
4655
  entryPath,
4656
+ ...pathArgs,
4195
4657
  "mcp",
4196
4658
  ...serverArgs
4197
4659
  ],
@@ -4209,6 +4671,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4209
4671
  command: node,
4210
4672
  args: [
4211
4673
  ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
4674
+ ...pathArgs,
4212
4675
  "mcp",
4213
4676
  ...serverArgs
4214
4677
  ],
@@ -4223,6 +4686,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4223
4686
  command: node,
4224
4687
  args: [
4225
4688
  await installManagedRuntime(context, product, product.version),
4689
+ ...pathArgs,
4226
4690
  "mcp",
4227
4691
  ...serverArgs
4228
4692
  ],
@@ -4307,13 +4771,17 @@ function snippetFor(client, name, entry) {
4307
4771
  * Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
4308
4772
  * replaced.
4309
4773
  */
4310
- function claimName(product, options, name, existing, platform) {
4774
+ function claimName(product, options, name, existing, _platform) {
4311
4775
  const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
4312
4776
  const foreign = taken.find((server) => !isProductServer(server, product));
4313
4777
  if (foreign) {
4314
4778
  const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
4315
4779
  const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
4316
- throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name: ${inlineCommand(installCommand$1(product, options, other, [], platform))}. --force does not replace a server this did not install.` });
4780
+ throw refusedWithRemedy("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, {
4781
+ binary: product.binary,
4782
+ words: installWords(product, options, other, []),
4783
+ say: (command) => `Register this one under another name: ${command}. --force does not replace a server this did not install.`
4784
+ });
4317
4785
  }
4318
4786
  if (taken.length > 0 && !options.force) {
4319
4787
  for (const pin of PIN_OPTIONS) {
@@ -4321,14 +4789,22 @@ function claimName(product, options, name, existing, platform) {
4321
4789
  const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
4322
4790
  if (!wanted || !served || served === wanted) continue;
4323
4791
  const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
4324
- throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${inlineCommand(installCommand$1(product, options, second === name ? `${name}-2` : second, [], platform))}.` });
4792
+ throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
4793
+ binary: product.binary,
4794
+ words: installWords(product, options, second === name ? `${name}-2` : second, []),
4795
+ say: (command) => `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${command}.`
4796
+ });
4325
4797
  }
4326
4798
  const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
4327
4799
  const again = {
4328
4800
  ...keepNarrowing(product, options, taken).options,
4329
4801
  launcher: options.launcher ?? (npx ? "npx" : void 0)
4330
4802
  };
4331
- throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `Pass --force to replace it — that is how an upgrade reaches a client: ${inlineCommand(installCommand$1(product, again, name, ["--force"], platform))}.` });
4803
+ throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
4804
+ binary: product.binary,
4805
+ words: installWords(product, again, name, ["--force"]),
4806
+ say: (command) => `Pass --force to replace it — that is how an upgrade reaches a client: ${command}.`
4807
+ });
4332
4808
  }
4333
4809
  return taken;
4334
4810
  }
@@ -4364,20 +4840,17 @@ function keepNarrowing(product, options, previous) {
4364
4840
  };
4365
4841
  }
4366
4842
  /**
4367
- * The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach.
4843
+ * The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach: its
4844
+ * words after the program.
4368
4845
  *
4369
4846
  * The hints used to be `mcp install --client <c> --force` whatever had been asked. Refused for `--name slack-acme
4370
4847
  * --workspace acme/slack`, the hint registered a second server under the default name, pinned to nothing — every
4371
4848
  * workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
4372
4849
  * rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
4373
4850
  * the pin and the narrowing, so they are repeated as they are.
4374
- *
4375
- * A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
4376
- * command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
4377
4851
  */
4378
- function installCommand$1(product, options, name, extra, platform) {
4852
+ function installWords(product, options, name, extra) {
4379
4853
  const words = [
4380
- product.binary,
4381
4854
  "mcp",
4382
4855
  "install",
4383
4856
  "--client",
@@ -4387,7 +4860,24 @@ function installCommand$1(product, options, name, extra, platform) {
4387
4860
  words.push(...product.serverArgs(options));
4388
4861
  if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
4389
4862
  words.push(...extra);
4390
- return shellCommand(words, platform);
4863
+ return words;
4864
+ }
4865
+ const REMEDIES = /* @__PURE__ */ new WeakMap();
4866
+ /** The remedy a refusal of `mcp install` carries, for the operation that called it to locate; undefined for any other. */
4867
+ function installRemedyOf(error) {
4868
+ return typeof error === "object" && error !== null ? REMEDIES.get(error) : void 0;
4869
+ }
4870
+ /**
4871
+ * A refusal whose remedy is another install: until the caller locates it, its hint says the words in prose — never a
4872
+ * command with a bare program.
4873
+ */
4874
+ function refusedWithRemedy(code, message, remedy, options = {}) {
4875
+ const error = new CommsError(code, message, {
4876
+ hint: remedy.say(`its own \`mcp install\` with ${remedy.words.slice(2).join(" ")}`),
4877
+ ...options.cause === void 0 ? {} : { cause: options.cause }
4878
+ });
4879
+ REMEDIES.set(error, remedy);
4880
+ return error;
4391
4881
  }
4392
4882
  /**
4393
4883
  * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
@@ -4430,16 +4920,16 @@ function capture$1(env, command, args) {
4430
4920
  * or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
4431
4921
  * Nothing codex prints is repeated, because its answer carries the entry's env.
4432
4922
  */
4433
- async function codexRegistration(env, binary, name, path, platform) {
4434
- const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
4923
+ async function codexRegistration(env, cliPath, name, path, platform) {
4924
+ const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${clientCommandHint([
4435
4925
  "codex",
4436
4926
  "mcp",
4437
4927
  "get",
4438
4928
  name
4439
- ], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
4929
+ ], platform, `codex's own \`mcp get\`, for the entry called "${name}"`)}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
4440
4930
  let answer;
4441
4931
  try {
4442
- answer = await capture$1(env, binary, [
4932
+ answer = await capture$1(env, cliPath, [
4443
4933
  "mcp",
4444
4934
  "get",
4445
4935
  name,
@@ -4494,15 +4984,15 @@ async function backUp(context, client, name, previous, now) {
4494
4984
  async function installTarget(context, options) {
4495
4985
  const apply = options.apply ?? true;
4496
4986
  const cliName = options.client === "claude-code" ? "claude" : options.client === "codex" ? "codex" : null;
4497
- const binary = cliName ? await findClientCli(cliName, context.env) : null;
4987
+ const cliPath = cliName ? await findClientCli(cliName, context.env) : null;
4498
4988
  const own = knownClientConfigs(context.env).find((file) => file.client === options.client)?.path;
4499
4989
  const configPath = options.client === "claude-code" ? void 0 : own;
4500
4990
  return {
4501
4991
  cliName,
4502
- binary,
4992
+ cliPath,
4503
4993
  own,
4504
4994
  configPath,
4505
- writes: apply && (cliName ? binary !== null : configPath !== void 0)
4995
+ writes: apply && (cliName ? cliPath !== null : configPath !== void 0)
4506
4996
  };
4507
4997
  }
4508
4998
  /**
@@ -4534,14 +5024,14 @@ async function preflightInstall(context, product, options) {
4534
5024
  const scan = await scanRegisteredServers(context.env);
4535
5025
  const existing = scan.servers;
4536
5026
  const target = await installTarget(context, options);
4537
- const { binary, own, configPath, writes } = target;
5027
+ const { cliPath, own, configPath, writes } = target;
4538
5028
  if (writes) {
4539
5029
  const blind = scan.unreadable.find((file) => file.path === own);
4540
5030
  if (blind) throw new CommsError("CONFIG", `${blind.path} could not be read (${blind.reason}), so what is already registered there cannot be checked; nothing was written`, { hint: "Fix the file, or add the entry by hand: `--print` shows it without writing anything." });
4541
5031
  }
4542
5032
  let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
4543
- if (writes && binary && options.client === "codex") {
4544
- const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
5033
+ if (writes && cliPath && options.client === "codex") {
5034
+ const reported = await codexRegistration(context.env, cliPath, name, configPath ?? "codex", context.platform);
4545
5035
  if (reported) previous = claimName(product, options, name, [reported], context.platform);
4546
5036
  }
4547
5037
  const { options: effective, kept } = keepNarrowing(product, options, previous);
@@ -4586,7 +5076,7 @@ function plannedInstall(preflight) {
4586
5076
  return {
4587
5077
  writes: target.writes,
4588
5078
  cliName: target.cliName,
4589
- binary: target.binary,
5079
+ cliPath: target.cliPath,
4590
5080
  configPath: target.configPath ?? null,
4591
5081
  own: target.own ?? null,
4592
5082
  replaces: previous.map((server) => JSON.stringify([
@@ -4620,7 +5110,7 @@ function planDrift(product, client, name, planned, now) {
4620
5110
  if (planned.writes !== now.writes) {
4621
5111
  if (cli !== null) drift.push(now.writes ? `${cli} was not on PATH when this was planned, and is now, so it would register the server rather than print its entry` : `${cli} was on PATH when this was planned, and is not now, so it would only print the entry`);
4622
5112
  else drift.push(now.writes ? "it would write the entry rather than print it" : "it would only print the entry");
4623
- } else if (planned.binary !== now.binary) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.binary}`);
5113
+ } else if (planned.cliPath !== now.cliPath) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.cliPath}`);
4624
5114
  if (planned.configPath !== now.configPath || planned.own !== now.own) drift.push(`${client}'s config is now ${now.own ?? now.configPath ?? "nowhere"}, not ${planned.own ?? planned.configPath ?? "nowhere"}`);
4625
5115
  if (JSON.stringify(planned.replaces) !== JSON.stringify(now.replaces)) drift.push(`what is registered as "${name}" is not what it was planned to replace`);
4626
5116
  if (JSON.stringify(planned.narrowing) !== JSON.stringify(now.narrowing)) {
@@ -4666,22 +5156,23 @@ async function mcpInstall(context, product, options, planned) {
4666
5156
  ...pinsOf(planned.narrowing)
4667
5157
  };
4668
5158
  const existing = scan.servers;
4669
- const { cliName, binary, own, configPath, writes } = target;
5159
+ const { cliName, cliPath, own, configPath, writes } = target;
4670
5160
  const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
4671
5161
  if (kept.length > 0) {
4672
- const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
5162
+ const byHand = `delete "${name}" from ${configPath}`;
5163
+ const removal = options.client === "claude-code" ? clientCommandHint([
4673
5164
  "claude",
4674
5165
  "mcp",
4675
5166
  "remove",
4676
5167
  name,
4677
5168
  "--scope",
4678
5169
  "user"
4679
- ], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
5170
+ ], context.platform, byHand) : options.client === "codex" ? clientCommandHint([
4680
5171
  "codex",
4681
5172
  "mcp",
4682
5173
  "remove",
4683
5174
  name
4684
- ], context.platform)) : `delete "${name}" from ${configPath}`;
5175
+ ], context.platform, byHand) : byHand;
4685
5176
  warnings.push(`Kept ${kept.join(" ")} from the "${name}" entry this replaced, because this install did not say otherwise. To register it wider on purpose, remove that entry first (${removal}), then install without them.`);
4686
5177
  }
4687
5178
  if (!writes) {
@@ -4709,7 +5200,7 @@ async function mcpInstall(context, product, options, planned) {
4709
5200
  let method = "printed";
4710
5201
  let applied = false;
4711
5202
  let notApplied;
4712
- if (cliName && binary && writes) {
5203
+ if (cliName && cliPath && writes) {
4713
5204
  const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
4714
5205
  const args = options.client === "claude-code" ? [
4715
5206
  "mcp",
@@ -4741,13 +5232,13 @@ async function mcpInstall(context, product, options, planned) {
4741
5232
  name
4742
5233
  ];
4743
5234
  try {
4744
- await run(context.env, binary, removal);
5235
+ await run(context.env, cliPath, removal);
4745
5236
  } catch (error) {
4746
5237
  const message = error instanceof CommsError ? error.message : String(error);
4747
5238
  if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4748
5239
  }
4749
5240
  try {
4750
- await run(context.env, binary, args);
5241
+ await run(context.env, cliPath, args);
4751
5242
  } catch (error) {
4752
5243
  const restore = options.client === "claude-code" ? [
4753
5244
  "mcp",
@@ -4771,31 +5262,36 @@ async function mcpInstall(context, product, options, planned) {
4771
5262
  ];
4772
5263
  let restored = true;
4773
5264
  try {
4774
- await run(context.env, binary, restore);
5265
+ await run(context.env, cliPath, restore);
4775
5266
  } catch {
4776
5267
  restored = false;
4777
5268
  }
4778
- throw new CommsError("CONFIG", restored ? `could not register "${name}"; the previous entry was put back` : `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
4779
- hint: restored ? "Check the client is not running, then try again." : `Re-register it with ${inlineCommand(installCommand$1(product, effective, name, [], context.platform))}. The old entry is in ${backupPath}.`,
5269
+ if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
5270
+ hint: "Check the client is not running, then try again.",
4780
5271
  cause: error
4781
5272
  });
5273
+ throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
5274
+ binary: product.binary,
5275
+ words: installWords(product, effective, name, []),
5276
+ say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
5277
+ }, { cause: error });
4782
5278
  }
4783
5279
  } else try {
4784
- await run(context.env, binary, args);
5280
+ await run(context.env, cliPath, args);
4785
5281
  } catch (error) {
4786
5282
  const message = error instanceof CommsError ? error.message : String(error);
4787
5283
  if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
4788
- hint: `Look at it with ${inlineCommand(shellCommand([
5284
+ hint: `Look at it with ${clientCommandHint([
4789
5285
  cliName,
4790
5286
  "mcp",
4791
5287
  "get",
4792
5288
  name
4793
- ], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
5289
+ ], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
4794
5290
  cliName,
4795
5291
  "mcp",
4796
5292
  "remove",
4797
5293
  name
4798
- ], context.platform))} and run this again; if not, choose another --name.`,
5294
+ ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
4799
5295
  cause: error
4800
5296
  });
4801
5297
  throw error;
@@ -5001,115 +5497,1033 @@ async function listManagedRuntimes(dataDir, packageName) {
5001
5497
  * removes leaves the record with it. When the record cannot be read, nothing is removed;
5002
5498
  * - never one a running process names. When the processes cannot be listed, nothing is removed at all.
5003
5499
  *
5004
- * What it cannot see is an entry in a file it does not read — a workspace `.vscode/mcp.json`, a config a client
5005
- * was pointed at on its command line — put there by hand, or pasted from a `--print` that warned its record could
5006
- * not be written. The documents say so rather than promise more.
5500
+ * What it cannot see is an entry in a file it does not read — a workspace `.vscode/mcp.json`, a config a client
5501
+ * was pointed at on its command line — put there by hand, or pasted from a `--print` that warned its record could
5502
+ * not be written. The documents say so rather than promise more.
5503
+ *
5504
+ * `only`, when given, is the most it may remove: the runtimes a person approved removing, from a dry run shown to
5505
+ * them. Anything else it would have removed is kept, and says why. Removal is approved as a list of paths, and a
5506
+ * runtime that became unused after that list was shown is not on it.
5507
+ */
5508
+ async function pruneManagedRuntimes(context, product, options = {}) {
5509
+ const result = {
5510
+ runtimeDir: join(context.core.paths.dataDir, "runtime"),
5511
+ dryRun: options.dryRun === true,
5512
+ removed: [],
5513
+ kept: []
5514
+ };
5515
+ const candidates = [];
5516
+ for (const { path, version } of await listManagedRuntimes(context.core.paths.dataDir, product.packageName)) {
5517
+ const real = await realpath(path).catch(() => path);
5518
+ candidates.push({
5519
+ path,
5520
+ version,
5521
+ aliases: [.../* @__PURE__ */ new Set([path, real])]
5522
+ });
5523
+ }
5524
+ if (candidates.length === 0) return result;
5525
+ const refuse = (why) => {
5526
+ result.refused = why;
5527
+ result.kept = candidates.map(({ path, version }) => ({
5528
+ path,
5529
+ version,
5530
+ reason: "not checked"
5531
+ }));
5532
+ return result;
5533
+ };
5534
+ const processes = await (options.processes ?? runningCommandLines)();
5535
+ if (processes === null) return refuse("the running processes on this machine could not be listed, so none of these can be shown unused");
5536
+ const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
5537
+ const handedOut = await readHandedOut(ledger);
5538
+ if (handedOut === null) return refuse(`${ledger} could not be read, so none of these can be shown never to have been handed out`);
5539
+ const recorded = handedOut.flatMap((record) => record.config ? [{
5540
+ client: record.client,
5541
+ path: record.config
5542
+ }] : []);
5543
+ const scan = await scanRegisteredServers(context.env, process.platform, recorded);
5544
+ if (scan.unreadable.length > 0) return refuse(`${scan.unreadable.map((file) => `${file.path} (${file.reason})`).join("; ")} could not be read, so none of these can be shown unregistered`);
5545
+ const registered = scan.servers;
5546
+ const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
5547
+ const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
5548
+ const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
5549
+ for (const { path, version, aliases } of candidates) {
5550
+ const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
5551
+ const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
5552
+ const reason = path === current ? "this release" : owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null;
5553
+ if (reason) {
5554
+ result.kept.push({
5555
+ path,
5556
+ version,
5557
+ reason
5558
+ });
5559
+ continue;
5560
+ }
5561
+ if (options.only !== void 0 && !options.only.includes(path)) {
5562
+ result.kept.push({
5563
+ path,
5564
+ version,
5565
+ reason: "not in the removal that was approved; prune again to include it"
5566
+ });
5567
+ continue;
5568
+ }
5569
+ if (!result.dryRun) await rm(path, { recursive: true });
5570
+ result.removed.push({
5571
+ path,
5572
+ version
5573
+ });
5574
+ }
5575
+ if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
5576
+ const gone = result.removed.map((item) => item.path);
5577
+ const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
5578
+ if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
5579
+ }
5580
+ return result;
5581
+ }
5582
+ /** Every runtime the installer handed out, or null when the record is there and cannot be read. */
5583
+ async function readHandedOut(path) {
5584
+ let text;
5585
+ try {
5586
+ text = await readFile(path, "utf8");
5587
+ } catch (error) {
5588
+ return error.code === "ENOENT" ? [] : null;
5589
+ }
5590
+ const records = [];
5591
+ for (const line of text.split("\n")) {
5592
+ if (!line.trim()) continue;
5593
+ try {
5594
+ const record = JSON.parse(line);
5595
+ if (typeof record.runtime !== "string") return null;
5596
+ records.push({
5597
+ at: String(record.at ?? ""),
5598
+ client: String(record.client ?? ""),
5599
+ name: String(record.name ?? ""),
5600
+ runtime: record.runtime,
5601
+ ...typeof record.config === "string" ? { config: record.config } : {},
5602
+ ...record.applied === true ? { applied: true } : {}
5603
+ });
5604
+ } catch {
5605
+ return null;
5606
+ }
5607
+ }
5608
+ return records;
5609
+ }
5610
+ /** A channel's registration facts, from its manifest. */
5611
+ function registrationFactsOf({ packageName, manifest }) {
5612
+ return {
5613
+ packageName,
5614
+ npxPackage: manifest.server.npxPackage,
5615
+ binary: manifest.binary,
5616
+ ...manifest.server.bins === void 0 ? {} : { bins: manifest.server.bins },
5617
+ ...manifest.server.entryFiles === void 0 ? {} : { entryFiles: manifest.server.entryFiles }
5618
+ };
5619
+ }
5620
+ /** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
5621
+ function launcherOf(server, facts, platform) {
5622
+ const fold = folding(platform);
5623
+ const parts = [server.command, ...server.args];
5624
+ if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
5625
+ if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
5626
+ const local = new RegExp("[/\\\\]packages[/\\\\][^/\\\\]+[/\\\\](?:src[/\\\\]cli\\.ts|dist[/\\\\]cli\\.mjs)$", platform === "win32" ? "i" : "");
5627
+ if (parts.some((part) => local.test(part))) return "local";
5628
+ return "other";
5629
+ }
5630
+ /** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
5631
+ function registrationVersion(server, facts, platform) {
5632
+ return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
5633
+ }
5634
+ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
5635
+ /**
5636
+ * Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
5637
+ * out, and the package its command line launches is read again from what is left rather than taken from the entry.
5638
+ * A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
5639
+ */
5640
+ function startsProduct(server, facts, platform) {
5641
+ const { words: args } = withoutPathOptions(server.args);
5642
+ const packageName = commandLinePackage(server.command, args);
5643
+ return isProductServer({
5644
+ command: server.command,
5645
+ args,
5646
+ ...packageName === void 0 ? {} : { packageName }
5647
+ }, facts, platform);
5648
+ }
5649
+ /**
5650
+ * A command's name as Windows finds it — its last path segment without `.cmd`, `.exe`, `.bat` or `.ps1` — without case
5651
+ * on Windows (`platform`), and exactly elsewhere.
5652
+ */
5653
+ function commandStem(command, platform) {
5654
+ return folding(platform)((command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
5655
+ }
5656
+ /** Whether npx runs the entry: its program is npx, or it names the product's package at a version, as npx does. */
5657
+ function isNpxRegistration(server, facts, platform) {
5658
+ return commandStem(server.command, platform) === "npx" || launcherOf(server, facts, platform) === "npx";
5659
+ }
5660
+ /**
5661
+ * The files an entry names as what it starts, as it writes them: every script among its words before `--` once the
5662
+ * path options are out (an interpreter's flags may name one too, so each is a candidate, not the answer), and its
5663
+ * program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
5664
+ */
5665
+ function registrationFiles(server, facts, platform) {
5666
+ const { words } = withoutPathOptions(server.args);
5667
+ const sentinel = words.indexOf("--");
5668
+ const options = sentinel < 0 ? words : words.slice(0, sentinel);
5669
+ const fold = folding(platform);
5670
+ return {
5671
+ scripts: options.flatMap((word) => [word, ...word.startsWith("--") && word.includes("=") ? [word.slice(word.indexOf("=") + 1)] : []]).filter((word) => !word.startsWith("-") && /\.(?:[cm]?js|[cm]?ts)$/i.test(word)),
5672
+ command: [facts.binary, ...facts.bins ?? []].map(fold).includes(commandStem(server.command, platform)) ? server.command : null
5673
+ };
5674
+ }
5675
+ function folding(platform) {
5676
+ return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
5677
+ }
5678
+ /** The package version, read from package.json at build time. */
5679
+ const VERSION = "0.13.1";
5680
+ /**
5681
+ * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
5682
+ * to decide whether the release it last heard of is newer than the one running.
5683
+ *
5684
+ * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
5685
+ * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
5686
+ * all of it, so nothing that imported these from there changes.
5687
+ */
5688
+ /**
5689
+ * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
5690
+ *
5691
+ * Anything the registry answers is checked against this before it is used, because a version goes into a directory
5692
+ * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
5693
+ * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
5694
+ */
5695
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
5696
+ function isVersion(value) {
5697
+ return typeof value === "string" && VERSION_PATTERN.test(value);
5698
+ }
5699
+ /** Two numeric strings with no leading zeros, compared as numbers of any size. */
5700
+ function compareNumeric(a, b) {
5701
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
5702
+ return a < b ? -1 : a > b ? 1 : 0;
5703
+ }
5704
+ /**
5705
+ * Semver precedence: -1, 0 or 1, or null when either is not a version.
5706
+ *
5707
+ * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
5708
+ * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
5709
+ * few packages as it can.
5710
+ */
5711
+ function compareVersions(a, b) {
5712
+ const left = VERSION_PATTERN.exec(a);
5713
+ const right = VERSION_PATTERN.exec(b);
5714
+ if (!left || !right) return null;
5715
+ for (const index of [
5716
+ 1,
5717
+ 2,
5718
+ 3
5719
+ ]) {
5720
+ const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
5721
+ if (order !== 0) return order < 0 ? -1 : 1;
5722
+ }
5723
+ const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
5724
+ const [ours, theirs] = [pre(left), pre(right)];
5725
+ if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
5726
+ for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
5727
+ const x = ours[index];
5728
+ const y = theirs[index];
5729
+ if (x === void 0) return -1;
5730
+ if (y === void 0) return 1;
5731
+ const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
5732
+ if (numericX && numericY) {
5733
+ const order = compareNumeric(x, y);
5734
+ if (order !== 0) return order < 0 ? -1 : 1;
5735
+ } else if (numericX !== numericY) return numericX ? -1 : 1;
5736
+ else if (x !== y) return x < y ? -1 : 1;
5737
+ }
5738
+ return 0;
5739
+ }
5740
+ /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
5741
+ function isBehind(version, latest) {
5742
+ if (version === latest) return false;
5743
+ const order = compareVersions(version, latest);
5744
+ return order === null ? true : order < 0;
5745
+ }
5746
+ /**
5747
+ * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
5748
+ * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
5749
+ */
5750
+ function isPrerelease(version) {
5751
+ return VERSION_PATTERN.exec(version)?.[4] !== void 0;
5752
+ }
5753
+ /**
5754
+ * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
5755
+ *
5756
+ * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
5757
+ * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
5758
+ * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
5759
+ * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
5760
+ */
5761
+ function satisfiesRange(version, range) {
5762
+ const wanted = version.replace(/^v/, "");
5763
+ if (!isVersion(wanted)) return null;
5764
+ const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
5765
+ if (alternatives.some((comparators) => comparators.length === 0)) return null;
5766
+ let any = false;
5767
+ for (const comparators of alternatives) {
5768
+ let all = true;
5769
+ for (const comparator of comparators) {
5770
+ const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
5771
+ const bound = match?.[2] ?? "";
5772
+ if (!isVersion(bound)) return null;
5773
+ const order = compareVersions(wanted, bound);
5774
+ if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
5775
+ }
5776
+ if (all) any = true;
5777
+ }
5778
+ return any;
5779
+ }
5780
+ /**
5781
+ * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
5782
+ *
5783
+ * A bare `agent-gmail approve …` is not runnable for most of the people it is printed for: a package run by npx, by a
5784
+ * managed runtime or from a checkout puts nothing on their PATH. So every such command names this process's Node and
5785
+ * a checked file of the product it runs, with the suite directories it uses pinned as options — and when no file can
5786
+ * be checked, there is no command at all, only a sentence saying what is missing. The caller says which package it is
5787
+ * and what to run; it never supplies the program or quotes the line.
5788
+ *
5789
+ * Three directions. A product's own CLI is found from the product itself, walking from one of its own modules to its
5790
+ * manifest: no registration is ever asked. A channel's handoff to core is found the way the channel itself finds core,
5791
+ * through its installed dependency, and only the same release. Another product's command is found among the servers
5792
+ * registered with the person's MCP clients, which the caller scanned and passes in: only a file-backed registration of
5793
+ * exactly the printing package's version, checked on disk. Nothing a registration names is run, or asked anything —
5794
+ * not even its interpreter for a version: no installer wrote down where a scanned entry came from, so none is trusted
5795
+ * to run (D1). This module never scans, spawns or probes.
5796
+ *
5797
+ * What is checked is checked when the command is printed, not for ever (D6): the file it names is real, readable and
5798
+ * inside its package then, and a later change to that installation is a later fact.
5799
+ */
5800
+ /** The gate a constructor call has to pass: only this module holds it. */
5801
+ const GATE = Symbol("locateCliCommand");
5802
+ /**
5803
+ * A command that runs one of this suite's own CLIs, made only by `locateCliCommand`. Rendered as every printed command
5804
+ * is — a line to paste, or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`,
5805
+ * `commandText`).
5806
+ *
5807
+ * A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
5808
+ * a spread copy of one with other words in it, as it would be with a symbol-keyed brand. Only the type is exported;
5809
+ * the class and its gate are not.
5810
+ */
5811
+ var PrintedCommand = class {
5812
+ #entry;
5813
+ /** The words, as the program is to receive them: this Node, its flags, the entry, then the CLI's own words. */
5814
+ words;
5815
+ /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
5816
+ line;
5817
+ platform;
5818
+ constructor(gate, words, platform, entry) {
5819
+ if (gate !== GATE) throw new TypeError("a printed command is made by locateCliCommand, and only there");
5820
+ this.#entry = entry;
5821
+ this.words = Object.freeze([...words]);
5822
+ this.line = quoteCommand(this.words, platform).line;
5823
+ this.platform = platform;
5824
+ Object.freeze(this);
5825
+ }
5826
+ /**
5827
+ * The real path of the file it runs, as it was checked when the command was located: a fact about then (D6). The
5828
+ * command names this path and nothing else, so what runs is whatever is there when the person runs it.
5829
+ */
5830
+ get entry() {
5831
+ return this.#entry;
5832
+ }
5833
+ /**
5834
+ * As JSON — a result's field written out by `--json`, or returned to an MCP client — it is the text it always was:
5835
+ * its line, or its words as JSON with what to do when no Windows line is safe (`commandText`). Rendered where it
5836
+ * leaves the process, so a field can hold the command itself and the wire stays the same.
5837
+ */
5838
+ toJSON() {
5839
+ return quotedText(this);
5840
+ }
5841
+ /** Never interpolated: a sentence gives a command through `handoffSentence`, a value through `handoffText`. */
5842
+ [Symbol.toPrimitive]() {
5843
+ throw new TypeError("a printed command is rendered with handoffText or handoffSentence, never interpolated");
5844
+ }
5845
+ };
5846
+ const STRIP_TYPES = "--experimental-strip-types";
5847
+ const TRANSFORM_TYPES = "--experimental-transform-types";
5848
+ /** The suite's packages, by name, and its channel packages, which reach core through their own dependency on it. */
5849
+ const SUITE_PACKAGES = new Set(CHANNEL_SNAPSHOT.map(({ packageName }) => packageName));
5850
+ const CHANNEL_PACKAGES$1 = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
5851
+ /**
5852
+ * Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
5853
+ *
5854
+ * The program is always this process's Node (`process.execPath`), which must satisfy the target's `engines.node`; no
5855
+ * interpreter named anywhere else is run, or asked its version. A TypeScript entry gets `--experimental-strip-types`,
5856
+ * which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
5857
+ * no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
5858
+ * CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
5859
+ * before any `--`.
5860
+ *
5861
+ * Synchronous, so a hint or a renderer can call it where it builds its sentence; for another product the caller scans
5862
+ * the registrations first and passes them in (`request.registrations`).
5863
+ */
5864
+ function locateCliCommand(request, runtime = {
5865
+ version: process.version,
5866
+ execArgv: process.execArgv
5867
+ }) {
5868
+ const { caller, target } = request;
5869
+ const [first] = request.words;
5870
+ if (first !== void 0 && (isAbsolute(first) || suiteCommandOf(first) !== null)) throw new TypeError(`the words to run start after the program: ${JSON.stringify(first)} is one`);
5871
+ const platform = request.platform ?? process.platform;
5872
+ let version = VERSION;
5873
+ const fail = (reason, detail, extra = {}) => notLocated(target, version, reason, detail, runtime.version, extra);
5874
+ let callerFile;
5875
+ try {
5876
+ callerFile = fileURLToPath(caller.url);
5877
+ } catch {
5878
+ return fail("caller", `${caller.url} is not a module on this machine`);
5879
+ }
5880
+ const callerSource = callerFile.endsWith(".ts");
5881
+ const nearest = nearestPackage(dirname(callerFile));
5882
+ const callerManifest = nearest?.manifest ?? null;
5883
+ if (nearest === null || callerManifest === null || callerManifest.name !== caller.packageName) {
5884
+ const name = typeof callerManifest?.name === "string" ? callerManifest.name : "no named package";
5885
+ return fail("manifest", `the package ${callerFile} belongs to is ${name}, not ${caller.packageName}`);
5886
+ }
5887
+ const callerVersion = typeof callerManifest.version === "string" ? callerManifest.version : null;
5888
+ if (callerVersion !== null) version = callerVersion;
5889
+ let callerRoot;
5890
+ try {
5891
+ callerRoot = realpathSync(nearest.root);
5892
+ } catch {
5893
+ return fail("caller", `${nearest.root} cannot be resolved`);
5894
+ }
5895
+ if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
5896
+ const pins = {};
5897
+ for (const key of request.uses) pins[key] = request.paths[key];
5898
+ let words;
5899
+ try {
5900
+ words = normalizePathOptionWords(request.words, pins, 0);
5901
+ } catch (error) {
5902
+ return fail("arguments", error instanceof Error ? error.message : String(error));
5903
+ }
5904
+ const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
5905
+ const located = (built, found, direction) => Object.freeze({
5906
+ ok: true,
5907
+ command: built.command,
5908
+ basis: Object.freeze({
5909
+ callerSource,
5910
+ callerRoot,
5911
+ callerVersion,
5912
+ targetRoot: found.root,
5913
+ targetVersion: typeof found.manifest.version === "string" ? found.manifest.version : null,
5914
+ callerWorkspace: null,
5915
+ targetWorkspace: null,
5916
+ entryKind: built.entryKind,
5917
+ ...direction
5918
+ })
5919
+ });
5920
+ if (caller.packageName === target.packageName) {
5921
+ const found = {
5922
+ root: callerRoot,
5923
+ manifest: callerManifest,
5924
+ source: callerSource
5925
+ };
5926
+ const built = build(found);
5927
+ if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
5928
+ return located(built, found, {
5929
+ direction: "own",
5930
+ sameCheckout: true
5931
+ });
5932
+ }
5933
+ if (target.manifest.channel === "core" && CHANNEL_PACKAGES$1.has(caller.packageName)) {
5934
+ const installed = installedPackage(dirname(callerFile), target.packageName);
5935
+ if (installed === null) return fail("core", `${caller.packageName} has no ${target.packageName} installed where it can find one`);
5936
+ const manifest = readPackageManifest(installed);
5937
+ if (manifest === null || manifest.name !== target.packageName) return fail("manifest", `the package found as ${target.packageName}, at ${installed}, is not that package`);
5938
+ if (manifest.version !== version) {
5939
+ const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
5940
+ return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
5941
+ }
5942
+ const callerWorkspace = workspaceOf(callerRoot);
5943
+ const targetWorkspace = workspaceOf(installed);
5944
+ const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
5945
+ const found = {
5946
+ root: installed,
5947
+ manifest,
5948
+ source: callerSource && sameCheckout
5949
+ };
5950
+ const built = build(found);
5951
+ if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
5952
+ return located(built, found, {
5953
+ direction: "channel-to-core",
5954
+ callerWorkspace,
5955
+ targetWorkspace,
5956
+ sameCheckout
5957
+ });
5958
+ }
5959
+ if (!SUITE_PACKAGES.has(caller.packageName)) return fail("direction", `${caller.packageName} is not a package of this suite`);
5960
+ if (request.registrations === void 0) return fail("no-registrations", "nothing registered was given to look through for another product's command");
5961
+ const facts = registrationFactsOf(target);
5962
+ const notUsed = [];
5963
+ const usable = [];
5964
+ let engineRange;
5965
+ for (const server of request.registrations) {
5966
+ if (!startsProduct(server, facts, platform)) continue;
5967
+ const reject = (why) => notUsed.push(Object.freeze({
5968
+ client: server.client,
5969
+ path: server.path,
5970
+ name: server.name,
5971
+ why
5972
+ }));
5973
+ if (isNpxRegistration(server, facts, platform)) {
5974
+ reject("npx");
5975
+ continue;
5976
+ }
5977
+ if (withoutPathOptions(server.args).dangling) {
5978
+ reject("arguments");
5979
+ continue;
5980
+ }
5981
+ const launcher = launcherOf(server, facts, platform);
5982
+ const files = registrationFiles(server, facts, platform);
5983
+ if (![...files.scripts, ...files.command === null ? [] : [files.command]].some((file) => isAbsolute(file))) {
5984
+ reject("no-file");
5985
+ continue;
5986
+ }
5987
+ const found = registeredPackage(files, target, facts);
5988
+ if (found === null) {
5989
+ reject("no-package");
5990
+ continue;
5991
+ }
5992
+ const managedAt = launcher === "managed" ? registrationVersion(server, facts, platform) : version;
5993
+ if (found.manifest.version !== version || managedAt !== version) {
5994
+ reject("version");
5995
+ continue;
5996
+ }
5997
+ const built = build(found);
5998
+ if (!built.ok) {
5999
+ reject(built.reason);
6000
+ if (built.reason === "engine") engineRange ??= built.nodeRange;
6001
+ continue;
6002
+ }
6003
+ usable.push({
6004
+ server,
6005
+ launcher,
6006
+ built,
6007
+ found
6008
+ });
6009
+ }
6010
+ const rank = ({ server, launcher }) => [
6011
+ launcher === "managed" ? "0" : "1",
6012
+ server.client,
6013
+ server.path,
6014
+ server.name,
6015
+ server.command,
6016
+ ...server.args
6017
+ ];
6018
+ const [best] = usable.sort((a, b) => compareWords(rank(a), rank(b)));
6019
+ if (best === void 0) {
6020
+ if (engineRange !== void 0) return fail("engine", `it needs Node ${engineRange}, and this is Node ${runtime.version}`, {
6021
+ nodeRange: engineRange,
6022
+ registrations: notUsed
6023
+ });
6024
+ return fail("not-registered", notRegistered(notUsed, version), { registrations: notUsed });
6025
+ }
6026
+ const { client, path, name } = best.server;
6027
+ return located(best.built, best.found, {
6028
+ direction: "cross-product",
6029
+ sameCheckout: false,
6030
+ registration: Object.freeze({
6031
+ client,
6032
+ path,
6033
+ name,
6034
+ launcher: best.launcher
6035
+ })
6036
+ });
6037
+ }
6038
+ /**
6039
+ * Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
6040
+ * CLI or the manifest's bin for the command it declares — as a readable file inside its package.
6041
+ */
6042
+ function buildCommand(target, found, words, runtime, callerSource, platform) {
6043
+ const engines = found.manifest.engines;
6044
+ const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
6045
+ if (typeof range !== "string") return {
6046
+ ok: false,
6047
+ reason: "engine",
6048
+ detail: `${target.packageName} at ${found.root} declares no Node range this can check`
6049
+ };
6050
+ if (satisfiesRange(runtime.version, range) !== true) return {
6051
+ ok: false,
6052
+ reason: "engine",
6053
+ detail: `it needs Node ${range}, and this is Node ${runtime.version}`,
6054
+ nodeRange: range
6055
+ };
6056
+ let entry;
6057
+ if (found.source) entry = join(found.root, "src", "cli.ts");
6058
+ else {
6059
+ const declared = found.manifest.agentcomms;
6060
+ const binary = typeof declared === "object" && declared !== null ? declared.binary : void 0;
6061
+ const bin = found.manifest.bin;
6062
+ const path = binary === target.manifest.binary && typeof bin === "object" && bin !== null && Object.hasOwn(bin, target.manifest.binary) ? bin[target.manifest.binary] : void 0;
6063
+ if (typeof path !== "string" || path.length === 0) return {
6064
+ ok: false,
6065
+ reason: "bin",
6066
+ detail: `the bin of ${target.packageName} at ${found.root} does not name the command it declares`
6067
+ };
6068
+ entry = resolve(found.root, path);
6069
+ }
6070
+ const checked = checkedEntry(found.root, entry);
6071
+ if (checked.ok === false) return {
6072
+ ok: false,
6073
+ reason: "entry",
6074
+ detail: checked.why
6075
+ };
6076
+ const flags = /\.[cm]?ts$/.test(checked.path) ? [STRIP_TYPES, ...callerSource && runtime.execArgv.includes(TRANSFORM_TYPES) ? [TRANSFORM_TYPES] : []] : [];
6077
+ return {
6078
+ ok: true,
6079
+ command: new PrintedCommand(GATE, [
6080
+ process.execPath,
6081
+ ...flags,
6082
+ checked.path,
6083
+ ...words
6084
+ ], platform, checked.path),
6085
+ entryKind: found.source ? "source" : "bin"
6086
+ };
6087
+ }
6088
+ /**
6089
+ * The target's package a registration's files lead to, or null: a script it starts, real or as written when it is
6090
+ * gone, inside the package (or inside the product's own server wrapper, which depends on it); or, for the product's
6091
+ * own command, the package that command links into or that npm installed beside it.
6092
+ */
6093
+ function registeredPackage(files, target, facts) {
6094
+ for (const script of files.scripts) {
6095
+ if (!isAbsolute(script)) continue;
6096
+ const found = packageAround(script, target, facts);
6097
+ if (found !== null) {
6098
+ const source = join(found.root, "src", "cli.ts");
6099
+ const real = realpathOfExisting(script);
6100
+ return {
6101
+ ...found,
6102
+ source: real !== null && sameFile(real, source)
6103
+ };
6104
+ }
6105
+ }
6106
+ const { command } = files;
6107
+ if (command === null || !isAbsolute(command)) return null;
6108
+ const linked = packageAround(command, target, facts);
6109
+ if (linked !== null) return {
6110
+ ...linked,
6111
+ source: false
6112
+ };
6113
+ const dir = dirname(command);
6114
+ for (const candidate of [join(dir, "node_modules"), join(dir, "..", "lib", "node_modules")]) {
6115
+ let root;
6116
+ try {
6117
+ root = realpathSync(join(candidate, ...target.packageName.split("/")));
6118
+ } catch {
6119
+ continue;
6120
+ }
6121
+ const manifest = readPackageManifest(root);
6122
+ if (manifest?.name === target.packageName) return {
6123
+ root,
6124
+ manifest,
6125
+ source: false
6126
+ };
6127
+ }
6128
+ return null;
6129
+ }
6130
+ /** The target's package that `file` is in — directly, or through the product's server wrapper, which depends on it. */
6131
+ function packageAround(file, target, facts) {
6132
+ const real = realpathOfExisting(file);
6133
+ if (real === null) return null;
6134
+ const nearest = nearestPackage(dirname(real));
6135
+ if (nearest === null || nearest.manifest === null) return null;
6136
+ let root;
6137
+ try {
6138
+ root = realpathSync(nearest.root);
6139
+ } catch {
6140
+ return null;
6141
+ }
6142
+ if (nearest.manifest.name === target.packageName) return {
6143
+ root,
6144
+ manifest: nearest.manifest
6145
+ };
6146
+ if (facts.npxPackage === target.packageName || nearest.manifest.name !== facts.npxPackage) return null;
6147
+ const installed = installedPackage(root, target.packageName);
6148
+ const manifest = installed === null ? null : readPackageManifest(installed);
6149
+ return installed !== null && manifest?.name === target.packageName ? {
6150
+ root: installed,
6151
+ manifest
6152
+ } : null;
6153
+ }
6154
+ function sameFile(a, b) {
6155
+ try {
6156
+ return realpathSync(a) === realpathSync(b);
6157
+ } catch {
6158
+ return false;
6159
+ }
6160
+ }
6161
+ /** Two lists of words in order: the first that differs decides, and a list that runs out first comes first. */
6162
+ function compareWords(a, b) {
6163
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
6164
+ const [x, y] = [a[index], b[index]];
6165
+ if (x !== y) return x < y ? -1 : 1;
6166
+ }
6167
+ return a.length - b.length;
6168
+ }
6169
+ /** Why no registration of another product could be used, in a clause: how many were looked at, and what each was. */
6170
+ function notRegistered(notUsed, version) {
6171
+ if (notUsed.length === 0) return "no MCP client on this machine has it registered";
6172
+ const said = {
6173
+ npx: "run by npx, which is never used for this",
6174
+ arguments: "with a path option that has no value",
6175
+ "no-file": "not started from a file named by its full path",
6176
+ "no-package": "whose package cannot be found or read",
6177
+ version: "of another or an unknown version",
6178
+ engine: "whose Node range this Node is outside, or which declares none",
6179
+ bin: "whose package does not name its command",
6180
+ entry: "whose command file is missing, unreadable or outside its package"
6181
+ };
6182
+ const counts = /* @__PURE__ */ new Map();
6183
+ for (const { why } of notUsed) counts.set(why, (counts.get(why) ?? 0) + 1);
6184
+ const parts = [...counts].map(([why, count]) => `${count} ${said[why]}`);
6185
+ return `none of ${notUsed.length === 1 ? "the 1 registration" : `the ${notUsed.length} registrations`} of it is ${version} in a file this can check (${parts.join("; ")})`;
6186
+ }
6187
+ function notLocated(target, version, reason, detail, nodeVersion, extra) {
6188
+ const named = `${target.manifest.label} ${version} (${target.packageName})`;
6189
+ const { nodeRange, registrations } = extra;
6190
+ const message = nodeRange === void 0 ? `${named} is not locatable here: ${detail}. Install or update it through your usual route, then try again.` : `${named} needs Node ${nodeRange}, and this is Node ${nodeVersion}, so there is no command to run it with here. Run it again under a Node in that range.`;
6191
+ const notLocated = {
6192
+ ok: false,
6193
+ reason,
6194
+ product: target.manifest.label,
6195
+ package: target.packageName,
6196
+ version,
6197
+ ...nodeRange === void 0 ? {} : { nodeRange },
6198
+ ...registrations === void 0 ? {} : { registrations: Object.freeze([...registrations]) },
6199
+ detail,
6200
+ message
6201
+ };
6202
+ Object.defineProperties(notLocated, {
6203
+ toJSON: { value: () => message },
6204
+ [Symbol.toPrimitive]: { value: () => {
6205
+ throw new TypeError("a missing command is said with handoffText or handoffSentence, never interpolated");
6206
+ } }
6207
+ });
6208
+ return Object.freeze(notLocated);
6209
+ }
6210
+ /**
6211
+ * The entry's real path, when it is a readable regular file inside its package's real root by whole segments — as
6212
+ * written and after links are resolved — or why not.
6213
+ *
6214
+ * Checked now, for the command printed now (D6). This does not make the path durable: an npx cache evicted, a global
6215
+ * install upgraded in place or a file replaced after this is what the command meets when it runs — a missing file
6216
+ * fails with Node's own error naming it, a replaced one runs as it then is. Nothing here, or in the command, looks
6217
+ * anywhere else for the product, PATH included, and a later locate checks the path again.
6218
+ */
6219
+ function checkedEntry(root, entry) {
6220
+ if (!isWithin(root, entry)) return {
6221
+ ok: false,
6222
+ why: `${entry} is outside its package, ${root}`
6223
+ };
6224
+ let real;
6225
+ try {
6226
+ real = realpathSync(entry);
6227
+ } catch {
6228
+ return {
6229
+ ok: false,
6230
+ why: `${entry} is not there`
6231
+ };
6232
+ }
6233
+ if (!isWithin(root, real)) return {
6234
+ ok: false,
6235
+ why: `${entry} leads to ${real}, outside its package, ${root}`
6236
+ };
6237
+ try {
6238
+ if (!statSync(real).isFile()) return {
6239
+ ok: false,
6240
+ why: `${entry} is not a file`
6241
+ };
6242
+ accessSync(real, constants$1.R_OK);
6243
+ } catch {
6244
+ return {
6245
+ ok: false,
6246
+ why: `${entry} cannot be read`
6247
+ };
6248
+ }
6249
+ return {
6250
+ ok: true,
6251
+ path: real
6252
+ };
6253
+ }
6254
+ /**
6255
+ * The real root of the package `name` installed for a module in `start`, found as Node's own resolution finds one: in
6256
+ * `node_modules` of each directory from there up. Not resolved through its `exports` — core's point at a build, and
6257
+ * which entry to run is decided here — and never from a global folder, which `import` does not read either.
6258
+ */
6259
+ function installedPackage(start, name) {
6260
+ let dir = resolve(start);
6261
+ for (;;) {
6262
+ if (basename(dir) !== "node_modules") {
6263
+ const candidate = join(dir, "node_modules", ...name.split("/"));
6264
+ try {
6265
+ if (statSync(candidate).isDirectory()) return realpathSync(candidate);
6266
+ } catch {}
6267
+ }
6268
+ const parent = dirname(dir);
6269
+ if (parent === dir) return null;
6270
+ dir = parent;
6271
+ }
6272
+ }
6273
+ /**
6274
+ * The real repository checkout whose workspace lists `root` as one of its packages, or null: the nearest
6275
+ * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
6276
+ * installed copy in its `node_modules` — is not a workspace package of it.
6277
+ */
6278
+ function workspaceOf(root) {
6279
+ let dir = dirname(root);
6280
+ for (;;) {
6281
+ const globs = workspaceGlobs(dir);
6282
+ if (globs !== null) return inWorkspace(relative(dir, root).split(sep), globs) ? dir : null;
6283
+ const parent = dirname(dir);
6284
+ if (parent === dir) return null;
6285
+ dir = parent;
6286
+ }
6287
+ }
6288
+ /** The `packages` globs of `dir/pnpm-workspace.yaml`, or null when there is no such file. */
6289
+ function workspaceGlobs(dir) {
6290
+ let text;
6291
+ try {
6292
+ text = readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8");
6293
+ } catch (error) {
6294
+ const code = error.code;
6295
+ return code === "ENOENT" || code === "ENOTDIR" ? null : [];
6296
+ }
6297
+ const unquote = (glob) => glob.trim().replace(/^(['"])(.*)\1$/, "$2");
6298
+ const lines = text.split(/\r?\n/);
6299
+ const at = lines.findIndex((line) => /^packages\s*:/.test(line));
6300
+ if (at < 0) return [];
6301
+ const inline = /^packages\s*:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[at]);
6302
+ if (inline) return (inline[1] ?? "").split(",").map(unquote).filter(Boolean);
6303
+ const globs = [];
6304
+ for (const line of lines.slice(at + 1)) {
6305
+ if (/^\s*(?:#.*)?$/.test(line)) continue;
6306
+ const item = /^\s+-\s*(.*?)\s*(?:#.*)?$/.exec(line);
6307
+ if (!item) break;
6308
+ globs.push(unquote(item[1] ?? ""));
6309
+ }
6310
+ return globs;
6311
+ }
6312
+ /** Whether a path, as segments from the workspace root, is one of its packages: some glob takes it and no `!` one. */
6313
+ function inWorkspace(segments, globs) {
6314
+ const matches = (glob) => segmentsMatch(segments, glob.replace(/^\.\//, "").replace(/\/+$/, "").split("/").filter((part) => part !== "."));
6315
+ const included = globs.filter((glob) => !glob.startsWith("!"));
6316
+ const excluded = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
6317
+ return included.some(matches) && !excluded.some(matches);
6318
+ }
6319
+ function segmentsMatch(path, pattern) {
6320
+ const [head, ...rest] = pattern;
6321
+ if (head === void 0) return path.length === 0;
6322
+ if (head === "**") return segmentsMatch(path, rest) || path.length > 0 && segmentsMatch(path.slice(1), pattern);
6323
+ const [segment] = path;
6324
+ if (segment === void 0) return false;
6325
+ return new RegExp(`^${head.split("*").map(escapeRegExp$1).join("[^/]*")}$`).test(segment) && segmentsMatch(path.slice(1), rest);
6326
+ }
6327
+ function escapeRegExp$1(text) {
6328
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6329
+ }
6330
+ /**
6331
+ * The commands a package tells a person to run, made once per printing process (design 2026-10-04, D1 and D5;
6332
+ * CUE-403). How a channel migrates a handoff to these is in CONTRIBUTING.md, "Telling a person what to run".
6333
+ *
6334
+ * A package makes them from where it is — a module of its own and its package name, the caller — with the folders and
6335
+ * shell of the process printing: `openCore({ caller })` puts them on `core.handoffs`. Each method takes the CLI's own
6336
+ * words, never the program, and returns a `Handoff`: a located `PrintedCommand`, or why there is none here. Nothing
6337
+ * here quotes a line by hand, names a bare binary, or guesses an installation.
6338
+ */
6339
+ /** The suite folders every CLI command opens: what a command is pinned to unless it says otherwise. */
6340
+ const HANDOFF_FOLDERS = Object.freeze([
6341
+ "configDir",
6342
+ "stateDir",
6343
+ "dataDir",
6344
+ "secretsDir"
6345
+ ]);
6346
+ Object.freeze({
6347
+ url: import.meta.url,
6348
+ packageName: "@agentcomms/core"
6349
+ });
6350
+ /** A channel of this release, by its word; a programming error for a word that is none. */
6351
+ function entryOf$1(channel) {
6352
+ const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
6353
+ if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
6354
+ return entry;
6355
+ }
6356
+ /** The folders a command is pinned to. */
6357
+ function usesOf(use) {
6358
+ return [...use?.uses ?? HANDOFF_FOLDERS, ...use?.downloads ? ["downloadsDir"] : []];
6359
+ }
6360
+ /** The commands a package prints, located from `caller` (CONTRIBUTING.md, "Telling a person what to run"). */
6361
+ function cliHandoffs(options) {
6362
+ const { caller, paths, registrations } = options;
6363
+ const platform = options.platform ?? process.platform;
6364
+ const own = CHANNEL_SNAPSHOT.find((entry) => entry.packageName === caller.packageName);
6365
+ if (own === void 0) throw new TypeError(`${caller.packageName} is not a package of this suite`);
6366
+ const locate = (target, words, use) => {
6367
+ const result = locateCliCommand({
6368
+ caller,
6369
+ target,
6370
+ words,
6371
+ uses: usesOf(use),
6372
+ paths,
6373
+ platform,
6374
+ ...registrations === void 0 ? {} : { registrations }
6375
+ }, ...options.runtime === void 0 ? [] : [options.runtime]);
6376
+ return result.ok ? result.command : result;
6377
+ };
6378
+ const handoffs = Object.freeze({
6379
+ caller,
6380
+ platform,
6381
+ own: (words, use) => locate(own, words, use),
6382
+ core: (words, use) => locate(entryOf$1("core"), words, use),
6383
+ of: (channel, words, use) => locate(entryOf$1(channel), words, use),
6384
+ on: (other) => other === platform ? handoffs : cliHandoffs({
6385
+ ...options,
6386
+ platform: other
6387
+ }),
6388
+ registered: async () => cliHandoffs({
6389
+ ...options,
6390
+ registrations: (await scanRegisteredServers(options.env ?? process.env, platform)).servers
6391
+ }),
6392
+ withRegistrations: (servers) => cliHandoffs({
6393
+ ...options,
6394
+ registrations: servers
6395
+ })
6396
+ });
6397
+ return handoffs;
6398
+ }
6399
+ /**
6400
+ * `core.handoffs` — quoted for `platform` when one is given — for anything that prints a command. A programming error
6401
+ * when core was opened without its caller: the package has to give it one (`openCore({ caller })`), and there is no
6402
+ * bare name to print instead.
6403
+ */
6404
+ function requireHandoffs(core, platform) {
6405
+ const handoffs = requiredHandoffs(core.handoffs);
6406
+ return platform === void 0 ? handoffs : handoffs.on(platform);
6407
+ }
6408
+ const MAP = {
6409
+ inbox: "inboxes",
6410
+ account: "accounts"
6411
+ };
6412
+ /**
6413
+ * An own property only.
6414
+ *
6415
+ * A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
6416
+ * of an inbox called `constructor`, which version 1 allows, found it.
6417
+ */
6418
+ function own$1(map, key) {
6419
+ return Object.hasOwn(map, key) ? map[key] : void 0;
6420
+ }
6421
+ function resolveName(config, kind, name, notFound) {
6422
+ if (kind === "inbox") {
6423
+ const inbox = own$1(config.inboxes, name);
6424
+ if (inbox) return {
6425
+ alias: name,
6426
+ inbox
6427
+ };
6428
+ } else {
6429
+ const account = own$1(config.accounts, name);
6430
+ if (account) return {
6431
+ alias: name,
6432
+ account
6433
+ };
6434
+ }
6435
+ const renamed = formerNameRefusal(config, kind, name);
6436
+ if (renamed) throw renamed;
6437
+ throw notFound?.() ?? defaultNotFound(config, kind, name);
6438
+ }
6439
+ /** What to say when there are no mailboxes at all: how one is connected, in words (`connectMailboxWords`). */
6440
+ function noInboxesHint() {
6441
+ const connect = connectMailboxWords();
6442
+ return connect ? `No inboxes yet: add one with ${connect}, at a terminal or from a chat.` : "No inboxes yet.";
6443
+ }
6444
+ function defaultNotFound(config, kind, name) {
6445
+ if (kind === "inbox") {
6446
+ const known = Object.keys(config.inboxes);
6447
+ return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
6448
+ }
6449
+ return new CommsError("NOT_FOUND", `no account called "${name}"`);
6450
+ }
6451
+ function lookupName(config, kind, name) {
6452
+ return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
6453
+ }
6454
+ function findById(config, kind, id) {
6455
+ if (kind === "inbox") {
6456
+ for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
6457
+ alias,
6458
+ inbox
6459
+ };
6460
+ } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
6461
+ alias,
6462
+ account
6463
+ };
6464
+ return null;
6465
+ }
6466
+ /**
6467
+ * The refusal for a former name, or null when `name` is not one.
6468
+ *
6469
+ * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
6470
+ * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
6471
+ * name that now belongs to nothing.
6472
+ */
6473
+ function formerNameRefusal(config, kind, name) {
6474
+ if (config.version !== 2) return null;
6475
+ const former = own$1(config.formerNames[MAP[kind]], name);
6476
+ if (!former) return null;
6477
+ const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
6478
+ if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
6479
+ formerName: name,
6480
+ id: former.id
6481
+ } });
6482
+ return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
6483
+ hint: `Use "${current.alias}".`,
6484
+ details: {
6485
+ formerName: name,
6486
+ currentName: current.alias,
6487
+ id: former.id
6488
+ }
6489
+ });
6490
+ }
6491
+ /**
6492
+ * Whether `name` may be given to a new or renamed account of this kind and platform.
6493
+ *
6494
+ * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
6495
+ * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
6496
+ * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
6497
+ * either kind.
5007
6498
  *
5008
- * `only`, when given, is the most it may remove: the runtimes a person approved removing, from a dry run shown to
5009
- * them. Anything else it would have removed is kept, and says why. Removal is approved as a list of paths, and a
5010
- * runtime that became unused after that list was shown is not on it.
6499
+ * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
6500
+ * again for version 2, because anything can happen between asking and writing.
5011
6501
  */
5012
- async function pruneManagedRuntimes(context, product, options = {}) {
5013
- const result = {
5014
- runtimeDir: join(context.core.paths.dataDir, "runtime"),
5015
- dryRun: options.dryRun === true,
5016
- removed: [],
5017
- kept: []
5018
- };
5019
- const candidates = [];
5020
- for (const { path, version } of await listManagedRuntimes(context.core.paths.dataDir, product.packageName)) {
5021
- const real = await realpath(path).catch(() => path);
5022
- candidates.push({
5023
- path,
5024
- version,
5025
- aliases: [.../* @__PURE__ */ new Set([path, real])]
5026
- });
6502
+ function nameAvailable(config, kind, name, platform) {
6503
+ const refuse = (error) => ({
6504
+ ok: false,
6505
+ error
6506
+ });
6507
+ if (config.version === 1) {
6508
+ if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
6509
+ if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
6510
+ if (kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.inboxes, name) ?? own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
6511
+ return { ok: true };
5027
6512
  }
5028
- if (candidates.length === 0) return result;
5029
- const refuse = (why) => {
5030
- result.refused = why;
5031
- result.kept = candidates.map(({ path, version }) => ({
5032
- path,
5033
- version,
5034
- reason: "not checked"
6513
+ const problem = nameShapeProblem(name, platform);
6514
+ if (problem) return refuse(new CommsError("USAGE", problem));
6515
+ if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
6516
+ for (const map of ["inboxes", "accounts"]) {
6517
+ const former = own$1(config.formerNames[map], name);
6518
+ if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
6519
+ hint: "A former name keeps pointing people at the account that had it. Choose another name.",
6520
+ details: {
6521
+ formerName: name,
6522
+ id: former.id
6523
+ }
5035
6524
  }));
5036
- return result;
5037
- };
5038
- const processes = await (options.processes ?? runningCommandLines)();
5039
- if (processes === null) return refuse("the running processes on this machine could not be listed, so none of these can be shown unused");
5040
- const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
5041
- const handedOut = await readHandedOut(ledger);
5042
- if (handedOut === null) return refuse(`${ledger} could not be read, so none of these can be shown never to have been handed out`);
5043
- const recorded = handedOut.flatMap((record) => record.config ? [{
5044
- client: record.client,
5045
- path: record.config
5046
- }] : []);
5047
- const scan = await scanRegisteredServers(context.env, process.platform, recorded);
5048
- if (scan.unreadable.length > 0) return refuse(`${scan.unreadable.map((file) => `${file.path} (${file.reason})`).join("; ")} could not be read, so none of these can be shown unregistered`);
5049
- const registered = scan.servers;
5050
- const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
5051
- const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
5052
- const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
5053
- for (const { path, version, aliases } of candidates) {
5054
- const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
5055
- const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
5056
- const reason = path === current ? "this release" : owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null;
5057
- if (reason) {
5058
- result.kept.push({
5059
- path,
5060
- version,
5061
- reason
5062
- });
5063
- continue;
5064
- }
5065
- if (options.only !== void 0 && !options.only.includes(path)) {
5066
- result.kept.push({
5067
- path,
5068
- version,
5069
- reason: "not in the removal that was approved; prune again to include it"
5070
- });
5071
- continue;
5072
- }
5073
- if (!result.dryRun) await rm(path, { recursive: true });
5074
- result.removed.push({
5075
- path,
5076
- version
5077
- });
5078
- }
5079
- if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
5080
- const gone = result.removed.map((item) => item.path);
5081
- const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
5082
- if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
5083
- }
5084
- return result;
5085
- }
5086
- /** Every runtime the installer handed out, or null when the record is there and cannot be read. */
5087
- async function readHandedOut(path) {
5088
- let text;
5089
- try {
5090
- text = await readFile(path, "utf8");
5091
- } catch (error) {
5092
- return error.code === "ENOENT" ? [] : null;
5093
- }
5094
- const records = [];
5095
- for (const line of text.split("\n")) {
5096
- if (!line.trim()) continue;
5097
- try {
5098
- const record = JSON.parse(line);
5099
- if (typeof record.runtime !== "string") return null;
5100
- records.push({
5101
- at: String(record.at ?? ""),
5102
- client: String(record.client ?? ""),
5103
- name: String(record.name ?? ""),
5104
- runtime: record.runtime,
5105
- ...typeof record.config === "string" ? { config: record.config } : {},
5106
- ...record.applied === true ? { applied: true } : {}
5107
- });
5108
- } catch {
5109
- return null;
5110
- }
5111
6525
  }
5112
- return records;
6526
+ return { ok: true };
5113
6527
  }
5114
6528
  /**
5115
6529
  * A pattern for each package a channel's manifest names as a rival (`rivals.packages`).
@@ -5134,22 +6548,31 @@ function escapeRegExp(text) {
5134
6548
  /**
5135
6549
  * The command that removes a registered server, in its client's own terms — the name as the client's file has it,
5136
6550
  * which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
5137
- * (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
6551
+ * (`quoteCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
6552
+ *
6553
+ * The client's own command, so an external one (`externalCommand`). A name that is also one of this suite's commands,
6554
+ * or a path into one of its packages, is refused there, since such words are how a suite command would get round the
6555
+ * locator; that entry is removed by hand instead, from the file that holds it.
5138
6556
  */
5139
- function removalCommand(client, name, platform) {
5140
- return commandText(shellCommand([
5141
- client,
5142
- "mcp",
5143
- "remove",
5144
- name
5145
- ], platform));
6557
+ function removalCommand(client, server, platform) {
6558
+ try {
6559
+ return remedy(externalCommand([
6560
+ client,
6561
+ "mcp",
6562
+ "remove",
6563
+ server.name
6564
+ ], "the MCP client's own command removes an entry from its own configuration", platform));
6565
+ } catch {
6566
+ return remedy(`remove "${server.name}" from ${server.path} by hand, then restart ${server.client}`);
6567
+ }
5146
6568
  }
5147
- function gmailRemoval({ client, name: server, path, scope }, platform) {
5148
- if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
6569
+ function gmailRemoval(registered, platform) {
6570
+ const { client, name: server, path, scope } = registered;
6571
+ if (scope === "project") return remedy(`remove "${server}" from the project entry in ${path} by hand`);
5149
6572
  switch (client) {
5150
- case "claude-code": return removalCommand("claude", server, platform);
5151
- case "codex": return removalCommand("codex", server, platform);
5152
- default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
6573
+ case "claude-code": return removalCommand("claude", registered, platform);
6574
+ case "codex": return removalCommand("codex", registered, platform);
6575
+ default: return remedy(`remove the "${server}" entry from ${path}, then restart ${client}`);
5153
6576
  }
5154
6577
  }
5155
6578
  /**
@@ -5160,7 +6583,7 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
5160
6583
  const patterns = rivalPatterns(packages);
5161
6584
  const findings = [];
5162
6585
  for (const server of servers) {
5163
- const line = [server.command, ...server.args].join(" ");
6586
+ const line = [server.command, ...withoutPathOptions(server.args).words].join(" ");
5164
6587
  const known = patterns.find((candidate) => candidate.pattern.test(line));
5165
6588
  if (!known) continue;
5166
6589
  findings.push({
@@ -5185,7 +6608,7 @@ function findRivalWordServers(servers, word, product) {
5185
6608
  return servers.filter((server) => !isProductServer(server, product) && pattern.test([
5186
6609
  server.name,
5187
6610
  server.command,
5188
- ...server.args,
6611
+ ...withoutPathOptions(server.args).words,
5189
6612
  server.url ?? ""
5190
6613
  ].join(" ")));
5191
6614
  }
@@ -5351,10 +6774,6 @@ function renderPrune(result, color) {
5351
6774
  if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
5352
6775
  return lines.join("\n");
5353
6776
  }
5354
- /** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
5355
- function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
5356
- return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
5357
- }
5358
6777
  /**
5359
6778
  * The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
5360
6779
  * touches.
@@ -5466,7 +6885,7 @@ async function prepareChange(core, request, options) {
5466
6885
  change: binding
5467
6886
  }),
5468
6887
  expiresAt: record.expiresAt,
5469
- next: nextStep(record.approvalId, policy, options)
6888
+ next: nextStep(record.approvalId, policy, core, options)
5470
6889
  };
5471
6890
  } catch (error) {
5472
6891
  await auditRefusal(core, "change.prepare", error, {
@@ -5498,8 +6917,9 @@ async function claimChange(core, approvalId, expect, options) {
5498
6917
  change: binding,
5499
6918
  policy
5500
6919
  }, {
5501
- pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
5502
- platform: options.platform
6920
+ pendingHint: handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`),
6921
+ platform: options.platform,
6922
+ handoffs: core.handoffs
5503
6923
  });
5504
6924
  const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
5505
6925
  await auditChange(core, {
@@ -5517,7 +6937,11 @@ async function claimChange(core, approvalId, expect, options) {
5517
6937
  paths: binding.loosened.map((loosening) => loosening.path),
5518
6938
  changes: binding.loosened
5519
6939
  };
5520
- } catch (error) {
6940
+ } catch (caught) {
6941
+ const error = isMisdirectedSend(caught) ? new CommsError(caught.code, caught.message, {
6942
+ hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId),
6943
+ ...caught.details === void 0 ? {} : { details: caught.details }
6944
+ }) : caught;
5521
6945
  await auditRefusal(core, "change.claim", error, {
5522
6946
  surface: options.surface,
5523
6947
  approvalId,
@@ -5528,11 +6952,11 @@ async function claimChange(core, approvalId, expect, options) {
5528
6952
  }
5529
6953
  }
5530
6954
  /** A change approval, checked to be one and to describe the change its digest binds. */
5531
- async function changeRecord(core, approvalId) {
6955
+ async function changeRecord(core, approvalId, options = {}) {
5532
6956
  const record = await core.approvals.get(approvalId);
5533
6957
  if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
5534
6958
  if (approvalKind(record) === "download") throw new CommsError("USAGE", `${approvalId} is a question about where to save files, not a configuration change`, { hint: DOWNLOAD_ANSWER_HINT });
5535
- if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: `Approve it with the command that prepared it: ${channelApproveCommands({ sending: true })}.` });
6959
+ if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId) });
5536
6960
  if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
5537
6961
  hint: "Nothing was approved. Prepare the change again.",
5538
6962
  details: { approvalId }
@@ -5547,7 +6971,7 @@ async function changeRecord(core, approvalId) {
5547
6971
  async function beginChangeApproval(core, approvalId, options) {
5548
6972
  let record;
5549
6973
  try {
5550
- record = await changeRecord(core, approvalId);
6974
+ record = await changeRecord(core, approvalId, options);
5551
6975
  const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
5552
6976
  return {
5553
6977
  approvalId,
@@ -5568,7 +6992,7 @@ async function beginChangeApproval(core, approvalId, options) {
5568
6992
  async function finishChangeApproval(core, approvalId, answer, options) {
5569
6993
  let record;
5570
6994
  try {
5571
- record = await changeRecord(core, approvalId);
6995
+ record = await changeRecord(core, approvalId, options);
5572
6996
  const digest = changeDigest(record.change);
5573
6997
  const approved = await core.approvals.approve(approvalId, "terminal", {
5574
6998
  draftMessageId: digest,
@@ -5629,8 +7053,8 @@ async function revokeChange(core, approvalId, reason, options) {
5629
7053
  function changePolicyOf(record) {
5630
7054
  return record.requiredPolicy === "chat" ? "chat" : "confirm";
5631
7055
  }
5632
- function nextStep(approvalId, policy, options) {
5633
- return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
7056
+ function nextStep(approvalId, policy, core, options) {
7057
+ return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `The change policy is confirm: ask the user to run ${command} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`);
5634
7058
  }
5635
7059
  /**
5636
7060
  * One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
@@ -5790,13 +7214,11 @@ async function gatedChange(core, change, options) {
5790
7214
  status: "approval-required",
5791
7215
  prepared: await prepareChange(core, request, {
5792
7216
  surface: options.surface,
5793
- approveCommand: options.approveCommand,
5794
7217
  platform: options.platform
5795
7218
  })
5796
7219
  };
5797
7220
  const consent = await claimChange(core, options.approvalId, request, {
5798
7221
  surface: options.surface,
5799
- approveCommand: options.approveCommand,
5800
7222
  platform: options.platform
5801
7223
  });
5802
7224
  return {
@@ -5814,11 +7236,9 @@ async function gatedChange(core, change, options) {
5814
7236
  */
5815
7237
  async function gatedChangeAtTerminal(core, change, options) {
5816
7238
  const streams = options.streams ?? defaultStreams;
5817
- const { approveCommand } = options;
5818
7239
  const first = await gatedChange(core, change, {
5819
7240
  surface: "cli",
5820
7241
  approvalId: options.approvalId,
5821
- approveCommand,
5822
7242
  platform: options.output.platform
5823
7243
  });
5824
7244
  if (first.status === "applied") return first.result;
@@ -5826,9 +7246,9 @@ async function gatedChangeAtTerminal(core, change, options) {
5826
7246
  if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
5827
7247
  if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
5828
7248
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
5829
- const { command } = options;
7249
+ const handoffs = requireHandoffs(core, options.output.platform);
5830
7250
  throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
5831
- hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
7251
+ hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, handoffs[options.rerunOn ?? "own"](insertWordsBeforeSentinel(options.rerun, ...carrying)), handoffs),
5832
7252
  details: {
5833
7253
  approvalId: prepared.approvalId,
5834
7254
  policy: prepared.policy,
@@ -5849,7 +7269,6 @@ async function gatedChangeAtTerminal(core, change, options) {
5849
7269
  const second = await gatedChange(core, change, {
5850
7270
  surface: "cli",
5851
7271
  approvalId: prepared.approvalId,
5852
- approveCommand,
5853
7272
  platform: options.output.platform
5854
7273
  });
5855
7274
  if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
@@ -5862,9 +7281,13 @@ async function gatedChangeAtTerminal(core, change, options) {
5862
7281
  * Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
5863
7282
  * its registration step and says why in its report — so the two say it in the same words.
5864
7283
  */
5865
- function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
5866
- const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
5867
- return prepared.policy === "confirm" ? `Show the person the preview. They run ${inlineCommand(changeApprovalCommand(approveCommand, prepared.approvalId, platform))}; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
7284
+ function approvalHint(prepared, rerun, maker) {
7285
+ const run = isCommand(rerun) ? inlineCommand(rerun) : null;
7286
+ const rerunSaid = isCommand(rerun) ? "" : rerun.message;
7287
+ if (prepared.policy !== "confirm") return run === null ? `Show the person the preview. ${rerunSaid}` : `Show the person the preview. Once they say yes, run ${run}.`;
7288
+ const approve = maker.own(["approve", prepared.approvalId]);
7289
+ if (isCommand(approve) && run !== null) return `Show the person the preview. They run ${inlineCommand(approve)}; then run ${run}.`;
7290
+ return `Show the person the preview. ${handoffSentence(approve, (command) => `They run ${command}.`)} ${run === null ? rerunSaid : `Then run ${run}.`}`;
5868
7291
  }
5869
7292
  /**
5870
7293
  * Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
@@ -5884,11 +7307,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
5884
7307
  refuseUnlessPerson(env, streams, {
5885
7308
  refusedToAgent: "only a person can approve a change, not an agent",
5886
7309
  refusedWithoutTerminal: "approving a change needs an interactive terminal",
5887
- command: commandText(shellCommand([
5888
- "agentcomms",
5889
- "approve",
5890
- approvalId
5891
- ], output.platform)),
7310
+ command: requireHandoffs(core, output.platform).own(["approve", approvalId]),
5892
7311
  color: output.color,
5893
7312
  json: output.json
5894
7313
  });
@@ -6074,11 +7493,17 @@ const KEYCHAIN_SERVICE = "agent-communications";
6074
7493
  * answer an OS prompt, so it must not hang.
6075
7494
  */
6076
7495
  const KEYCHAIN_TIMEOUT_MS = 12e3;
7496
+ /** "Run the doctor": core's, located from whatever is printing, or why there is none here. */
7497
+ function doctorSentence(handoffs, say) {
7498
+ return handoffSentence(requiredHandoffs(handoffs).core(["doctor"]), say, { instead: "Call comms_doctor from a chat." });
7499
+ }
6077
7500
  var FileSecretStore = class {
6078
7501
  kind = "file";
6079
7502
  directory;
6080
- constructor(directory) {
7503
+ #handoffs;
7504
+ constructor(directory, options = {}) {
6081
7505
  this.directory = directory;
7506
+ this.#handoffs = options.handoffs;
6082
7507
  }
6083
7508
  #path(ref) {
6084
7509
  return join(this.directory, `${createHash("sha256").update(ref).digest("hex").slice(0, 32)}.json`);
@@ -6098,7 +7523,7 @@ var FileSecretStore = class {
6098
7523
  } catch (error) {
6099
7524
  if (error.code === "ENOENT") return null;
6100
7525
  throw new CommsError("SECRET_STORE_UNAVAILABLE", "a stored secret file could not be read", {
6101
- hint: "Run `agentcomms doctor`; re-authorise the inbox if the file is damaged.",
7526
+ hint: `${doctorSentence(this.#handoffs, (command) => `Run ${command}.`)} Re-authorise the inbox if the file is damaged.`,
6102
7527
  cause: error
6103
7528
  });
6104
7529
  }
@@ -6133,10 +7558,10 @@ async function loadKeyringModule() {
6133
7558
  var KeychainTimeout = class extends Error {
6134
7559
  name = "KeychainTimeout";
6135
7560
  };
6136
- function keychainError(action, cause, timeoutMs) {
7561
+ function keychainError(action, cause, timeoutMs, handoffs) {
6137
7562
  const timedOut = cause instanceof KeychainTimeout;
6138
7563
  return new CommsError(timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "SECRET_STORE_UNAVAILABLE", timedOut ? `the system keychain did not answer within ${Math.round(timeoutMs / 1e3)}s while trying to ${action}` : `the system keychain refused to ${action} (it may be locked, or access was denied)`, {
6139
- hint: timedOut ? "Look for a system dialog asking to allow access to the keychain, then retry. Run `agentcomms doctor` for details." : "Unlock the keychain and retry, or run `agentcomms doctor`. On macOS a Node upgrade can require allowing access again.",
7564
+ hint: timedOut ? `Look for a system dialog asking to allow access to the keychain, then retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)}` : `Unlock the keychain and retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)} On macOS a Node upgrade can require allowing access again.`,
6140
7565
  details: { reason: timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "KEYCHAIN_UNAVAILABLE" },
6141
7566
  cause
6142
7567
  });
@@ -6161,6 +7586,7 @@ var KeychainSecretStore = class {
6161
7586
  #module;
6162
7587
  #timeoutMs;
6163
7588
  #namespace;
7589
+ #handoffs;
6164
7590
  /** One keychain call at a time: parallel calls would each raise their own OS prompt and exhaust the thread pool. */
6165
7591
  #queue = Promise.resolve();
6166
7592
  /**
@@ -6174,17 +7600,18 @@ var KeychainSecretStore = class {
6174
7600
  * @param namespace keeps entries of different config directories apart (a test run or a second setup must never
6175
7601
  * read or overwrite another's `client:default:secret`); see {@link keychainNamespace}.
6176
7602
  */
6177
- constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
7603
+ constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS, options = {}) {
6178
7604
  this.#module = module;
6179
7605
  this.#namespace = namespace;
6180
7606
  this.#timeoutMs = timeoutMs;
7607
+ this.#handoffs = options.handoffs;
6181
7608
  }
6182
7609
  #entry(ref) {
6183
7610
  return new this.#module.AsyncEntry(KEYCHAIN_SERVICE, `${this.#namespace}:${ref}`, { linux: { store: "secret-service" } });
6184
7611
  }
6185
7612
  #serial(action, fn) {
6186
7613
  const run = this.#queue.then(async () => {
6187
- if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs);
7614
+ if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs, this.#handoffs);
6188
7615
  const native = fn();
6189
7616
  try {
6190
7617
  return await raceTimeout(native, this.#timeoutMs);
@@ -6196,7 +7623,7 @@ var KeychainSecretStore = class {
6196
7623
  if (this.#stuck === stuck) this.#stuck = null;
6197
7624
  });
6198
7625
  }
6199
- throw keychainError(action, error, this.#timeoutMs);
7626
+ throw keychainError(action, error, this.#timeoutMs, this.#handoffs);
6200
7627
  }
6201
7628
  });
6202
7629
  this.#queue = run.catch(() => void 0);
@@ -6236,10 +7663,11 @@ function keychainNamespace(configDir) {
6236
7663
  }
6237
7664
  /** Opens the backend recorded in config for an inbox or client. */
6238
7665
  async function openSecretStore(kind, options) {
6239
- if (kind === "file") return new FileSecretStore(options.secretsDir);
7666
+ const told = { handoffs: options.handoffs };
7667
+ if (kind === "file") return new FileSecretStore(options.secretsDir, told);
6240
7668
  const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
6241
7669
  if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
6242
- return new KeychainSecretStore(keyring, options.namespace);
7670
+ return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
6243
7671
  }
6244
7672
  var InboxStateStore = class {
6245
7673
  directory;
@@ -6273,25 +7701,37 @@ var InboxStateStore = class {
6273
7701
  }
6274
7702
  };
6275
7703
  function openCore(options = {}) {
6276
- const paths = resolvePaths(options);
7704
+ const { paths, pathOverrides } = resolvePathIdentity(options);
6277
7705
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
6278
- const config = new ConfigStore(paths.configDir);
7706
+ const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
7707
+ caller: options.caller,
7708
+ paths,
7709
+ platform: options.platform,
7710
+ env: options.env
7711
+ });
7712
+ const config = new ConfigStore(paths.configDir, { handoffs });
6279
7713
  let cached = null;
6280
7714
  return {
6281
7715
  paths,
7716
+ pathOverrides,
6282
7717
  config,
6283
7718
  states: new InboxStateStore(paths.stateDir),
6284
- approvals: new ApprovalStore(paths.stateDir, { now }),
7719
+ approvals: new ApprovalStore(paths.stateDir, {
7720
+ now,
7721
+ handoffs
7722
+ }),
6285
7723
  ledger: new SendLedger(paths.stateDir, now),
6286
7724
  plans: new PlanStore(paths.stateDir, now),
6287
7725
  taint: new TaintStore(paths.stateDir, now),
6288
7726
  audit: new AuditLog(paths.stateDir, now),
7727
+ handoffs,
6289
7728
  async secrets(kind) {
6290
7729
  const chosen = kind ?? secretsStoreOf(await config.load());
6291
7730
  if (cached?.kind === chosen) return cached.store;
6292
7731
  const store = await openSecretStore(chosen, {
6293
7732
  secretsDir: paths.secretsDir,
6294
- namespace: keychainNamespace(paths.configDir)
7733
+ namespace: keychainNamespace(paths.configDir),
7734
+ handoffs
6295
7735
  });
6296
7736
  cached = {
6297
7737
  kind: chosen,
@@ -6491,81 +7931,6 @@ strictObject({
6491
7931
  sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
6492
7932
  });
6493
7933
  /**
6494
- * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
6495
- * to decide whether the release it last heard of is newer than the one running.
6496
- *
6497
- * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
6498
- * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
6499
- * all of it, so nothing that imported these from there changes.
6500
- */
6501
- /**
6502
- * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
6503
- *
6504
- * Anything the registry answers is checked against this before it is used, because a version goes into a directory
6505
- * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
6506
- * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
6507
- */
6508
- const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
6509
- function isVersion(value) {
6510
- return typeof value === "string" && VERSION_PATTERN.test(value);
6511
- }
6512
- /** Two numeric strings with no leading zeros, compared as numbers of any size. */
6513
- function compareNumeric(a, b) {
6514
- if (a.length !== b.length) return a.length < b.length ? -1 : 1;
6515
- return a < b ? -1 : a > b ? 1 : 0;
6516
- }
6517
- /**
6518
- * Semver precedence: -1, 0 or 1, or null when either is not a version.
6519
- *
6520
- * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
6521
- * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
6522
- * few packages as it can.
6523
- */
6524
- function compareVersions(a, b) {
6525
- const left = VERSION_PATTERN.exec(a);
6526
- const right = VERSION_PATTERN.exec(b);
6527
- if (!left || !right) return null;
6528
- for (const index of [
6529
- 1,
6530
- 2,
6531
- 3
6532
- ]) {
6533
- const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
6534
- if (order !== 0) return order < 0 ? -1 : 1;
6535
- }
6536
- const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
6537
- const [ours, theirs] = [pre(left), pre(right)];
6538
- if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
6539
- for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
6540
- const x = ours[index];
6541
- const y = theirs[index];
6542
- if (x === void 0) return -1;
6543
- if (y === void 0) return 1;
6544
- const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
6545
- if (numericX && numericY) {
6546
- const order = compareNumeric(x, y);
6547
- if (order !== 0) return order < 0 ? -1 : 1;
6548
- } else if (numericX !== numericY) return numericX ? -1 : 1;
6549
- else if (x !== y) return x < y ? -1 : 1;
6550
- }
6551
- return 0;
6552
- }
6553
- /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
6554
- function isBehind(version, latest) {
6555
- if (version === latest) return false;
6556
- const order = compareVersions(version, latest);
6557
- return order === null ? true : order < 0;
6558
- }
6559
- /**
6560
- * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
6561
- * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
6562
- */
6563
- function isPrerelease(version) {
6564
- return VERSION_PATTERN.exec(version)?.[4] !== void 0;
6565
- }
6566
- /** The package version, read from package.json at build time. */
6567
- const VERSION = "0.13.0";
6568
- /**
6569
7934
  * Registering, pruning and listing the MCP servers of every channel, from the core.
6570
7935
  *
6571
7936
  * `agentcomms mcp install` registers the core server; `comms_server_install` registers any channel's; and
@@ -6765,7 +8130,9 @@ function serverInstallChange(core, env, request, own) {
6765
8130
  checkPin(config, request);
6766
8131
  const product = await productNow();
6767
8132
  const { version } = product;
6768
- const preflight = await preflightInstall(context, product, installOptions(request));
8133
+ const preflight = await preflightInstall(context, product, installOptions(request)).catch(async (error) => {
8134
+ throw await remedyLocated(error, core);
8135
+ });
6769
8136
  const { target, previous, effective, kept, command } = preflight;
6770
8137
  planned = {
6771
8138
  product,
@@ -6796,7 +8163,9 @@ function serverInstallChange(core, env, request, own) {
6796
8163
  },
6797
8164
  apply: async () => {
6798
8165
  if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
6799
- const result = await mcpInstall(context, planned.product, installOptions(request), planned.install);
8166
+ const result = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
8167
+ throw await remedyLocated(error, core);
8168
+ });
6800
8169
  return {
6801
8170
  ...result,
6802
8171
  restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
@@ -6805,6 +8174,23 @@ function serverInstallChange(core, env, request, own) {
6805
8174
  };
6806
8175
  }
6807
8176
  /**
8177
+ * A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
8178
+ * the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
8179
+ * installer says only the words; it may not import the locator.
8180
+ */
8181
+ async function remedyLocated(error, core) {
8182
+ const remedy = installRemedyOf(error);
8183
+ if (remedy === void 0 || !(error instanceof CommsError)) return error;
8184
+ const channel = CHANNELS.find((each) => channelServer(each).binary === remedy.binary);
8185
+ if (channel === void 0) return error;
8186
+ const maker = await requireHandoffs(core).registered();
8187
+ return new CommsError(error.code, error.message, {
8188
+ hint: handoffSentence(maker.of(channel, remedy.words), remedy.say),
8189
+ ...error.details === void 0 ? {} : { details: error.details },
8190
+ ...error.cause === void 0 ? {} : { cause: error.cause }
8191
+ });
8192
+ }
8193
+ /**
6808
8194
  * Removing a channel's unused managed runtimes, as a change.
6809
8195
  *
6810
8196
  * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
@@ -6822,7 +8208,7 @@ function serverPruneChange(core, env, request, own) {
6822
8208
  };
6823
8209
  const { packageName, version } = ownProduct(request.channel, own) ?? {
6824
8210
  packageName: channelServer(request.channel).packageName,
6825
- version: "0.13.0"
8211
+ version: "0.13.1"
6826
8212
  };
6827
8213
  const product = {
6828
8214
  packageName,
@@ -7057,40 +8443,52 @@ async function pendingUpdate(options) {
7057
8443
  };
7058
8444
  return verdict;
7059
8445
  }
7060
- /**
7061
- * The two ways on, as a tool call and as a command — and the command through npx, for a machine with no `agentcomms`
7062
- * installed: one that runs only a plugin's server, say, has neither the core server nor the command.
7063
- */
7064
- const UPDATE_WAYS = Object.freeze({
7065
- update: {
7066
- tool: "comms_update",
7067
- command: "agentcomms update",
7068
- npx: "npx -y @agentcomms/core@latest update"
7069
- },
7070
- later: {
7071
- tool: "comms_update",
7072
- arguments: { later: true },
7073
- command: "agentcomms update --later",
7074
- npx: "npx -y @agentcomms/core@latest update --later"
7075
- }
7076
- });
8446
+ function updateCommands(core, platform) {
8447
+ const handoffs = requireHandoffs(core, platform);
8448
+ return {
8449
+ update: handoffs.core(["update"]),
8450
+ later: handoffs.core(["update", "--later"])
8451
+ };
8452
+ }
8453
+ /** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
8454
+ function updateCommandSaid(commands, which, say) {
8455
+ return handoffSentence(commands[which], say);
8456
+ }
8457
+ function updateWaysOf(commands) {
8458
+ return {
8459
+ update: {
8460
+ tool: "comms_update",
8461
+ command: commands.update
8462
+ },
8463
+ later: {
8464
+ tool: "comms_update",
8465
+ arguments: { later: true },
8466
+ command: commands.later
8467
+ }
8468
+ };
8469
+ }
8470
+ /** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
8471
+ function labelOf(channel) {
8472
+ return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
8473
+ }
7077
8474
  /**
7078
8475
  * What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
7079
8476
  * two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
7080
8477
  */
7081
- function updateStopMessage(pending, where) {
8478
+ function updateStopMessage(pending, where, commands) {
8479
+ const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
7082
8480
  const didNotRun = `Nothing was done: ${where.tool} did not run.`;
7083
- const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. At a terminal: \`${UPDATE_WAYS.later.command}\` (\`${UPDATE_WAYS.later.npx}\` where agentcomms is not installed).`;
8481
+ const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. ${updateCommandSaid(commands, "later", (command) => `At a terminal: ${command}.`)}`;
7084
8482
  if (pending.kind === "restart") return [
7085
8483
  "Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
7086
- `This is ${where.server} ${pending.running}; ${pending.latest} is installed on this machine — every registration of this server names it — and a client starts it only once it is restarted. ${didNotRun}`,
8484
+ `This is ${server}, ${pending.running}; ${pending.latest} is installed on this machine — every registration of this server names it — and a client starts it only once it is restarted. ${didNotRun}`,
7087
8485
  `Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
7088
8486
  ].join("\n");
7089
8487
  return [
7090
8488
  UPDATE_FIRST,
7091
- `This is ${where.server} ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
8489
+ `This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
7092
8490
  "Ask the person which they want:",
7093
- `- Update now: call comms_update on the agentcomms (core) server. It shows every step and asks before it changes anything; restart the client after. At a terminal: \`${UPDATE_WAYS.update.command}\` (\`${UPDATE_WAYS.update.npx}\` where agentcomms is not installed). A server comms_update does not find registered here — a plugin's, an extension's — is updated where it was installed.`,
8491
+ `- Update now: call comms_update on the core server. It shows every step and asks before it changes anything; restart the client after. ${updateCommandSaid(commands, "update", (command) => `At a terminal: ${command}.`)} A server comms_update does not find registered here — a plugin's, an extension's — is updated where it was installed.`,
7094
8492
  `- ${later}`
7095
8493
  ].join("\n");
7096
8494
  }
@@ -7117,7 +8515,6 @@ const CHANNEL_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(c
7117
8515
  const SERVER_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(channel).npxPackage).filter((name) => !CHANNEL_PACKAGES.includes(name)));
7118
8516
  Object.freeze([.../* @__PURE__ */ new Set([...CHANNEL_PACKAGES, ...SERVER_PACKAGES])].sort());
7119
8517
  channelServer("core").packageName;
7120
- listed(CHANNELS.map((channel) => `\`${channelServer(channel).binary} mcp prune\``), "and");
7121
8518
  /**
7122
8519
  * "Not now": no server or command on this machine stops for the update until the next local midnight.
7123
8520
  *
@@ -7225,8 +8622,9 @@ function updateToolGate(options) {
7225
8622
  if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
7226
8623
  return stoppedCall(pending, {
7227
8624
  server: options.server,
8625
+ channel: options.channel,
7228
8626
  tool
7229
- });
8627
+ }, updateCommands(options.core));
7230
8628
  };
7231
8629
  }
7232
8630
  /**
@@ -7234,15 +8632,17 @@ function updateToolGate(options) {
7234
8632
  * a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
7235
8633
  * `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
7236
8634
  */
7237
- function stoppedCall(pending, where) {
7238
- const message = updateStopMessage(pending, where);
8635
+ function stoppedCall(pending, where, commands) {
8636
+ const message = updateStopMessage(pending, where, commands);
8637
+ const ways = updateWaysOf(commands);
8638
+ const leaving = (value) => JSON.parse(JSON.stringify(value));
7239
8639
  return {
7240
8640
  isError: true,
7241
8641
  content: [{
7242
8642
  type: "text",
7243
8643
  text: message
7244
8644
  }],
7245
- structuredContent: { error: {
8645
+ structuredContent: leaving({ error: {
7246
8646
  code: "UPDATE_REQUIRED",
7247
8647
  message,
7248
8648
  hint: pending.kind === "restart" ? "Restart the MCP client, or put the stop off until midnight with comms_update `later: true` once the person agrees." : "Update with comms_update (the person approves its preview), or put it off until midnight with comms_update `later: true` once the person agrees.",
@@ -7252,10 +8652,10 @@ function stoppedCall(pending, where) {
7252
8652
  running: pending.running,
7253
8653
  latest: pending.latest,
7254
8654
  installed: pending.kind === "restart",
7255
- update: UPDATE_WAYS.update,
7256
- later: UPDATE_WAYS.later
8655
+ update: ways.update,
8656
+ later: ways.later
7257
8657
  }
7258
- } }
8658
+ } })
7259
8659
  };
7260
8660
  }
7261
8661
  /** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
@@ -7350,19 +8750,21 @@ async function updateGateAtTerminal(options) {
7350
8750
  ...where
7351
8751
  });
7352
8752
  if (pending === null) return null;
7353
- if (!(agentMarker(env) === null && canPrompt(env, streams, {
8753
+ const person = agentMarker(env) === null && canPrompt(env, streams, {
7354
8754
  json: output.json,
7355
8755
  noInput: options.noInput === true
7356
- }))) throw updateRequired(pending, options.binary);
8756
+ });
8757
+ const commands = updateCommands(core, output.platform);
8758
+ if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
7357
8759
  const bold = (word) => paint(output.color, "bold", word);
7358
8760
  const answer = (await askLine(streams, pending.kind === "restart" ? `${pending.latest} is installed (this is ${pending.running}). Switch ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? ` : `${pending.latest} is out (you have ${pending.running}). Update ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? `)).trim().toLowerCase();
7359
8761
  if (answer === "now" || answer === "update") {
7360
8762
  if (pending.kind === "restart") {
7361
- streams.stdout.write(`${pending.latest} is installed globally; this ${options.binary} is ${pending.running}, started from somewhere else — npx's cache, a project's own install, a checkout. Run your command again from the installed one. ${options.binary} did not run.\n`);
8763
+ streams.stdout.write(`${pending.latest} is installed globally; this ${cliOf(options.channel)} is ${pending.running}, started from somewhere else — npx's cache, a project's own install, a checkout. Run your command again from the installed one. The command did not run.\n`);
7362
8764
  return EXIT_CODES.UPDATE;
7363
8765
  }
7364
8766
  if (!options.update) {
7365
- streams.stdout.write(`${options.binary} reads only this machine and cannot fetch the update itself. Run \`${UPDATE_WAYS.update.command}\` (\`${UPDATE_WAYS.update.npx}\` where agentcomms is not installed), then run your command again.\n`);
8767
+ streams.stdout.write(`This ${cliOf(options.channel)} reads only this machine and cannot fetch the update itself. ${updateCommandSaid(commands, "update", (command) => `Run ${command}, then run your command again.`)}\n`);
7366
8768
  return EXIT_CODES.UPDATE;
7367
8769
  }
7368
8770
  return afterUpdate(await options.update(), options, pending);
@@ -7371,39 +8773,50 @@ async function updateGateAtTerminal(options) {
7371
8773
  const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
7372
8774
  env,
7373
8775
  output,
7374
- command: UPDATE_WAYS.later.command,
7375
- approveCommand: options.approveCommand,
8776
+ rerun: ["update", "--later"],
8777
+ rerunOn: "core",
7376
8778
  answered: true,
7377
8779
  streams
7378
8780
  });
7379
8781
  streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
7380
8782
  return null;
7381
8783
  }
7382
- throw new CommsError("USAGE", `cancelled: ${options.binary} did not run, and nothing was changed`, { hint: `Update with \`${UPDATE_WAYS.update.command}\`, or put it off until tomorrow with \`${UPDATE_WAYS.later.command}\`.` });
8784
+ throw new CommsError("USAGE", "cancelled: the command did not run, and nothing was changed", { hint: bothSaid(commands, (update, later) => `Update with ${update}, or put it off until tomorrow with ${later}.`) });
8785
+ }
8786
+ /**
8787
+ * The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
8788
+ * command most people can run, and which a sentence would read as one (CUE-403).
8789
+ */
8790
+ function cliOf(channel) {
8791
+ if (channel === "core") return "core CLI";
8792
+ return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
7383
8793
  }
7384
8794
  /**
7385
8795
  * The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
7386
8796
  * — the update installed, and an older copy running — says to run the command from the installed one.
7387
8797
  */
7388
- function updateRequired(pending, binary) {
7389
- const later = `\`${UPDATE_WAYS.later.command}\` to put it off until tomorrow`;
7390
- const npx = `Where agentcomms is not installed: \`${UPDATE_WAYS.update.npx}\`, and \`${UPDATE_WAYS.later.npx}\`.`;
8798
+ function updateRequired(pending, cli, commands) {
8799
+ const ways = updateWaysOf(commands);
7391
8800
  const details = {
7392
8801
  running: pending.running,
7393
8802
  latest: pending.latest,
7394
8803
  installed: pending.kind === "restart",
7395
- update: UPDATE_WAYS.update,
7396
- later: UPDATE_WAYS.later
8804
+ update: ways.update,
8805
+ later: ways.later
7397
8806
  };
7398
- if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${binary} is ${pending.running}: run the command again from the installed one, or ${later}`, {
7399
- hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees. ${npx}`,
8807
+ if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${cli} is ${pending.running}: ${handoffSentence(commands.later, (later) => `run the command again from the installed one, or ${later} to put it off until tomorrow`)}`, {
8808
+ hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
7400
8809
  details
7401
8810
  });
7402
- return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): run \`${UPDATE_WAYS.update.command}\` first, or ${later}`, {
7403
- hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees. ${npx}`,
8811
+ return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): ${bothSaid(commands, (update, later) => `run ${update} first, or ${later} to put it off until tomorrow`)}`, {
8812
+ hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
7404
8813
  details
7405
8814
  });
7406
8815
  }
8816
+ /** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
8817
+ function bothSaid(commands, say) {
8818
+ return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
8819
+ }
7407
8820
  /**
7408
8821
  * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
7409
8822
  * the update did everything and this command's release is now installed where it runs from — its global package at
@@ -7411,8 +8824,10 @@ function updateRequired(pending, binary) {
7411
8824
  * script that goes on after `cmd && next` must not take it for the command's success.
7412
8825
  */
7413
8826
  async function afterUpdate(outcome, options, before) {
7414
- const { streams, binary } = options;
7415
- const notRun = `${binary} did not run`;
8827
+ const { streams } = options;
8828
+ const cli = cliOf(options.channel);
8829
+ const notRun = "The command did not run";
8830
+ const commands = updateCommands(options.core, options.output.platform);
7416
8831
  if (outcome === "failed") {
7417
8832
  streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
7418
8833
  return EXIT_CODES.UNAVAILABLE;
@@ -7426,10 +8841,10 @@ async function afterUpdate(outcome, options, before) {
7426
8841
  streams.stdout.write("Updated. Run your command again.\n");
7427
8842
  return EXIT_CODES.OK;
7428
8843
  }
7429
- streams.stdout.write(`${before.latest} is installed here: run your command again from it. ${notRun}.\n`);
8844
+ streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
7430
8845
  return EXIT_CODES.UPDATE;
7431
8846
  }
7432
- streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. \`${UPDATE_WAYS.update.command}\` updates what this machine registers and has installed globally — what it found is said above — and this ${binary} ${before.running} is started from somewhere else: npx's cache, a project's own install, a checkout. Run it from ${before.latest}, or put this off until tomorrow with \`${UPDATE_WAYS.later.command}\`.\n`);
8847
+ streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. The update moves what this machine registers and has installed globally — what it found is said above — and this ${cli}, ${before.running}, is started from somewhere else: npx's cache, a project's own install, a checkout. ${handoffSentence(commands.later, (later) => `Run it from ${before.latest}, or put this off until tomorrow with ${later}.`)}\n`);
7433
8848
  return EXIT_CODES.UPDATE;
7434
8849
  }
7435
8850
  async function askLine(streams, question) {
@@ -7446,7 +8861,7 @@ async function askLine(streams, question) {
7446
8861
  }
7447
8862
  channelServer("core").packageName;
7448
8863
  //#endregion
7449
- //#region ../core/dist/tool-arguments-JmEBiWPl.mjs
8864
+ //#region ../core/dist/tool-arguments-BcYu_EPp.mjs
7450
8865
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
7451
8866
  const TARGET = "draft-2020-12";
7452
8867
  /**
@@ -8450,7 +9865,7 @@ strictObject({
8450
9865
  }))) !== JSON.stringify(narrowing)) issue(["narrowing"], kept ? `${kept.label}'s server is pinned by ${narrowingWords(narrowing)}, as its entries have always been written` : `\`inbox\`, \`--read-only\` and \`workspace\` are Gmail's and Slack's; a channel after them is pinned by ${narrowingWords(narrowing)} and nothing else`);
8451
9866
  }
8452
9867
  if (isCore) {
8453
- if (manifest.binary !== CORE_BINARY) issue(["binary"], `the core's command is \`${CORE_BINARY}\``);
9868
+ if (manifest.binary !== CORE_BINARY) issue(["binary"], "is not the core's own command name");
8454
9869
  } else if (!CHANNEL_BINARY.test(manifest.binary)) issue(["binary"], "a channel's command is `agent-<something>`");
8455
9870
  (manifest.server.bins ?? []).forEach((bin, index) => {
8456
9871
  if (!CHANNEL_BINARY.test(bin)) issue([
@@ -8474,7 +9889,7 @@ strictObject({
8474
9889
  if (rivals.word === void 0 && rivals.packages === void 0) issue(["rivals"], "a word or packages");
8475
9890
  if (rivals.word === void 0 !== (rivals.can === void 0)) issue(["rivals"], "`word` and `can` come together: what a server matching the word can do unapproved");
8476
9891
  }
8477
- if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"], `the channel's own command: \`${manifest.binary} …\``);
9892
+ if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"], "must start with the channel's own command, its `binary`, and a space");
8478
9893
  if (manifest.skills) {
8479
9894
  const family = manifest.skills.prefix.slice(0, -1);
8480
9895
  if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
@@ -8765,20 +10180,25 @@ function newWhatsAppAccount(options) {
8765
10180
  * The configuration has names this channel can use.
8766
10181
  *
8767
10182
  * WhatsApp accounts are `organisation/whatsapp`, which only version 2 of the configuration names. A version-1 file is
8768
- * one an older release on this machine may still share; `agentcomms names migrate` moves it on, and until then this
8769
- * channel adds and reads nothing rather than invent a flat name for a platform that never had one.
10183
+ * one an older release on this machine may still share; core's `names migrate` moves it on, and until then this
10184
+ * channel adds and reads nothing rather than invent a flat name for a platform that never had one. The refusal names
10185
+ * core's command as `handoffs` find it: the core this package installs.
8770
10186
  */
8771
- function requireNamedConfig(config) {
8772
- if (config.version !== 2) throw new CommsError("CONFIG", "WhatsApp accounts need the organisation/platform names, and this configuration still has the old flat ones", { hint: "Run `agentcomms names migrate --dry-run` to see what everything would be called, then `agentcomms names migrate`." });
10187
+ function requireNamedConfig(config, handoffs) {
10188
+ if (config.version !== 2) throw new CommsError("CONFIG", "WhatsApp accounts need the organisation/platform names, and this configuration still has the old flat ones", { hint: handoffSentence(handoffs.core([
10189
+ "names",
10190
+ "migrate",
10191
+ "--dry-run"
10192
+ ]), (dryRun) => handoffSentence(handoffs.core(["names", "migrate"]), (migrate) => `Run ${dryRun} to see what everything would be called, then ${migrate}.`), { instead: "Call comms_names_migrate from a chat." }) });
8773
10193
  }
8774
10194
  /** Every WhatsApp account's name, sorted. Other platforms' accounts in the same map are not this channel's. */
8775
10195
  function whatsappAccountNames(config) {
8776
10196
  return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name]) => name).sort();
8777
10197
  }
8778
- function notFound(config, name) {
10198
+ function notFound(config, name, handoffs) {
8779
10199
  return () => {
8780
10200
  const known = whatsappAccountNames(config);
8781
- return new CommsError("NOT_FOUND", `no WhatsApp account called "${name}"`, { hint: known.length ? `Known accounts: ${known.join(", ")}.` : "None yet: a person adds one with `agent-whatsapp add <organisation/whatsapp>`." });
10201
+ return new CommsError("NOT_FOUND", `no WhatsApp account called "${name}"`, { hint: known.length ? `Known accounts: ${known.join(", ")}.` : handoffSentenceToFill(handoffs.own(["add"]), ["<organisation>/whatsapp"], (command) => `None yet: a person adds one with ${command}.`) });
8782
10202
  };
8783
10203
  }
8784
10204
  /**
@@ -8787,19 +10207,15 @@ function notFound(config, name) {
8787
10207
  * Through core's `resolveName`, so a former name is refused with the name the account has now. Another platform's
8788
10208
  * account under the name is "not found", as a missing one is. A record whose mode is not `read`, or whose `source`
8789
10209
  * is not a store path this package would read, is refused rather than acted on: nothing here would know what it
8790
- * means, and the vocabulary is closed (design §5).
10210
+ * means, and the vocabulary is closed (design §5). A refusal names this package's commands as `handoffs` find them.
8791
10211
  */
8792
- function requireAccount(config, name, platform = process.platform) {
10212
+ function requireAccount(config, name, handoffs) {
8793
10213
  if (name === void 0 || name === "") throw new CommsError("USAGE", "which WhatsApp account? there is no default", { hint: "Pass the account, as `organisation/whatsapp`." });
8794
- requireNamedConfig(config);
8795
- const { account } = resolveName(config, "account", name, notFound(config, name));
8796
- if (account.platform !== "whatsapp") throw notFound(config, name)();
10214
+ requireNamedConfig(config, handoffs);
10215
+ const { account } = resolveName(config, "account", name, notFound(config, name, handoffs));
10216
+ if (account.platform !== "whatsapp") throw notFound(config, name, handoffs)();
8797
10217
  const mode = account.mode ?? account.tier;
8798
- if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with ${inlineCommand(shellCommand([
8799
- "agent-whatsapp",
8800
- "remove",
8801
- name
8802
- ], platform))} and add it again.` });
10218
+ if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. ${handoffSentence(handoffs.own(["remove", name]), (command) => `Remove it with ${command} and add it again.`)}` });
8803
10219
  const source = account.source;
8804
10220
  if (source !== void 0 && (typeof source !== "string" || basename(source) !== "ChatStorage.sqlite")) throw new CommsError("CONFIG", `"${name}" names a store this package does not read`, { hint: `Only a file called ${STORE_FILE} is read. Remove the account and add it again.` });
8805
10221
  return {
@@ -9230,7 +10646,7 @@ function nodeSupportsSqlite(version = process.versions.node) {
9230
10646
  /** Refuses to start on a Node too old for `node:sqlite`, saying which Node it is and what it needs. */
9231
10647
  function requireSupportedNode(version = process.versions.node) {
9232
10648
  if (nodeSupportsSqlite(version)) return;
9233
- throw new CommsError("CONFIG", `agent-whatsapp needs Node ${MIN_NODE} or newer, and this is Node ${version}`, {
10649
+ throw new CommsError("CONFIG", `the WhatsApp channel needs Node ${MIN_NODE} or newer, and this is Node ${version}`, {
9234
10650
  hint: `It reads WhatsApp's store with Node's own SQLite (node:sqlite), which Node ${MIN_NODE} is the first to have complete. Install a newer Node — the current 22 or 24 release — and run it again.`,
9235
10651
  details: {
9236
10652
  reason: "NODE_TOO_OLD",
@@ -9253,7 +10669,7 @@ function loadSqlite() {
9253
10669
  return await import("node:sqlite");
9254
10670
  } catch (error) {
9255
10671
  throw new CommsError("CONFIG", `this Node (${process.versions.node}) has no usable node:sqlite`, {
9256
- hint: `agent-whatsapp needs Node ${MIN_NODE} or newer, run without flags that turn built-in modules off.`,
10672
+ hint: `The WhatsApp channel needs Node ${MIN_NODE} or newer, run without flags that turn built-in modules off.`,
9257
10673
  cause: error
9258
10674
  });
9259
10675
  } finally {
@@ -9450,19 +10866,22 @@ var WhatsAppIndex = class WhatsAppIndex {
9450
10866
  constructor(db) {
9451
10867
  this.#db = db;
9452
10868
  }
9453
- /** Every read opens the index through here, and cannot without the account's lists. */
9454
- static async open(directory, accountName, visibility, platform = process.platform) {
10869
+ /**
10870
+ * Every read opens the index through here, and cannot without the account's lists. A refusal names the sync to run
10871
+ * as `handoffs` find it.
10872
+ */
10873
+ static async open(directory, accountName, visibility, handoffs) {
9455
10874
  const path = join(directory, INDEX_FILE);
10875
+ const sync = () => handoffs.own([
10876
+ "sync",
10877
+ "--account",
10878
+ accountName
10879
+ ]);
9456
10880
  try {
9457
10881
  await stat(path);
9458
10882
  } catch {
9459
10883
  throw new CommsError("NOT_FOUND", `"${accountName}" has not been synced yet, so there is nothing to read`, {
9460
- hint: `Run ${inlineCommand(shellCommand([
9461
- "agent-whatsapp",
9462
- "sync",
9463
- "--account",
9464
- accountName
9465
- ], platform))} (or the whatsapp_sync tool) first.`,
10884
+ hint: handoffSentence(sync(), (command) => `Run ${command} (or the whatsapp_sync tool) first.`, { instead: "Call the whatsapp_sync tool first." }),
9466
10885
  details: { reason: "NOT_SYNCED" }
9467
10886
  });
9468
10887
  }
@@ -9470,12 +10889,7 @@ var WhatsAppIndex = class WhatsAppIndex {
9470
10889
  const db = await openDatabase(path, { readOnly: true });
9471
10890
  if (db.prepare("SELECT value FROM meta WHERE key = 'format'").get()?.value !== String(2)) {
9472
10891
  db.close();
9473
- throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run ${inlineCommand(shellCommand([
9474
- "agent-whatsapp",
9475
- "sync",
9476
- "--account",
9477
- accountName
9478
- ], platform))} to rebuild it.` });
10892
+ throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: handoffSentence(sync(), (command) => `Run ${command} to rebuild it.`, { instead: "Call the whatsapp_sync tool to rebuild it." }) });
9479
10893
  }
9480
10894
  db.function("agent_sees_chat", { deterministic: true }, (id) => visibility.seesChat(String(id)) ? 1 : 0);
9481
10895
  db.function("agent_kind", { deterministic: true }, (id) => chatKindOf(String(id)));
@@ -9752,8 +11166,8 @@ var ChatListStore = class {
9752
11166
  * so a run that stopped before the rename finishes on the next one.
9753
11167
  * - **Logged.** A line in core's audit log, and a line on stderr for the person when a command did it.
9754
11168
  *
9755
- * A version-1 configuration has no `organisation/whatsapp` names, so nothing moves until `agentcomms names migrate`
9756
- * has run; the file stays where it is and the next run tries again.
11169
+ * A version-1 configuration has no `organisation/whatsapp` names, so nothing moves until core's `names migrate` has
11170
+ * run — named as the context's handoffs find core's command — and the file stays where it is for the next run.
9757
11171
  */
9758
11172
  const SPIKE_CONFIG_FILE = "whatsapp-spike.json";
9759
11173
  const spikeAccountSchema = looseObject({
@@ -9807,7 +11221,7 @@ async function migrateSpikeAccounts(context) {
9807
11221
  const config = await context.core.config.load();
9808
11222
  if (config.version !== 2) return {
9809
11223
  ...result,
9810
- deferred: "the configuration still has the old flat names; after `agentcomms names migrate`, the spike’s accounts move on the next run"
11224
+ deferred: `the configuration still has the old flat names; ${handoffSentence(context.handoffs.core(["names", "migrate"]), (command) => `after ${command}, the spike’s accounts move on the next run`, { instead: "after comms_names_migrate, the spike’s accounts move on the next run." }).replace(/\.$/, "")}`
9811
11225
  };
9812
11226
  const moving = [];
9813
11227
  const records = Object.entries(spike.data.accounts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
@@ -9945,6 +11359,8 @@ var WhatsAppContext = class {
9945
11359
  surface;
9946
11360
  platform;
9947
11361
  core;
11362
+ /** The commands this tells a person to run, located, and quoted for this context's shell. */
11363
+ handoffs;
9948
11364
  lists;
9949
11365
  /** The name a pinned server was started for, as given. */
9950
11366
  pinned;
@@ -9957,10 +11373,14 @@ var WhatsAppContext = class {
9957
11373
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
9958
11374
  this.surface = options.surface ?? "cli";
9959
11375
  this.platform = options.platform ?? process.platform;
9960
- this.core = openCore({
11376
+ this.core = options.core ?? openCore({
9961
11377
  env: this.env,
9962
- now: this.now
11378
+ now: this.now,
11379
+ platform: this.platform,
11380
+ ...options.pathOverrides ? { pathOverrides: options.pathOverrides } : {},
11381
+ caller: CALLER
9963
11382
  });
11383
+ this.handoffs = requireHandoffs(this.core).on(this.platform);
9964
11384
  this.lists = new ChatListStore(this.core.paths.configDir);
9965
11385
  this.pinned = options.account;
9966
11386
  this.#log = options.log;
@@ -9974,12 +11394,13 @@ var WhatsAppContext = class {
9974
11394
  migration() {
9975
11395
  this.#migration ??= migrateSpikeAccounts({
9976
11396
  core: this.core,
11397
+ handoffs: this.handoffs,
9977
11398
  lists: this.lists,
9978
11399
  env: this.env,
9979
11400
  now: this.now,
9980
11401
  surface: this.surface
9981
11402
  }).then((result) => {
9982
- if (result) this.#log?.(`agent-whatsapp: ${describeMigration(result)}`);
11403
+ if (result) this.#log?.(`WhatsApp: ${describeMigration(result)}`);
9983
11404
  return result;
9984
11405
  });
9985
11406
  return this.#migration;
@@ -9997,7 +11418,7 @@ var WhatsAppContext = class {
9997
11418
  */
9998
11419
  async checkPin() {
9999
11420
  if (this.pinned === void 0) return;
10000
- this.#pinnedId = requireAccount(await this.config(), this.pinned, this.platform).account.id;
11421
+ this.#pinnedId = requireAccount(await this.config(), this.pinned, this.handoffs).account.id;
10001
11422
  }
10002
11423
  /** The name a call acts on: the one it gave, checked against the pin when there is one. */
10003
11424
  async #nameFor(config, named) {
@@ -10013,7 +11434,7 @@ var WhatsAppContext = class {
10013
11434
  /** One account, resolved and checked, with the person's lists for it — which every read applies. */
10014
11435
  async account(named) {
10015
11436
  const config = await this.config();
10016
- const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.platform);
11437
+ const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.handoffs);
10017
11438
  return {
10018
11439
  name,
10019
11440
  account,
@@ -10040,7 +11461,8 @@ var WhatsAppContext = class {
10040
11461
  return {
10041
11462
  ...this.#source,
10042
11463
  env: this.env,
10043
- isDefault
11464
+ isDefault,
11465
+ handoffs: this.handoffs
10044
11466
  };
10045
11467
  }
10046
11468
  /** The account's own directory under core's state directory: its index and its brief snapshots. */
@@ -10235,7 +11657,7 @@ async function draftMessage(context, request) {
10235
11657
  for (const { name, account, lists } of scope) {
10236
11658
  let index;
10237
11659
  try {
10238
- index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
11660
+ index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs);
10239
11661
  } catch (error) {
10240
11662
  if (scope.length > 1 && isCommsError(error) && error.details?.reason === "NOT_SYNCED") continue;
10241
11663
  throw error;
@@ -10420,7 +11842,7 @@ async function openIndex(context, accountName) {
10420
11842
  const { name, account, lists } = await context.account(accountName);
10421
11843
  return {
10422
11844
  name,
10423
- index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform)
11845
+ index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs)
10424
11846
  };
10425
11847
  }
10426
11848
  async function listChats(context, request) {
@@ -10560,7 +11982,7 @@ var SourceTimeout = class extends Error {
10560
11982
  const stuckCalls = /* @__PURE__ */ new WeakMap();
10561
11983
  function pendingError(options) {
10562
11984
  return new CommsError("TRANSIENT", "reading WhatsApp’s message store is waiting for a macOS permission dialog", {
10563
- hint: `Look for a dialog asking whether ${responsibleApp(options.env ?? {}) ?? "your terminal or MCP client"} may access data from other apps, choose Allow, then run this again. An MCP server cannot answer it: run \`agent-whatsapp status\` in a terminal once, or grant Full Disk Access.`,
11985
+ hint: `Look for a dialog asking whether ${responsibleApp(options.env ?? {}) ?? "your terminal or MCP client"} may access data from other apps, choose Allow, then run this again. ${handoffSentence(options.handoffs.own(["status"]), (command) => `An MCP server cannot answer it: run ${command} in a terminal once, or grant Full Disk Access.`, { instead: "An MCP server cannot answer it: grant Full Disk Access." })}`,
10564
11986
  details: { reason: "MACOS_PROMPT_PENDING" }
10565
11987
  });
10566
11988
  }
@@ -10600,7 +12022,11 @@ function sourceError(error, path, options) {
10600
12022
  const code = error?.code;
10601
12023
  const platform = options.platform ?? process.platform;
10602
12024
  if (code === "ENOENT" || code === "ENOTDIR") return new CommsError("NOT_FOUND", `there is no WhatsApp message store at ${path}`, {
10603
- hint: options.isDefault ? "Is WhatsApp for Mac installed and signed in here? It creates this file once it has synced. The WhatsApp Business app keeps its own, under group.net.whatsapp.WhatsAppSMB.shared: pass it with --source." : "Check the path given with --source.",
12025
+ hint: options.isDefault ? `Is WhatsApp for Mac installed and signed in here? It creates this file once it has synced. ${handoffSentenceToFill(options.handoffs.own(["add"]), [
12026
+ "<organisation>/whatsapp",
12027
+ "--source",
12028
+ "<path to its ChatStorage.sqlite>"
12029
+ ], (command) => `The WhatsApp Business app keeps its own, under group.net.whatsapp.WhatsAppSMB.shared: add it with ${command}.`, { instead: "The WhatsApp Business app keeps its own, under group.net.whatsapp.WhatsAppSMB.shared: a person adds that with add’s --source." })}` : "Check the path given with --source.",
10604
12030
  details: {
10605
12031
  reason: "NO_STORE",
10606
12032
  path
@@ -10745,7 +12171,7 @@ async function removeStaleSnapshots(workDir) {
10745
12171
  * The caller owns the result and must `dispose()` it: the copy is every message in the store, and it lives only as
10746
12172
  * long as the import that reads it.
10747
12173
  */
10748
- async function snapshotStore(storePath, workDir, options = {}) {
12174
+ async function snapshotStore(storePath, workDir, options) {
10749
12175
  checkStorePath(storePath);
10750
12176
  const attempts = options.attempts ?? 5;
10751
12177
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -10799,7 +12225,7 @@ const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "latin1");
10799
12225
  * Opening the file is what macOS's privacy check guards, so this is the cheapest honest test of access: a `stat` can
10800
12226
  * succeed where an `open` is refused. The bytes are only compared with SQLite's magic header.
10801
12227
  */
10802
- async function probeStore(storePath, options = {}) {
12228
+ async function probeStore(storePath, options) {
10803
12229
  checkStorePath(storePath);
10804
12230
  const io = options.io ?? nodeSourceIo;
10805
12231
  let header;
@@ -10860,7 +12286,7 @@ async function statusOf(context, named, check) {
10860
12286
  }
10861
12287
  let index = { synced: false };
10862
12288
  try {
10863
- const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
12289
+ const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs);
10864
12290
  try {
10865
12291
  const stats = opened.stats();
10866
12292
  index = {
@@ -10903,7 +12329,7 @@ async function whatsappStatus(context, request) {
10903
12329
  accounts,
10904
12330
  reads: READS,
10905
12331
  sends: SENDS,
10906
- ...accounts.length === 0 ? { setup: "agent-whatsapp add <organisation>/whatsapp — run by a person, in a terminal" } : {},
12332
+ ...accounts.length === 0 ? { setup: handoffSentenceToFill(context.handoffs.own(["add"]), ["<organisation>/whatsapp"], (command) => `${command} — run by a person, in a terminal`) } : {},
10907
12333
  ...spike ? { spike: describeMigration(spike) } : {}
10908
12334
  };
10909
12335
  }
@@ -10911,8 +12337,14 @@ async function whatsappStatus(context, request) {
10911
12337
  //#region src/operations/sync.ts
10912
12338
  /** Builds tried before a sync whose lists keep changing under it gives up. */
10913
12339
  const LIST_ROUNDS = 3;
10914
- function removedWhileSyncing(name) {
10915
- return new CommsError("NOT_FOUND", `"${name}" was removed while it was being synced, so nothing was indexed`, { hint: "Add it again with `agent-whatsapp add` to read it." });
12340
+ /** The account went while it synced; adding it again — this name, this store — is the person's, located here. */
12341
+ function removedWhileSyncing(context, name, account) {
12342
+ const add = [
12343
+ "add",
12344
+ name,
12345
+ ...account.source === void 0 ? [] : ["--source", account.source]
12346
+ ];
12347
+ return new CommsError("NOT_FOUND", `"${name}" was removed while it was being synced, so nothing was indexed`, { hint: handoffSentence(context.handoffs.own(add), (command) => `Add it again with ${command} to read it.`) });
10916
12348
  }
10917
12349
  function sameLists(a, b) {
10918
12350
  return JSON.stringify([a.allow, a.deny]) === JSON.stringify([b.allow, b.deny]);
@@ -10933,7 +12365,7 @@ async function syncAccount(context, request) {
10933
12365
  const directory = context.accountDir(account);
10934
12366
  return withFileLock(context.syncLock(account), async () => {
10935
12367
  let current = await context.accountById(account.id);
10936
- if (!current) throw removedWhileSyncing(name);
12368
+ if (!current) throw removedWhileSyncing(context, name, account);
10937
12369
  await ensurePrivateDir(directory);
10938
12370
  const staleRemoved = await removeStaleSnapshots(directory);
10939
12371
  const snapshot = await snapshotStore(store.path, directory, context.sourceOptions(store.isDefault));
@@ -10957,7 +12389,7 @@ async function syncAccount(context, request) {
10957
12389
  copied: snapshot.copied
10958
12390
  }, new Visibility(built.lists), async () => {
10959
12391
  const now = await context.accountById(account.id);
10960
- if (!now) throw removedWhileSyncing(built.name);
12392
+ if (!now) throw removedWhileSyncing(context, built.name, account);
10961
12393
  current = now;
10962
12394
  return sameLists(now.lists, built.lists);
10963
12395
  });
@@ -10991,4 +12423,4 @@ async function syncAccount(context, request) {
10991
12423
  });
10992
12424
  }
10993
12425
  //#endregion
10994
- export { paint as $, EXIT_CODES as A, colorEnabled as B, checkStorePath as C, CHANNELS as D, strictToolArguments as E, approvalsOf as F, gatedChangeAtTerminal as G, commandText as H, approveChangeAtTerminal as I, isCommsError as J, inlineCommand as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNEL_SERVERS as O, approvalKind as P, openCore as Q, channelManifest as R, storeIdentity as S, expandSource as T, escapeForDisplay as U, commandPathOf as V, exemptFromUpdateGate as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, listChats as a, shellCommand as at, requireAccount as b, Presenter as c, updateGateAtTerminal as ct, draftMessage as d, withFileLock as dt, renderInstall as et, Visibility as f, writeResult as ft, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, serverPruneChange as it, UNTRUSTED_NOTICE as j, CommsError as k, innerText as l, updateToolGate as lt, WhatsAppContext as m, whatsappStatus as n, runCommand as nt, readChat as o, toCommsError as ot, listKey as p, VERSION$1 as pt, installExitStatus as q, probeStore as r, serverInstallChange as rt, searchMessages as s, truncateDisplay as st, syncAccount as t, renderPrune as tt, composeDraft as u, windowsSystemProgram as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
12426
+ export { paint as $, PATH_OPTIONS as A, colorEnabled as B, checkStorePath as C, CHANNEL_SERVERS as D, strictToolArguments as E, approvalsOf as F, handoffSentence as G, escapeForDisplay as H, approveChangeAtTerminal as I, isCommsError as J, handoffText as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CommsError as O, approvalKind as P, openCore as Q, childEnvironment as R, storeIdentity as S, expandSource as T, exemptFromUpdateGate as U, commandPathOf as V, gatedChangeAtTerminal as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, VERSION$1 as _t, listChats as a, sendApprovesHint as at, requireAccount as b, Presenter as c, toCommsError as ct, draftMessage as d, updateToolGate as dt, pathOverridesFromCliOptions as et, Visibility as f, windowsSystemProgram as ft, requireSupportedNode as g, PACKAGE_NAME as gt, WhatsAppIndex as h, CALLER as ht, CHAT_KINDS as i, runCommand as it, UNTRUSTED_NOTICE as j, EXIT_CODES as k, innerText as l, truncateDisplay as lt, WhatsAppContext as m, writeResult as mt, whatsappStatus as n, renderPrune as nt, readChat as o, serverInstallChange as ot, listKey as p, withFileLock as pt, installExitStatus as q, probeStore as r, resolvePaths as rt, searchMessages as s, serverPruneChange as st, syncAccount as t, renderInstall as tt, composeDraft as u, updateGateAtTerminal as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, cliHandoffs as z };