@agentcomms/resend 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.
@@ -4,15 +4,15 @@ import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative
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 { homedir, platform, userInfo } from "node:os";
7
8
  import "node:readline/promises";
8
9
  import { styleText } from "node:util";
9
- import { homedir, platform, userInfo } from "node:os";
10
- import { constants as constants$1, realpathSync } from "node:fs";
10
+ import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
11
11
  import { execFile, spawn } from "node:child_process";
12
12
  //#endregion
13
13
  //#region src/version.ts
14
14
  /** The package version, read from package.json at build time. */
15
- const VERSION$1 = "0.13.0";
15
+ const VERSION$1 = "0.13.1";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -187,7 +187,21 @@ function refusedBecause(method, pathname) {
187
187
  return null;
188
188
  }
189
189
  //#endregion
190
- //#region ../core/dist/update-check-C8sNjCez.mjs
190
+ //#region src/caller.ts
191
+ /** This package's name, as its manifest has it: what core checks a caller against. */
192
+ const PACKAGE_NAME = "@agentcomms/resend";
193
+ /**
194
+ * This package as core's caller (CUE-403; CONTRIBUTING.md, "Telling a person what to run"): one of its own modules and
195
+ * its name, so every command it prints for a person runs this installation, with its folders pinned. Its CLI and its
196
+ * server open core with it, and nothing else does. Bundled, this module is part of the bundle's file — still inside
197
+ * this package — so the check that the caller is this package holds in the published build as in a checkout.
198
+ */
199
+ const RESEND_CALLER = Object.freeze({
200
+ url: import.meta.url,
201
+ packageName: PACKAGE_NAME
202
+ });
203
+ //#endregion
204
+ //#region ../core/dist/update-check-BPwwmUCi.mjs
191
205
  /**
192
206
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
193
207
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -1049,7 +1063,7 @@ const CHANNEL_SNAPSHOT = [
1049
1063
  guarantee: {
1050
1064
  ceiling: "code",
1051
1065
  floor: "code",
1052
- 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."
1066
+ 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."
1053
1067
  }
1054
1068
  },
1055
1069
  narrowing: [{
@@ -1169,25 +1183,6 @@ function manifestOf(channel) {
1169
1183
  function accountChannels() {
1170
1184
  return MANIFESTS.filter((manifest) => manifest.accounts !== void 0);
1171
1185
  }
1172
- /**
1173
- * 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.
1174
- */
1175
- function listed(words, conjunction, options = {}) {
1176
- if (words.length <= 1) return words.join("");
1177
- const last = words.at(-1);
1178
- const rest = words.slice(0, -1);
1179
- return `${rest.join(", ")}${options.oxford && rest.length > 1 ? "," : ""} ${conjunction} ${last}`;
1180
- }
1181
- /**
1182
- * Every channel's own approve command, quoted: "`agent-gmail approve` or `agent-slack approve`".
1183
- *
1184
- * `sending` keeps only the channels whose accounts can be in `send`: the commands that can have prepared a send. A
1185
- * channel that never sends — WhatsApp — has an approve command for the changes its `mcp install` and `mcp prune`
1186
- * make, and naming it where the approval in hand is a send would send a person to a command that never prepared one.
1187
- */
1188
- function channelApproveCommands(options = {}) {
1189
- return listed(accountChannels().filter((manifest) => options.sending !== true || manifest.accounts?.modes.includes("send") === true).flatMap((manifest) => manifest.approve ? [`\`${manifest.approve}\``] : []), "or");
1190
- }
1191
1186
  /** The noun for one account on `channel` — `mailbox`, `workspace` — or `account` for a channel that names none. */
1192
1187
  function accountNoun(channel) {
1193
1188
  return manifestOf(channel)?.accounts?.noun ?? "account";
@@ -1205,106 +1200,20 @@ function narrowingOwner(option) {
1205
1200
  return MANIFESTS.find((manifest) => manifest.narrowing?.some((narrowing) => narrowing.option === option));
1206
1201
  }
1207
1202
  /**
1208
- * The command that connects a mailbox, for a hint about there being none: the `inbox add` of the channel whose
1209
- * accounts are mailboxes, in the `inboxes` map.
1203
+ * How a mailbox is connected, for a hint about there being none: the `inbox add` of the channel whose accounts are
1204
+ * mailboxes, in the `inboxes` map — named in words, "Gmail's inbox add", never as a command. Core does not have that
1205
+ * channel's CLI, and finds it only among this machine's registrations (CUE-403); a hint made where none were read must
1206
+ * not invent the command from the manifest's binary.
1210
1207
  */
1211
- function connectMailboxCommand() {
1208
+ function connectMailboxWords() {
1212
1209
  const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
1213
- return mail ? `${mail.binary} inbox add` : void 0;
1214
- }
1215
- /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
1216
- const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1217
- function randomString(length) {
1218
- const bytes = randomBytes(length);
1219
- let out = "";
1220
- for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
1221
- return out;
1222
- }
1223
- /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
1224
- const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
1225
- const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
1226
- function newApprovalId() {
1227
- return `ap_${randomString(26)}`;
1228
- }
1229
- function newPlanToken() {
1230
- return `pl_${randomString(26)}`;
1231
- }
1232
- const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
1233
- /** A short challenge a human types back to approve: letters only, no look-alikes. */
1234
- function newChallenge(length = 4) {
1235
- let out = "";
1236
- for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
1237
- return out;
1238
- }
1239
- /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
1240
- function hashChallenge(challenge) {
1241
- return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
1242
- }
1243
- /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
1244
- function challengeMatches(answer, storedHash) {
1245
- const a = Buffer.from(hashChallenge(answer), "hex");
1246
- const b = Buffer.from(storedHash, "hex");
1247
- return a.length === b.length && timingSafeEqual(a, b);
1248
- }
1249
- function okEnvelope(data) {
1250
- return {
1251
- ok: true,
1252
- schemaVersion: 1,
1253
- data
1254
- };
1255
- }
1256
- function errorEnvelope(error) {
1257
- const body = {
1258
- code: error.code,
1259
- message: error.message
1260
- };
1261
- if (error.hint !== void 0) body.hint = error.hint;
1262
- if (error.details !== void 0) body.details = error.details;
1263
- return {
1264
- ok: false,
1265
- schemaVersion: 1,
1266
- error: body
1267
- };
1268
- }
1269
- const defaultStreams = {
1270
- stdout: process.stdout,
1271
- stderr: process.stderr,
1272
- stdin: process.stdin
1273
- };
1274
- function colorEnabled(env, stream, flag) {
1275
- if (flag === false) return false;
1276
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1277
- if (env.TERM === "dumb") return false;
1278
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1279
- return Boolean(stream.isTTY);
1280
- }
1281
- /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1282
- function canPrompt(env, streams, options) {
1283
- if (options.json || options.noInput) return false;
1284
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1285
- return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1286
- }
1287
- /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1288
- const AGENT_MARKERS = [
1289
- "CLAUDECODE",
1290
- "CLAUDE_CODE_ENTRYPOINT",
1291
- "CODEX_SANDBOX",
1292
- "CODEX_HOME",
1293
- "CURSOR_AGENT",
1294
- "GEMINI_CLI",
1295
- "AGENT_COMMS_AGENT"
1296
- ];
1297
- function agentMarker(env) {
1298
- for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1299
- return null;
1300
- }
1301
- function paint(color, format, text) {
1302
- return color ? styleText(format, text, { validateStream: false }) : text;
1210
+ return mail ? `${mail.label}'s inbox add` : void 0;
1303
1211
  }
1304
1212
  /**
1305
1213
  * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
1306
- * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
1307
- * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
1214
+ * Every command this suite prints to be run — a change to run again with its approval, a folder to take out, an entry
1215
+ * to register again or remove — is quoted here, when the locator or `externalCommand` makes it, so no printer quotes
1216
+ * for a shell of its own.
1308
1217
  *
1309
1218
  * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
1310
1219
  * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
@@ -1343,31 +1252,58 @@ function paint(color, format, text) {
1343
1252
  * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
1344
1253
  * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
1345
1254
  *
1255
+ * - The program itself has to be left as it is. Quoted, it is a string to PowerShell rather than a command — PowerShell
1256
+ * runs a quoted program only after `&`, which cmd.exe reads as the end of a command — so a program whose path needs
1257
+ * quotes, as `C:\Program Files\nodejs\node.exe` does, leaves the command with no line either (CUE-403).
1258
+ *
1346
1259
  * Everywhere else there is always a line: single quotes make any word safe.
1260
+ *
1261
+ * 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
1262
+ * of this suite's own commands comes from `locateCliCommand`, any other program's from `externalCommand`, and each of
1263
+ * them quotes its words here.
1347
1264
  */
1348
- function shellCommand(words, platform = process.platform) {
1265
+ function quoteCommand(words, platform) {
1349
1266
  if (platform !== "win32") return {
1350
1267
  words,
1351
1268
  line: words.map(posixShellWord).join(" "),
1352
1269
  platform
1353
1270
  };
1354
1271
  const printed = words.map(windowsShellWord);
1272
+ const programBare = words.length === 0 || printed[0] === words[0];
1355
1273
  return {
1356
1274
  words,
1357
- line: printed.includes(null) ? null : printed.join(" "),
1275
+ line: printed.includes(null) || !programBare ? null : printed.join(" "),
1358
1276
  platform
1359
1277
  };
1360
1278
  }
1361
- /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
1362
- function withWords(command, ...more) {
1363
- return shellCommand([...command.words, ...more], command.platform);
1279
+ /**
1280
+ * A printed command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written,
1281
+ * never quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
1282
+ * command has no line.
1283
+ */
1284
+ function quotedLineWithWordsToFill(command, ...toFill) {
1285
+ if (command.line === null) return null;
1286
+ const sentinel = command.words.indexOf("--");
1287
+ if (sentinel < 0) return [command.line, ...toFill].join(" ");
1288
+ const word = command.platform === "win32" ? windowsShellWord : posixShellWord;
1289
+ const printed = command.words.map((each) => word(each));
1290
+ return [
1291
+ ...printed.slice(0, sentinel),
1292
+ ...toFill,
1293
+ ...printed.slice(sentinel)
1294
+ ].join(" ");
1295
+ }
1296
+ /** Whether a Windows command has no line because its program's path needs quotes, which PowerShell would not run. */
1297
+ function programNeedsQuotes(command) {
1298
+ const [program] = command.words;
1299
+ return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
1364
1300
  }
1365
1301
  function posixShellWord(word) {
1366
1302
  return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1367
1303
  }
1368
1304
  /**
1369
1305
  * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
1370
- * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1306
+ * does (see `quoteCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1371
1307
  */
1372
1308
  function windowsShellWord(word) {
1373
1309
  if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
@@ -1405,140 +1341,189 @@ function commandAsJson(words) {
1405
1341
  }
1406
1342
  /** What is said beside a command shown as JSON. */
1407
1343
  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";
1408
- /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1409
- function inlineCommand(command) {
1410
- return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
1411
- }
1412
- /** 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. */
1413
- function commandText(command) {
1414
- return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
1344
+ /** What is said beside a command shown as JSON because its program's path needs quotes. */
1345
+ 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";
1346
+ function toType(command) {
1347
+ return programNeedsQuotes(command) ? TO_TYPE_PROGRAM : TO_TYPE;
1415
1348
  }
1416
- /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1417
- function writeResult(data, options, human, streams = defaultStreams) {
1418
- if (options.json) {
1419
- streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1420
- return;
1421
- }
1422
- const text = human(data);
1423
- if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1424
- }
1425
- /** Writes an error and returns the exit code to use. */
1426
- function writeError(error, options, streams = defaultStreams) {
1427
- const commsError = toCommsError(error);
1428
- if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1429
- else {
1430
- streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1431
- if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1432
- }
1433
- return commsError.exitCode;
1349
+ /** Quoted words in backticks, for a sentence — as words in JSON, saying it has to be typed, when there is no line. */
1350
+ function inlineQuoted(command) {
1351
+ return command.line === null ? `\`${commandAsJson(command.words)}\` (${toType(command)})` : `\`${command.line}\``;
1434
1352
  }
1435
- /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1436
- async function runCommand(options, body, streams = defaultStreams) {
1437
- try {
1438
- await body();
1439
- return EXIT_CODES.OK;
1440
- } catch (error) {
1441
- return writeError(error, options, streams);
1442
- }
1353
+ /** Quoted words as text of their own — a list's line, a field's value — or as words in JSON, saying to type them. */
1354
+ function quotedText(command) {
1355
+ return command.line === null ? `${commandAsJson(command.words)} (${toType(command)})` : command.line;
1443
1356
  }
1444
1357
  /**
1445
- * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1446
- * whose code is not its own to make up.
1447
- *
1448
- * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1449
- * but it refuses in exactly the same order and words, because it is the same gate.
1358
+ * The same command with words the agent is to fill in — a placeholder such as `<folder>` — before its first `--`: for
1359
+ * showing as words in JSON when the command has no line. With a line, `quotedLineWithWordsToFill` prints them as they
1360
+ * are written instead, never quoted as a word.
1450
1361
  */
1451
- function refuseUnlessPerson(env, streams, gate) {
1452
- const marker = agentMarker(env);
1453
- if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1454
- hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
1455
- details: { marker }
1456
- });
1457
- const prompting = {};
1458
- if (gate.json !== void 0) prompting.json = gate.json;
1459
- if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1460
- if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
1362
+ function quotedWithWordsToFill(command, toFill) {
1363
+ const sentinel = command.words.indexOf("--");
1364
+ const at = sentinel < 0 ? command.words.length : sentinel;
1365
+ return quoteCommand([
1366
+ ...command.words.slice(0, at),
1367
+ ...toFill,
1368
+ ...command.words.slice(at)
1369
+ ], command.platform);
1461
1370
  }
1462
- /**
1463
- * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1464
- * and the preview renderer (which makes them visible), so the two can never disagree.
1465
- */
1466
- /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
1467
- const INVISIBLE_RANGES = [
1468
- [173, 173],
1469
- [847, 847],
1470
- [1564, 1564],
1471
- [4447, 4448],
1472
- [6068, 6069],
1473
- [6155, 6159],
1474
- [8203, 8207],
1475
- [8232, 8233],
1476
- [8234, 8238],
1477
- [8288, 8292],
1478
- [8294, 8303],
1479
- [12644, 12644],
1480
- [65024, 65039],
1481
- [65279, 65279],
1482
- [65440, 65440],
1483
- [917504, 917631]
1484
- ];
1485
- function isInvisible(codePoint) {
1486
- for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
1487
- return false;
1371
+ /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
1372
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1373
+ function randomString(length) {
1374
+ const bytes = randomBytes(length);
1375
+ let out = "";
1376
+ for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
1377
+ return out;
1488
1378
  }
1489
- /**
1490
- * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
1491
- * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
1492
- */
1493
- function isControl(codePoint) {
1494
- if (codePoint === 9 || codePoint === 10) return false;
1495
- return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
1379
+ /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
1380
+ const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
1381
+ const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
1382
+ function newApprovalId() {
1383
+ return `ap_${randomString(26)}`;
1496
1384
  }
1497
- function isDangerous(codePoint) {
1498
- return isControl(codePoint) || isInvisible(codePoint);
1385
+ function newPlanToken() {
1386
+ return `pl_${randomString(26)}`;
1499
1387
  }
1500
- /**
1501
- * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
1502
- * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1503
- *
1504
- * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
1505
- * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
1506
- * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1507
- */
1508
- function stripInvisible(text) {
1509
- let removed = 0;
1388
+ const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
1389
+ /** A short challenge a human types back to approve: letters only, no look-alikes. */
1390
+ function newChallenge(length = 4) {
1510
1391
  let out = "";
1511
- for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1512
- else out += char;
1392
+ for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
1393
+ return out;
1394
+ }
1395
+ /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
1396
+ function hashChallenge(challenge) {
1397
+ return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
1398
+ }
1399
+ /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
1400
+ function challengeMatches(answer, storedHash) {
1401
+ const a = Buffer.from(hashChallenge(answer), "hex");
1402
+ const b = Buffer.from(storedHash, "hex");
1403
+ return a.length === b.length && timingSafeEqual(a, b);
1404
+ }
1405
+ function okEnvelope(data) {
1513
1406
  return {
1514
- text: out,
1515
- removed
1407
+ ok: true,
1408
+ schemaVersion: 1,
1409
+ data
1410
+ };
1411
+ }
1412
+ function errorEnvelope(error) {
1413
+ const body = {
1414
+ code: error.code,
1415
+ message: error.message
1416
+ };
1417
+ if (error.hint !== void 0) body.hint = error.hint;
1418
+ if (error.details !== void 0) body.details = error.details;
1419
+ return {
1420
+ ok: false,
1421
+ schemaVersion: 1,
1422
+ error: body
1516
1423
  };
1517
1424
  }
1518
1425
  const APP_DIR_NAME = "agent-communications";
1519
- /**
1520
- * Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
1521
- * where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
1522
- *
1523
- * On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
1524
- * the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
1525
- * exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
1526
- * someone who names a directory means that directory.
1527
- */
1426
+ /** The five suite directories a CLI can pin independently. */
1427
+ const PATH_OPTIONS = [
1428
+ {
1429
+ key: "configDir",
1430
+ option: "config-dir",
1431
+ flag: "--config-dir"
1432
+ },
1433
+ {
1434
+ key: "stateDir",
1435
+ option: "state-dir",
1436
+ flag: "--state-dir"
1437
+ },
1438
+ {
1439
+ key: "dataDir",
1440
+ option: "data-dir",
1441
+ flag: "--data-dir"
1442
+ },
1443
+ {
1444
+ key: "secretsDir",
1445
+ option: "secrets-dir",
1446
+ flag: "--secrets-dir"
1447
+ },
1448
+ {
1449
+ key: "downloadsDir",
1450
+ option: "downloads-dir",
1451
+ flag: "--downloads-dir"
1452
+ }
1453
+ ];
1454
+ /**
1455
+ * Words with the five path options and their values taken out where an option parser would read them — before the
1456
+ * first `--`, in both the `--x value` and `--x=value` forms — and every word from `--` on kept as it is.
1457
+ *
1458
+ * What a registered entry is recognised by. A pinned folder is a folder: `/tmp/@agentcomms/slack`, or one ending in
1459
+ * `packages/slack/src/cli.ts`, read as a package or an entry, made a Gmail registration Slack's. `dangling` says a
1460
+ * spaced option had no value before `--` or the end, which is a broken entry rather than one to guess about.
1461
+ */
1462
+ function withoutPathOptions(words) {
1463
+ const flags = PATH_OPTIONS.map(({ flag }) => flag);
1464
+ const kept = [];
1465
+ let dangling = false;
1466
+ for (let index = 0; index < words.length; index += 1) {
1467
+ const word = words[index];
1468
+ if (word === "--") {
1469
+ kept.push(...words.slice(index));
1470
+ break;
1471
+ }
1472
+ if (flags.includes(word)) {
1473
+ const value = words[index + 1];
1474
+ if (value === void 0 || value === "--") dangling = true;
1475
+ else index += 1;
1476
+ continue;
1477
+ }
1478
+ if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
1479
+ kept.push(word);
1480
+ }
1481
+ return {
1482
+ words: kept,
1483
+ dangling
1484
+ };
1485
+ }
1486
+ /**
1487
+ * Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
1488
+ * where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
1489
+ *
1490
+ * On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
1491
+ * the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
1492
+ * exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
1493
+ * someone who names a directory means that directory.
1494
+ */
1528
1495
  function resolvePaths(options = {}) {
1496
+ return resolvePathIdentity(options).paths;
1497
+ }
1498
+ /** Resolves the environment/default identity, then overlays each explicit pin without re-deriving any sibling. */
1499
+ function resolvePathIdentity(options = {}) {
1529
1500
  const env = options.env ?? process.env;
1530
1501
  const platform = options.platform ?? process.platform;
1531
1502
  const home = options.home ?? homeOf(env, platform);
1532
1503
  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")));
1533
1504
  const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
1534
1505
  const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
1535
- return {
1506
+ const derived = {
1536
1507
  configDir,
1537
1508
  stateDir: resolve(env.AGENT_COMMS_STATE_DIR || join(localRoot, "state")),
1538
1509
  secretsDir: resolve(join(localRoot, "secrets")),
1539
1510
  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"))),
1540
1511
  downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
1541
1512
  };
1513
+ const pathOverrides = {};
1514
+ for (const { key } of PATH_OPTIONS) {
1515
+ const value = options.pathOverrides?.[key];
1516
+ if (value === void 0) continue;
1517
+ if (value.length === 0) throw new TypeError(`${key} cannot be empty`);
1518
+ pathOverrides[key] = resolve(value);
1519
+ }
1520
+ return {
1521
+ paths: {
1522
+ ...derived,
1523
+ ...pathOverrides
1524
+ },
1525
+ pathOverrides: Object.freeze(pathOverrides)
1526
+ };
1542
1527
  }
1543
1528
  /**
1544
1529
  * The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
@@ -1608,6 +1593,294 @@ function shortenHome(path, home, platform = process.platform) {
1608
1593
  function homeDirectory(env = process.env) {
1609
1594
  return env.HOME || env.USERPROFILE || homedir();
1610
1595
  }
1596
+ const defaultStreams = {
1597
+ stdout: process.stdout,
1598
+ stderr: process.stderr,
1599
+ stdin: process.stdin
1600
+ };
1601
+ /** Converts the shared parser's five global option values into independently applied core path pins. */
1602
+ function pathOverridesFromCliOptions(values) {
1603
+ const overrides = {};
1604
+ for (const { key, option, flag } of PATH_OPTIONS) {
1605
+ const value = values[option];
1606
+ if (value === void 0) continue;
1607
+ if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
1608
+ overrides[key] = value;
1609
+ }
1610
+ return overrides;
1611
+ }
1612
+ /** Inserts generated option words immediately before the first end-of-options sentinel. */
1613
+ function insertWordsBeforeSentinel(words, ...inserted) {
1614
+ const sentinel = words.indexOf("--");
1615
+ const at = sentinel < 0 ? words.length : sentinel;
1616
+ return [
1617
+ ...words.slice(0, at),
1618
+ ...inserted,
1619
+ ...words.slice(at)
1620
+ ];
1621
+ }
1622
+ /**
1623
+ * Removes the named options only where the CLI parser can see them: before the first `--`. Both `--x value` and
1624
+ * `--x=value` are removed. The sentinel and every positional word after it are byte-for-byte data.
1625
+ */
1626
+ function withoutOptionsBeforeSentinel(words, flags) {
1627
+ const kept = [];
1628
+ for (let index = 0; index < words.length; index += 1) {
1629
+ const word = words[index];
1630
+ if (word === "--") {
1631
+ kept.push(...words.slice(index));
1632
+ break;
1633
+ }
1634
+ const spaced = flags.find((flag) => word === flag);
1635
+ if (spaced !== void 0) {
1636
+ const value = words[index + 1];
1637
+ if (value === void 0 || value === "--") throw new CommsError("USAGE", `${spaced} needs a value before --`);
1638
+ index += 1;
1639
+ continue;
1640
+ }
1641
+ if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
1642
+ kept.push(word);
1643
+ }
1644
+ return kept;
1645
+ }
1646
+ /**
1647
+ * Replaces every pre-sentinel suite path option with one canonical pin per supplied directory. `insertAt` counts
1648
+ * words after the old pins are removed: zero puts global options after the executable held elsewhere, one puts them
1649
+ * after an entry/package word. Downloads are included only when the caller supplies that pin.
1650
+ */
1651
+ function normalizePathOptionWords(words, pins, insertAt) {
1652
+ const stripped = withoutOptionsBeforeSentinel(words, PATH_OPTIONS.map(({ flag }) => flag));
1653
+ const sentinel = stripped.indexOf("--");
1654
+ const optionsEnd = sentinel < 0 ? stripped.length : sentinel;
1655
+ if (!Number.isSafeInteger(insertAt) || insertAt < 0 || insertAt > optionsEnd) throw new TypeError(`path-option insertion point ${insertAt} is outside the option words`);
1656
+ const pathWords = PATH_OPTIONS.flatMap(({ key, flag }) => {
1657
+ const value = pins[key];
1658
+ if (value === void 0) return [];
1659
+ if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
1660
+ return [flag, resolve(value)];
1661
+ });
1662
+ return [
1663
+ ...stripped.slice(0, insertAt),
1664
+ ...pathWords,
1665
+ ...stripped.slice(insertAt)
1666
+ ];
1667
+ }
1668
+ function colorEnabled(env, stream, flag) {
1669
+ if (flag === false) return false;
1670
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1671
+ if (env.TERM === "dumb") return false;
1672
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1673
+ return Boolean(stream.isTTY);
1674
+ }
1675
+ /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1676
+ function canPrompt(env, streams, options) {
1677
+ if (options.json || options.noInput) return false;
1678
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1679
+ return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1680
+ }
1681
+ /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1682
+ const AGENT_MARKERS = [
1683
+ "CLAUDECODE",
1684
+ "CLAUDE_CODE_ENTRYPOINT",
1685
+ "CODEX_SANDBOX",
1686
+ "CODEX_HOME",
1687
+ "CURSOR_AGENT",
1688
+ "GEMINI_CLI",
1689
+ "AGENT_COMMS_AGENT"
1690
+ ];
1691
+ function agentMarker(env) {
1692
+ for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1693
+ return null;
1694
+ }
1695
+ function paint(color, format, text) {
1696
+ return color ? styleText(format, text, { validateStream: false }) : text;
1697
+ }
1698
+ /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1699
+ function inlineCommand(command) {
1700
+ return inlineQuoted(command);
1701
+ }
1702
+ /** 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. */
1703
+ function commandText(command) {
1704
+ return quotedText(command);
1705
+ }
1706
+ /**
1707
+ * A command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written, never
1708
+ * quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
1709
+ * command has no line.
1710
+ */
1711
+ function lineWithWordsToFill(command, ...toFill) {
1712
+ return quotedLineWithWordsToFill(command, ...toFill);
1713
+ }
1714
+ /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1715
+ function writeResult(data, options, human, streams = defaultStreams) {
1716
+ if (options.json) {
1717
+ streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1718
+ return;
1719
+ }
1720
+ const text = human(data);
1721
+ if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1722
+ }
1723
+ /** Writes an error and returns the exit code to use. */
1724
+ function writeError(error, options, streams = defaultStreams) {
1725
+ const commsError = toCommsError(error);
1726
+ if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1727
+ else {
1728
+ streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1729
+ if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1730
+ }
1731
+ return commsError.exitCode;
1732
+ }
1733
+ /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1734
+ async function runCommand(options, body, streams = defaultStreams) {
1735
+ try {
1736
+ await body();
1737
+ return EXIT_CODES.OK;
1738
+ } catch (error) {
1739
+ return writeError(error, options, streams);
1740
+ }
1741
+ }
1742
+ /**
1743
+ * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1744
+ * whose code is not its own to make up.
1745
+ *
1746
+ * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1747
+ * but it refuses in exactly the same order and words, because it is the same gate.
1748
+ */
1749
+ function refuseUnlessPerson(env, streams, gate) {
1750
+ const marker = agentMarker(env);
1751
+ if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1752
+ hint: gateSentence(gate.command, (command) => `Ask the user to run ${command} in their own terminal.`),
1753
+ details: { marker }
1754
+ });
1755
+ const prompting = {};
1756
+ if (gate.json !== void 0) prompting.json = gate.json;
1757
+ if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1758
+ if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: gateSentence(gate.command, (command) => `Run ${command} directly in a terminal.`) });
1759
+ }
1760
+ /** A gate's command in a sentence: in backticks, or the sentence saying why there is none in its place. */
1761
+ function gateSentence(command, say) {
1762
+ return "message" in command ? command.message : say(inlineCommand(command));
1763
+ }
1764
+ /**
1765
+ * The handoffs a store or a deep function was given (`core.handoffs`, handed on by `openCore`), for the command one of
1766
+ * its refusals names. A programming error when there are none: the package that opened core did not give it its
1767
+ * caller (`openCore({ caller })`), and there is no command to print in its place — not a bare name, not a guess.
1768
+ */
1769
+ function requiredHandoffs(handoffs) {
1770
+ if (handoffs === void 0) throw new TypeError("core was opened without its caller, so it cannot locate a command: pass openCore({ caller })");
1771
+ return handoffs;
1772
+ }
1773
+ /** Whether a handoff is a command to run, rather than the sentence saying why there is none. */
1774
+ function isCommand(handoff) {
1775
+ return !("message" in handoff);
1776
+ }
1777
+ /**
1778
+ * 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
1779
+ * to do when no line is safe in every Windows shell; or, with no command, the sentence saying why.
1780
+ */
1781
+ function handoffText(handoff) {
1782
+ return isCommand(handoff) ? commandText(handoff) : handoff.message;
1783
+ }
1784
+ /**
1785
+ * A remedy, a line per argument: one part, or the parts of one line in order — `[remove, ', then add it again']`.
1786
+ * A command is shown as `handoffText` shows it: its line, its words as JSON with what to do when no Windows line is
1787
+ * safe, or why there is none here.
1788
+ */
1789
+ function remedy(...lines) {
1790
+ const part = (each) => typeof each === "string" ? each : handoffText(each);
1791
+ return lines.map((line) => Array.isArray(line) ? line.map(part).join("") : part(line)).join("\n");
1792
+ }
1793
+ /** The sentence saying why there is no command, after another way when there is one. */
1794
+ function noCommand(handoff, options) {
1795
+ return options.instead === void 0 ? handoff.message : `${options.instead} ${handoff.message}`;
1796
+ }
1797
+ /**
1798
+ * A sentence that gives the command, in backticks, made by `say`; or, with no command, the sentence saying why there is
1799
+ * none — after `instead`, when given — never `say` with something else in the command's place.
1800
+ */
1801
+ function handoffSentence(handoff, say, options = {}) {
1802
+ return isCommand(handoff) ? say(inlineCommand(handoff)) : noCommand(handoff, options);
1803
+ }
1804
+ /**
1805
+ * As `handoffSentence`, for a command with words the agent is to fill in — a placeholder such as `<folder>`, printed as
1806
+ * written — before its first `--`. With no line to show them in, the command is shown as its words, placeholders among
1807
+ * them, saying it has to be typed.
1808
+ */
1809
+ function handoffSentenceToFill(handoff, toFill, say, options = {}) {
1810
+ if (!isCommand(handoff)) return noCommand(handoff, options);
1811
+ const line = lineWithWordsToFill(handoff, ...toFill);
1812
+ if (line !== null) return say(`\`${line}\``);
1813
+ return say(inlineQuoted(quotedWithWordsToFill(handoff, toFill)));
1814
+ }
1815
+ /**
1816
+ * Commands that each could do it — one per channel, say — as one phrase: the commands joined by "or", then, for each
1817
+ * that has none here, the sentence saying why. `none` is said when not one of them is a command.
1818
+ */
1819
+ function handoffChoices(handoffs, none, options = {}) {
1820
+ const commands = handoffs.filter((handoff) => isCommand(handoff)).map((handoff) => inlineCommand(handoff));
1821
+ const missing = handoffs.filter((handoff) => !isCommand(handoff));
1822
+ const reasons = [...new Set(missing.map((handoff) => handoff.message))].join(" ");
1823
+ if (commands.length === 0) return `${none} ${reasons}`.trim();
1824
+ const conjunction = options.conjunction ?? "or";
1825
+ const joined = commands.length === 1 ? commands[0] : `${commands.slice(0, -1).join(", ")} ${conjunction} ${commands.at(-1)}`;
1826
+ return reasons === "" ? joined : `${joined} (${reasons.replace(/\.$/, "")})`;
1827
+ }
1828
+ /**
1829
+ * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1830
+ * and the preview renderer (which makes them visible), so the two can never disagree.
1831
+ */
1832
+ /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
1833
+ const INVISIBLE_RANGES = [
1834
+ [173, 173],
1835
+ [847, 847],
1836
+ [1564, 1564],
1837
+ [4447, 4448],
1838
+ [6068, 6069],
1839
+ [6155, 6159],
1840
+ [8203, 8207],
1841
+ [8232, 8233],
1842
+ [8234, 8238],
1843
+ [8288, 8292],
1844
+ [8294, 8303],
1845
+ [12644, 12644],
1846
+ [65024, 65039],
1847
+ [65279, 65279],
1848
+ [65440, 65440],
1849
+ [917504, 917631]
1850
+ ];
1851
+ function isInvisible(codePoint) {
1852
+ for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
1853
+ return false;
1854
+ }
1855
+ /**
1856
+ * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
1857
+ * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
1858
+ */
1859
+ function isControl(codePoint) {
1860
+ if (codePoint === 9 || codePoint === 10) return false;
1861
+ return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
1862
+ }
1863
+ function isDangerous(codePoint) {
1864
+ return isControl(codePoint) || isInvisible(codePoint);
1865
+ }
1866
+ /**
1867
+ * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
1868
+ * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1869
+ *
1870
+ * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
1871
+ * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
1872
+ * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1873
+ */
1874
+ function stripInvisible(text) {
1875
+ let removed = 0;
1876
+ let out = "";
1877
+ for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1878
+ else out += char;
1879
+ return {
1880
+ text: out,
1881
+ removed
1882
+ };
1883
+ }
1611
1884
  const WINDOWS_RESERVED$1 = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
1612
1885
  const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
1613
1886
  /** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
@@ -1740,7 +2013,14 @@ async function checkAttachable(path, policy) {
1740
2013
  throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
1741
2014
  }
1742
2015
  if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
1743
- if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Copy the file under your home folder — not into one of its hidden folders — and name the copy instead, or allow its folder with `agentcomms attach roots add <folder>` (needs your approval)." });
2016
+ if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) {
2017
+ const allow = handoffSentenceToFill(requiredHandoffs(policy.handoffs).core([
2018
+ "attach",
2019
+ "roots",
2020
+ "add"
2021
+ ]), ["<folder>"], (command) => `allow its folder with ${command} (needs your approval).`, { instead: "allow its folder with comms_attach from a chat (needs your approval)." });
2022
+ throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: `Copy the file under your home folder — not into one of its hidden folders — and name the copy instead, or ${allow}` });
2023
+ }
1744
2024
  const name = basename(real);
1745
2025
  const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
1746
2026
  for (const entry of policy.deny) {
@@ -2216,21 +2496,24 @@ function committedSecretsStore(config) {
2216
2496
  * asked for, the keychain by default. `choosing` says this command is the one choosing it.
2217
2497
  *
2218
2498
  * A different backend asked for is refused, not taken: one backend holds everything here, and changing it has to move
2219
- * what is already stored, which is what `agentcomms secrets migrate` does and nothing else does.
2499
+ * what is already stored, which is what `agentcomms secrets migrate` does and nothing else does. The refusal names that
2500
+ * command, made by `handoffs` — the caller's, located (CUE-403).
2220
2501
  */
2221
- function secretsStoreFor(config, requested, platform = process.platform) {
2502
+ function secretsStoreFor(config, requested, handoffs) {
2222
2503
  const committed = committedSecretsStore(config);
2223
2504
  if (committed === null) return {
2224
2505
  store: requested ?? "keychain",
2225
2506
  choosing: true
2226
2507
  };
2227
- if (requested !== void 0 && requested !== committed) throw new CommsError("CONFIG", `this configuration already keeps its secrets in the ${committed} store`, { hint: `Everything here uses one store, and changing it moves what is already stored. To change it, run ${inlineCommand(shellCommand([
2228
- "agentcomms",
2229
- "secrets",
2230
- "migrate",
2231
- "--to",
2232
- requested
2233
- ], platform))}, then run this again.` });
2508
+ if (requested !== void 0 && requested !== committed) {
2509
+ const migrate = handoffSentence(handoffs.core([
2510
+ "secrets",
2511
+ "migrate",
2512
+ "--to",
2513
+ requested
2514
+ ]), (command) => `To change it, run ${command}, then run this again.`, { instead: "To change it, call comms_secrets_migrate from a chat, then run this again." });
2515
+ throw new CommsError("CONFIG", `this configuration already keeps its secrets in the ${committed} store`, { hint: `Everything here uses one store, and changing it moves what is already stored. ${migrate}` });
2516
+ }
2234
2517
  return {
2235
2518
  store: committed,
2236
2519
  choosing: false
@@ -2292,10 +2575,20 @@ function parseConfig$1(text, source = "config.json") {
2292
2575
  var ConfigStore = class {
2293
2576
  path;
2294
2577
  #lockPath;
2578
+ #handoffs;
2295
2579
  #cache = null;
2296
- constructor(configDir) {
2580
+ /**
2581
+ * @param options.handoffs the printing package's handoffs (`core.handoffs`, CUE-403), for the command a refusal
2582
+ * names; left out, a refusal that has to name one is a programming error (`requiredHandoffs`).
2583
+ */
2584
+ constructor(configDir, options = {}) {
2297
2585
  this.path = join(configDir, "config.json");
2298
2586
  this.#lockPath = join(configDir, ".config.lock");
2587
+ this.#handoffs = options.handoffs;
2588
+ }
2589
+ /** "Run the names migration again", as core's command — located from whatever is printing — or the tool. */
2590
+ #namesMigrateAgain(why) {
2591
+ return handoffSentence(requiredHandoffs(this.#handoffs).core(["names", "migrate"]), (command) => `Run ${command} again ${why}.`, { instead: `Call comms_names_migrate again ${why}.` });
2299
2592
  }
2300
2593
  /** The current config; an empty one when the file does not exist yet. */
2301
2594
  async load() {
@@ -2328,7 +2621,7 @@ var ConfigStore = class {
2328
2621
  this.#cache = null;
2329
2622
  const current = structuredClone(await this.load());
2330
2623
  const next = await mutator(structuredClone(current));
2331
- 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." });
2624
+ 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." });
2332
2625
  const parsed = schemaFor(current.version).safeParse(next);
2333
2626
  if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, current.version)}`);
2334
2627
  if (current.version === 2 && parsed.data.version === 2) {
@@ -2339,7 +2632,7 @@ var ConfigStore = class {
2339
2632
  const allowed = new Set(options.consent?.paths ?? []);
2340
2633
  const unconsented = loosened.filter((path) => !allowed.has(path));
2341
2634
  if (unconsented.length > 0) throw new CommsError("LOOSENING_REFUSED", `this change loosens a safety setting: ${unconsented.join(", ")}`, {
2342
- 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.",
2635
+ 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.",
2343
2636
  details: { paths: unconsented }
2344
2637
  });
2345
2638
  const approved = options.consent?.changes;
@@ -2399,13 +2692,13 @@ var ConfigStore = class {
2399
2692
  const raw = await readFileIfExists(this.path);
2400
2693
  const current = raw === null ? emptyConfig() : parseConfig$1(raw, this.path);
2401
2694
  if (current.version === 2) {
2402
- 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." });
2695
+ 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") });
2403
2696
  return {
2404
2697
  status: "already-migrated",
2405
2698
  config: current
2406
2699
  };
2407
2700
  }
2408
- 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." });
2701
+ 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") });
2409
2702
  const parsed = configV2Schema.safeParse(build(structuredClone(current)));
2410
2703
  if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, 2)}`);
2411
2704
  const unchanged = onlyKeysRenamed(current, parsed.data);
@@ -2968,13 +3261,40 @@ function publicView(record) {
2968
3261
  const { challengeHash: _hidden, ...rest } = record;
2969
3262
  return rest;
2970
3263
  }
3264
+ /**
3265
+ * The refusals of a send's approval offered to a change. They name each sending channel's `approve`; another product's
3266
+ * is found only among the registrations read for it, which the store does not read under its lock — so a caller that
3267
+ * can read them says it again with them (`sendApprovesHint`).
3268
+ */
3269
+ const MISDIRECTED_SENDS = /* @__PURE__ */ new WeakSet();
3270
+ /**
3271
+ * What an agent is told when a change waits for a person at a terminal: the printing package's own `approve` with this
3272
+ * id, located, or why there is none here.
3273
+ */
3274
+ function approvePendingHint(handoffs, approvalId, platform) {
3275
+ const located = requiredHandoffs(handoffs);
3276
+ 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.`);
3277
+ }
3278
+ /** Whether `error` is the refusal of a send's approval offered as a change's. */
3279
+ function isMisdirectedSend(error) {
3280
+ return error instanceof CommsError && MISDIRECTED_SENDS.has(error);
3281
+ }
3282
+ /**
3283
+ * What approves the send `approvalId`: the `approve` of every channel that sends, one of which prepared it — a send
3284
+ * record does not say which (D1). Each is a located command, or why it has none here.
3285
+ */
3286
+ function sendApprovesHint(maker, approvalId) {
3287
+ 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.`;
3288
+ }
2971
3289
  var ApprovalStore = class {
2972
3290
  directory;
2973
3291
  #now;
2974
3292
  #ttlMs;
2975
3293
  #downloadTtlMs;
3294
+ #handoffs;
2976
3295
  constructor(stateDir, options = {}) {
2977
3296
  this.directory = join(stateDir, "approvals");
3297
+ this.#handoffs = options.handoffs;
2978
3298
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
2979
3299
  this.#ttlMs = options.ttlMs ?? 6e5;
2980
3300
  this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
@@ -3080,17 +3400,17 @@ var ApprovalStore = class {
3080
3400
  * Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
3081
3401
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
3082
3402
  */
3083
- #requireKind(record, kind, platform) {
3403
+ #requireKind(record, kind, platform, handoffs = this.#handoffs) {
3404
+ const maker = () => requiredHandoffs(handoffs).on(platform);
3084
3405
  const actual = approvalKind(record);
3085
3406
  if (actual === kind) return;
3086
3407
  const id = record.approvalId;
3087
3408
  if (actual === "download") throw (kind === "change" ? refuseChange : refuse$1)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
3088
3409
  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.");
3089
- throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
3090
- "agentcomms",
3091
- "approve",
3092
- id
3093
- ], 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.`);
3410
+ if (kind === "send") throw refuse$1("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.`));
3411
+ const misdirected = refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, sendApprovesHint(maker(), id));
3412
+ MISDIRECTED_SENDS.add(misdirected);
3413
+ throw misdirected;
3094
3414
  }
3095
3415
  /** Issues a new challenge to show a human; only its hash is kept. */
3096
3416
  async issueChallenge(approvalId, kind = "send", platform = process.platform) {
@@ -3173,7 +3493,7 @@ var ApprovalStore = class {
3173
3493
  async claimForSend(approvalId, live, options = {}) {
3174
3494
  let failure = null;
3175
3495
  const result = await this.#transition(approvalId, (current) => {
3176
- this.#requireKind(current, "send", options.platform ?? process.platform);
3496
+ this.#requireKind(current, "send", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
3177
3497
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3178
3498
  if (options.signal?.aborted) throw cancelledClaim(current);
3179
3499
  const voidWith = (code, reason) => {
@@ -3285,7 +3605,7 @@ var ApprovalStore = class {
3285
3605
  const digest = changeDigest(live.change);
3286
3606
  let failure = null;
3287
3607
  const result = await this.#transition(approvalId, (current) => {
3288
- this.#requireKind(current, "change", options.platform ?? process.platform);
3608
+ this.#requireKind(current, "change", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
3289
3609
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3290
3610
  if (options.signal?.aborted) throw cancelledClaim(current);
3291
3611
  const voidWith = (reason) => {
@@ -3301,11 +3621,7 @@ var ApprovalStore = class {
3301
3621
  };
3302
3622
  if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
3303
3623
  if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
3304
- 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([
3305
- "agentcomms",
3306
- "approve",
3307
- approvalId
3308
- ], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
3624
+ 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));
3309
3625
  if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
3310
3626
  }
3311
3627
  return {
@@ -3427,7 +3743,7 @@ var ApprovalStore = class {
3427
3743
  const digest = downloadDigest(live);
3428
3744
  let failure = null;
3429
3745
  const result = await this.#transition(approvalId, (current) => {
3430
- this.#requireKind(current, "download", options.platform ?? process.platform);
3746
+ this.#requireKind(current, "download", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
3431
3747
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3432
3748
  if (options.signal?.aborted) throw cancelledClaim(current);
3433
3749
  const voidWith = (reason) => {
@@ -3578,125 +3894,99 @@ var AuditLog = class {
3578
3894
  return out.reverse();
3579
3895
  }
3580
3896
  };
3581
- const MAP = {
3582
- inbox: "inboxes",
3583
- account: "accounts"
3584
- };
3897
+ /** Every command a manifest declares — each channel's `binary` and its server's other `bins` — lowercased. */
3898
+ const SUITE_COMMANDS = new Set(CHANNEL_SNAPSHOT.flatMap(({ manifest }) => [manifest.binary, ...manifest.server.bins ?? []]).map((name) => name.toLowerCase()));
3899
+ /** The extensions Windows finds an executable by: `agent-gmail.cmd` is `agent-gmail`. */
3900
+ const EXECUTABLE_EXTENSION = /\.(?:cmd|exe|bat|ps1|com)$/i;
3585
3901
  /**
3586
- * An own property only.
3587
- *
3588
- * A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
3589
- * of an inbox called `constructor`, which version 1 allows, found it.
3902
+ * The suite command a word names as a program — its last path part, without a Windows extension, compared without
3903
+ * case as Windows compares executable names — or null.
3590
3904
  */
3591
- function own$1(map, key) {
3592
- return Object.hasOwn(map, key) ? map[key] : void 0;
3905
+ function suiteCommandOf(word) {
3906
+ const name = (word.split(/[\\/]/).at(-1) ?? "").replace(EXECUTABLE_EXTENSION, "").toLowerCase();
3907
+ return SUITE_COMMANDS.has(name) ? name : null;
3593
3908
  }
3594
- function resolveName(config, kind, name, notFound) {
3595
- if (kind === "inbox") {
3596
- const inbox = own$1(config.inboxes, name);
3597
- if (inbox) return {
3598
- alias: name,
3599
- inbox
3600
- };
3601
- } else {
3602
- const account = own$1(config.accounts, name);
3603
- if (account) return {
3604
- alias: name,
3605
- account
3606
- };
3607
- }
3608
- const renamed = formerNameRefusal(config, kind, name);
3609
- if (renamed) throw renamed;
3610
- throw notFound?.() ?? defaultNotFound(config, kind, name);
3611
- }
3612
- /** What to say when there are no mailboxes at all: the command that connects one, from the mail channel's manifest. */
3613
- function noInboxesHint() {
3614
- const connect = connectMailboxCommand();
3615
- return connect ? `No inboxes yet: add one with \`${connect}\`.` : "No inboxes yet.";
3909
+ /** A parsed `package.json`, as an object, or null when there is none, it cannot be read, or it is not an object. */
3910
+ function readPackageManifest(dir) {
3911
+ const read = readPackageFile(dir);
3912
+ return read === "absent" ? null : read;
3616
3913
  }
3617
- function defaultNotFound(config, kind, name) {
3618
- if (kind === "inbox") {
3619
- const known = Object.keys(config.inboxes);
3620
- return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
3914
+ /** `absent` when the directory has no `package.json`; otherwise what it says, or null when that cannot be read. */
3915
+ function readPackageFile(dir) {
3916
+ let text;
3917
+ try {
3918
+ text = readFileSync(join(dir, "package.json"), "utf8");
3919
+ } catch (error) {
3920
+ const code = error.code;
3921
+ return code === "ENOENT" || code === "ENOTDIR" ? "absent" : null;
3922
+ }
3923
+ try {
3924
+ const parsed = JSON.parse(text);
3925
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
3926
+ } catch {
3927
+ return null;
3621
3928
  }
3622
- return new CommsError("NOT_FOUND", `no account called "${name}"`);
3623
- }
3624
- function lookupName(config, kind, name) {
3625
- return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
3626
3929
  }
3627
- function findById(config, kind, id) {
3628
- if (kind === "inbox") {
3629
- for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
3630
- alias,
3631
- inbox
3930
+ /**
3931
+ * The nearest package at or above `start`: the first directory with a `package.json`, and what it says (null when it
3932
+ * cannot be read or parsed). Not the nearest one with a particular name — the package a module belongs to is the
3933
+ * nearest one, as Node itself decides, and a caller that names a different one has to be told so.
3934
+ */
3935
+ function nearestPackage(start) {
3936
+ let dir = resolve(start);
3937
+ for (;;) {
3938
+ const manifest = readPackageFile(dir);
3939
+ if (manifest !== "absent") return {
3940
+ root: dir,
3941
+ manifest
3632
3942
  };
3633
- } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
3634
- alias,
3635
- account
3636
- };
3637
- return null;
3943
+ const parent = dirname(dir);
3944
+ if (parent === dir) return null;
3945
+ dir = parent;
3946
+ }
3638
3947
  }
3639
3948
  /**
3640
- * The refusal for a former name, or null when `name` is not one.
3641
- *
3642
- * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
3643
- * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
3644
- * name that now belongs to nothing.
3949
+ * Whether `path` is `root` or inside it, by whole path segments: `/x/core-evil` is not inside `/x/core`, which a
3950
+ * comparison of strings would say it was. Both are taken as given; a caller compares real paths with real paths.
3645
3951
  */
3646
- function formerNameRefusal(config, kind, name) {
3647
- if (config.version !== 2) return null;
3648
- const former = own$1(config.formerNames[MAP[kind]], name);
3649
- if (!former) return null;
3650
- const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
3651
- if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
3652
- formerName: name,
3653
- id: former.id
3654
- } });
3655
- return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
3656
- hint: `Use "${current.alias}".`,
3657
- details: {
3658
- formerName: name,
3659
- currentName: current.alias,
3660
- id: former.id
3661
- }
3662
- });
3952
+ function isWithin(root, path) {
3953
+ const between = relative(resolve(root), resolve(path));
3954
+ return between === "" || !isAbsolute(between) && between !== ".." && !/^\.\.[\\/]/.test(between);
3955
+ }
3956
+ /** The real path of `path`, or of its deepest ancestor that exists with the rest joined on; null when none does. */
3957
+ function realpathOfExisting(path) {
3958
+ let current = resolve(path);
3959
+ const rest = [];
3960
+ for (;;) try {
3961
+ const real = realpathSync(current);
3962
+ return rest.length === 0 ? real : join(real, ...rest.reverse());
3963
+ } catch {
3964
+ const parent = dirname(current);
3965
+ if (parent === current) return null;
3966
+ rest.push(basename(current));
3967
+ current = parent;
3968
+ }
3663
3969
  }
3664
3970
  /**
3665
- * Whether `name` may be given to a new or renamed account of this kind and platform.
3666
- *
3667
- * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
3668
- * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
3669
- * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
3670
- * either kind.
3671
- *
3672
- * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
3673
- * again for version 2, because anything can happen between asking and writing.
3971
+ * The root of the suite package `path` is in — itself or any ancestor whose `package.json` names a package of this
3972
+ * suite — as written and after resolving links, or null. Every ancestor is looked at, not only the nearest package:
3973
+ * a file in `packages/gmail/node_modules/x` is still inside Gmail's root.
3674
3974
  */
3675
- function nameAvailable(config, kind, name, platform) {
3676
- const refuse = (error) => ({
3677
- ok: false,
3678
- error
3679
- });
3680
- if (config.version === 1) {
3681
- if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
3682
- 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." }));
3683
- 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." }));
3684
- return { ok: true };
3685
- }
3686
- const problem = nameShapeProblem(name, platform);
3687
- if (problem) return refuse(new CommsError("USAGE", problem));
3688
- if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3689
- for (const map of ["inboxes", "accounts"]) {
3690
- const former = own$1(config.formerNames[map], name);
3691
- if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
3692
- hint: "A former name keeps pointing people at the account that had it. Choose another name.",
3693
- details: {
3694
- formerName: name,
3695
- id: former.id
3696
- }
3697
- }));
3975
+ function suitePackageRootOf(path) {
3976
+ const candidates = [resolve(path)];
3977
+ const real = realpathOfExisting(path);
3978
+ if (real !== null && real !== candidates[0]) candidates.push(real);
3979
+ for (const candidate of candidates) {
3980
+ let dir = candidate;
3981
+ for (;;) {
3982
+ const name = readPackageManifest(dir)?.name;
3983
+ if (typeof name === "string" && name.startsWith("@agentcomms/")) return dir;
3984
+ const parent = dirname(dir);
3985
+ if (parent === dir) break;
3986
+ dir = parent;
3987
+ }
3698
3988
  }
3699
- return { ok: true };
3989
+ return null;
3700
3990
  }
3701
3991
  /**
3702
3992
  * Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
@@ -4332,10 +4622,126 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
4332
4622
  };
4333
4623
  }
4334
4624
  function packageFrom(server) {
4335
- const line = [server.command, ...server.args].join(" ");
4625
+ return commandLinePackage(server.command, server.args);
4626
+ }
4627
+ /**
4628
+ * The npm package an entry's command line launches, when one can be read from it: `@scope/name`, or a bare `…-mcp`.
4629
+ * Read from the words left once the suite's path options are taken out (`withoutPathOptions`): a pinned folder such as
4630
+ * `/tmp/@agentcomms/slack` is not a package, and read as one it made a Gmail entry Slack's.
4631
+ */
4632
+ function commandLinePackage(command, args) {
4633
+ const line = [command, ...withoutPathOptions(args).words].join(" ");
4336
4634
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
4337
4635
  }
4338
4636
  /**
4637
+ * A command for a person to run that is not this suite's: `chmod`, or an MCP client's own `claude mcp …` and
4638
+ * `codex mcp …` (design 2026-10-04, D5).
4639
+ *
4640
+ * Every command printed for a person is one of two kinds. One of this suite's own CLIs is a `PrintedCommand`, which
4641
+ * only the locator makes (`cli-command.ts`): it names this Node and a checked file of the product, because a bare
4642
+ * `agent-gmail` is not on the PATH of most people it is printed for (CUE-403). Anything else is an `ExternalCommand`,
4643
+ * made here and nowhere else, with the reason that program is one a person may be told to run.
4644
+ *
4645
+ * So that this is not a way round the locator, `externalCommand` refuses words that would start one of this suite's
4646
+ * products by another route: a word containing one of the suite's commands — in any case, alone, as a path's last
4647
+ * part or inside a wrapper's payload (`sh -c "agent-gmail …"`) — a word naming a package of the suite
4648
+ * (`npx @agentcomms/gmail`), or a path inside the root of a suite package, as written or through a link
4649
+ * (`node …/packages/gmail/dist/cli.mjs`). Every word is looked at, not only the first: the program is often a wrapper.
4650
+ *
4651
+ * This module is a leaf: it reads the manifests' facts and the files a path names, and imports nothing that registers,
4652
+ * scans or locates, so nothing it refuses can be routed back through it.
4653
+ */
4654
+ /** The gate a constructor call has to pass: only this module holds it. */
4655
+ const GATE$1 = Symbol("externalCommand");
4656
+ /**
4657
+ * One reviewed command of a program that is not this suite's. Rendered as every printed command is — a line to paste,
4658
+ * or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`, `commandText`).
4659
+ *
4660
+ * A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
4661
+ * a spread copy of one with other words in it. Only the type is exported; the class is not.
4662
+ */
4663
+ var ExternalCommand = class {
4664
+ #reason;
4665
+ /** The words, as the program is to receive them. */
4666
+ words;
4667
+ /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
4668
+ line;
4669
+ platform;
4670
+ constructor(gate, words, platform, reason) {
4671
+ if (gate !== GATE$1) throw new TypeError("an external command is made by externalCommand, and only there");
4672
+ this.#reason = reason;
4673
+ this.words = Object.freeze([...words]);
4674
+ this.line = quoteCommand(this.words, platform).line;
4675
+ this.platform = platform;
4676
+ Object.freeze(this);
4677
+ }
4678
+ /** Why this program, which is not part of this suite, is one a person may be told to run. */
4679
+ get reason() {
4680
+ return this.#reason;
4681
+ }
4682
+ /** As JSON, the text it always was: its line, or its words as JSON with what to do (as a `PrintedCommand`). */
4683
+ toJSON() {
4684
+ return quotedText(this);
4685
+ }
4686
+ /** Never interpolated: rendered with `commandText` or `inlineCommand`. */
4687
+ [Symbol.toPrimitive]() {
4688
+ throw new TypeError("an external command is rendered with commandText or inlineCommand, never interpolated");
4689
+ }
4690
+ };
4691
+ /** A package specifier of the suite, with either separator: `@agentcomms/gmail`, `node_modules\@agentcomms\gmail`. */
4692
+ const SUITE_PACKAGE = /@agentcomms[\\/]/i;
4693
+ /**
4694
+ * The parts of a word a shell or a wrapper could take as a command name: split wherever a character could not be part
4695
+ * of one, so `sh -c "cd x && agent-gmail approve"`, `--run=agent-gmail` and `C:\npm\agent-gmail.cmd` each yield
4696
+ * `agent-gmail`. Compared without case, as Windows compares executable names; on a case-sensitive system that refuses
4697
+ * a little more than it must, and nothing that it should not.
4698
+ */
4699
+ function commandNames(word) {
4700
+ return word.split(/[^A-Za-z0-9._-]+/).map((part) => part.replace(/^\.+|\.+$/g, "").replace(EXECUTABLE_EXTENSION, "").toLowerCase()).filter(Boolean);
4701
+ }
4702
+ /**
4703
+ * The paths in a word: the word itself, and each part of it between spaces, quotes, `=` and a shell's separators, that
4704
+ * is absolute, `~/…`, or a `file:` URL. A relative path is not resolved: the folder a person pastes the command in is
4705
+ * not known here, and a server called `7/gmail` is a name, not a path.
4706
+ */
4707
+ function pathsIn(word) {
4708
+ const paths = [];
4709
+ for (const part of [word, ...word.split(/[\s"'`=;&|<>(),]+/)]) {
4710
+ if (part.length === 0) continue;
4711
+ if (/^file:/i.test(part)) try {
4712
+ paths.push(fileURLToPath(part));
4713
+ } catch {}
4714
+ else if (part === "~" || /^~[\\/]/.test(part)) paths.push(join(homedir(), part.slice(2)));
4715
+ else if (isAbsolute(part)) paths.push(part);
4716
+ }
4717
+ return paths;
4718
+ }
4719
+ /** Why these words must not be an external command, or null when they may be. */
4720
+ function refusal$1(words) {
4721
+ for (const [index, word] of words.entries()) {
4722
+ if (SUITE_PACKAGE.test(word)) return `word ${index} names a package of this suite`;
4723
+ for (const path of pathsIn(word)) {
4724
+ const root = suitePackageRootOf(path);
4725
+ if (root !== null) return `word ${index} is a path inside ${root}, a package of this suite`;
4726
+ }
4727
+ if (commandNames(word).some((name) => SUITE_COMMANDS.has(name))) return `word ${index} names a command of this suite`;
4728
+ }
4729
+ return null;
4730
+ }
4731
+ /**
4732
+ * The one way to make an `ExternalCommand`: `words` for a program that is not this suite's, and `reason`, why a person
4733
+ * may be told to run it. Throws a `TypeError` for words that would start a product of this suite (see the module), so
4734
+ * a call site whose words come from somewhere else — a server's name read from a client's file, a folder — says what
4735
+ * to do in prose instead when one is refused. `platform` is the shell it is quoted for.
4736
+ */
4737
+ function externalCommand(words, reason, platform = process.platform) {
4738
+ if (words.length === 0 || (words[0] ?? "").length === 0) throw new TypeError("an external command needs a program");
4739
+ if (reason.trim().length === 0) throw new TypeError("an external command needs the reason it may be run");
4740
+ const refused = refusal$1(words);
4741
+ if (refused !== null) throw new TypeError(`not an external command: ${refused}; a command of this suite comes from locateCliCommand`);
4742
+ return new ExternalCommand(GATE$1, words, platform, reason);
4743
+ }
4744
+ /**
4339
4745
  * The environment a child process is started with: `env` as it is, and on Windows also
4340
4746
  * `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
4341
4747
  * program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
@@ -4434,28 +4840,55 @@ async function resolveNode(env) {
4434
4840
  return await whichExecutable("node", env) ?? process.execPath;
4435
4841
  }
4436
4842
  function minimalEnv(context, node) {
4437
- const env = {
4438
- AGENT_COMMS_CONFIG_DIR: context.core.paths.configDir,
4439
- PATH: [
4440
- dirname(node),
4441
- "/usr/local/bin",
4442
- "/usr/bin",
4443
- "/bin"
4444
- ].join(delimiter)
4445
- };
4843
+ const env = { PATH: [
4844
+ dirname(node),
4845
+ "/usr/local/bin",
4846
+ "/usr/bin",
4847
+ "/bin"
4848
+ ].join(delimiter) };
4446
4849
  if (process.platform === "linux") for (const key of ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) {
4447
4850
  const value = context.env[key];
4448
4851
  if (value) env[key] = value;
4449
4852
  }
4450
4853
  return env;
4451
4854
  }
4855
+ /** The complete suite identity every installer-written server registration carries. Downloads are operation-local. */
4856
+ function registrationPathArgs(context) {
4857
+ const { configDir, stateDir, dataDir, secretsDir } = context.core.paths;
4858
+ return [
4859
+ "--config-dir",
4860
+ configDir,
4861
+ "--state-dir",
4862
+ stateDir,
4863
+ "--data-dir",
4864
+ dataDir,
4865
+ "--secrets-dir",
4866
+ secretsDir
4867
+ ];
4868
+ }
4452
4869
  function unscoped(packageName) {
4453
4870
  return packageName.split("/").at(-1) ?? packageName;
4454
4871
  }
4455
- function escapeRegExp$1(text) {
4872
+ function escapeRegExp$2(text) {
4456
4873
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4457
4874
  }
4458
4875
  /**
4876
+ * An MCP client's own command about one of its entries — `claude mcp get <name>`, `codex mcp remove <name>` — in
4877
+ * backticks for a hint, or, when there is none to give, `instead`.
4878
+ *
4879
+ * The client's program, so an external command (`externalCommand`), and the name is whatever the entry is called. A
4880
+ * name that is also one of this suite's commands — the core server's own default, `agentcomms`, is one — is refused
4881
+ * there, because such a word is how a suite command would get round the locator; the hint then says the same thing in
4882
+ * words rather than printing a command with that name in it.
4883
+ */
4884
+ function clientCommandHint(words, platform, instead) {
4885
+ try {
4886
+ return inlineCommand(externalCommand(words, "the MCP client's own command reads or removes an entry of its own configuration", platform));
4887
+ } catch {
4888
+ return instead;
4889
+ }
4890
+ }
4891
+ /**
4459
4892
  * The directory the managed launcher installs one product's exact version into: `<data>/runtime/<version>-<name>`.
4460
4893
  *
4461
4894
  * Exported so that everything which reads these paths back — both doctors, `prune`, the tests — builds them here
@@ -4476,11 +4909,12 @@ function managedRuntimeEntry(dataDir, packageName, version) {
4476
4909
  *
4477
4910
  * Both `runtime/0.4.0/…` (every Gmail install before the move) and `runtime/0.4.0-gmail/…` (every install
4478
4911
  * since) are read, and a prerelease keeps its own hyphen: `0.5.0-rc.1-gmail` is `0.5.0-rc.1`. Either separator,
4479
- * and allowed to start the string, so a Windows or a relative path is not missed.
4912
+ * and allowed to start the string, so a Windows or a relative path is not missed. On Windows (`platform`), whose
4913
+ * paths are not case-sensitive, the folder names are read without case; the version keeps the case it has.
4480
4914
  */
4481
- function managedRuntimeVersion(path, packageName) {
4482
- const scope = packageName.split("/").map(escapeRegExp$1).join("[/\\\\]");
4483
- return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$1(unscoped(packageName))})?[/\\\\]node_modules[/\\\\]${scope}[/\\\\]`).exec(path)?.[1] ?? null;
4915
+ function managedRuntimeVersion(path, packageName, platform) {
4916
+ const scope = packageName.split("/").map(escapeRegExp$2).join("[/\\\\]");
4917
+ return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$2(unscoped(packageName))})?[/\\\\]node_modules[/\\\\]${scope}[/\\\\]`, platform === "win32" ? "i" : "").exec(path)?.[1] ?? null;
4484
4918
  }
4485
4919
  /**
4486
4920
  * The version one argument of a registered entry pins, or null when it pins none.
@@ -4488,10 +4922,10 @@ function managedRuntimeVersion(path, packageName) {
4488
4922
  * Two launchers pin, and they look nothing alike: `managed` writes a runtime path, `npx` a package spec. Reading
4489
4923
  * only the first reported an `npx`-pinned install as current for ever. `local` pins nothing and is ignored.
4490
4924
  */
4491
- function pinnedVersion(argument, product) {
4492
- const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$1).join("|");
4493
- const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)$`);
4494
- return managedRuntimeVersion(argument, product.packageName) ?? spec.exec(argument)?.[1] ?? null;
4925
+ function pinnedVersion(argument, product, platform) {
4926
+ const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$2).join("|");
4927
+ const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)$`, platform === "win32" ? "i" : "");
4928
+ return managedRuntimeVersion(argument, product.packageName, platform) ?? spec.exec(argument)?.[1] ?? null;
4495
4929
  }
4496
4930
  /**
4497
4931
  * Whether a registered entry starts this product's server.
@@ -4501,11 +4935,20 @@ function pinnedVersion(argument, product) {
4501
4935
  * by, compared as whole runs of segments — `@agentcomms/gmail-evil/dist/cli.mjs`, `@agentcomms/gmail/dist/
4502
4936
  * index.mjs` and `packages/gmail/src/nested/cli.ts` are all near misses, and each was once accepted by a looser
4503
4937
  * match somewhere in this repository.
4938
+ *
4939
+ * Only the entry's own words are read: the suite's path options and their values are taken out first, because a pinned
4940
+ * folder that ends in `packages/slack/src/cli.ts` is a folder, not Slack (`withoutPathOptions`). On Windows
4941
+ * (`platform`), whose file names are not case-sensitive, the command's name and every path segment are compared
4942
+ * without case, as Windows itself finds them; everywhere else exactly. Left out, it compares exactly.
4504
4943
  */
4505
- function isProductServer(server, product) {
4506
- const launched = (server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, "");
4507
- if (launched && [product.binary, ...product.bins ?? []].includes(launched)) return true;
4508
- if (server.packageName) return server.packageName === product.packageName || server.packageName === product.npxPackage;
4944
+ function isProductServer(server, product, platform) {
4945
+ const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
4946
+ const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
4947
+ if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
4948
+ if (server.packageName) {
4949
+ const named = fold(server.packageName);
4950
+ return named === fold(product.packageName) || named === fold(product.npxPackage);
4951
+ }
4509
4952
  const short = unscoped(product.packageName);
4510
4953
  const entries = [
4511
4954
  [
@@ -4528,9 +4971,9 @@ function isProductServer(server, product) {
4528
4971
  ],
4529
4972
  ...product.entryFiles ?? []
4530
4973
  ];
4531
- return [server.command, ...server.args].some((part) => {
4532
- const segments = part.split(/[\\/]+/).filter(Boolean);
4533
- return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === wanted));
4974
+ return [server.command, ...withoutPathOptions(server.args).words].some((part) => {
4975
+ const segments = part.split(/[\\/]+/).filter(Boolean).map(fold);
4976
+ return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === fold(wanted)));
4534
4977
  });
4535
4978
  }
4536
4979
  /**
@@ -4626,6 +5069,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4626
5069
  const launcher = options.launcher ?? "managed";
4627
5070
  const { node } = resolved;
4628
5071
  const env = minimalEnv(context, node);
5072
+ const pathArgs = registrationPathArgs(context);
4629
5073
  const serverArgs = product.serverArgs(options);
4630
5074
  if (launcher === "npx") return {
4631
5075
  entry: {
@@ -4633,6 +5077,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4633
5077
  args: [
4634
5078
  "-y",
4635
5079
  `${product.npxPackage}@${product.version}`,
5080
+ ...pathArgs,
4636
5081
  ...product.npxArgs ?? [],
4637
5082
  ...serverArgs
4638
5083
  ],
@@ -4649,6 +5094,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4649
5094
  args: [
4650
5095
  ...entryPath.endsWith(".ts") ? ["--experimental-strip-types", "--disable-warning=ExperimentalWarning"] : [],
4651
5096
  entryPath,
5097
+ ...pathArgs,
4652
5098
  "mcp",
4653
5099
  ...serverArgs
4654
5100
  ],
@@ -4666,6 +5112,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4666
5112
  command: node,
4667
5113
  args: [
4668
5114
  ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
5115
+ ...pathArgs,
4669
5116
  "mcp",
4670
5117
  ...serverArgs
4671
5118
  ],
@@ -4680,6 +5127,7 @@ async function buildEntry(context, product, options, apply, resolved) {
4680
5127
  command: node,
4681
5128
  args: [
4682
5129
  await installManagedRuntime(context, product, product.version),
5130
+ ...pathArgs,
4683
5131
  "mcp",
4684
5132
  ...serverArgs
4685
5133
  ],
@@ -4764,13 +5212,17 @@ function snippetFor(client, name, entry) {
4764
5212
  * Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
4765
5213
  * replaced.
4766
5214
  */
4767
- function claimName(product, options, name, existing, platform) {
5215
+ function claimName(product, options, name, existing, _platform) {
4768
5216
  const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
4769
5217
  const foreign = taken.find((server) => !isProductServer(server, product));
4770
5218
  if (foreign) {
4771
5219
  const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
4772
5220
  const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
4773
- 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.` });
5221
+ throw refusedWithRemedy("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, {
5222
+ binary: product.binary,
5223
+ words: installWords(product, options, other, []),
5224
+ say: (command) => `Register this one under another name: ${command}. --force does not replace a server this did not install.`
5225
+ });
4774
5226
  }
4775
5227
  if (taken.length > 0 && !options.force) {
4776
5228
  for (const pin of PIN_OPTIONS) {
@@ -4778,14 +5230,22 @@ function claimName(product, options, name, existing, platform) {
4778
5230
  const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
4779
5231
  if (!wanted || !served || served === wanted) continue;
4780
5232
  const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
4781
- 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))}.` });
5233
+ throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
5234
+ binary: product.binary,
5235
+ words: installWords(product, options, second === name ? `${name}-2` : second, []),
5236
+ say: (command) => `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${command}.`
5237
+ });
4782
5238
  }
4783
5239
  const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
4784
5240
  const again = {
4785
5241
  ...keepNarrowing(product, options, taken).options,
4786
5242
  launcher: options.launcher ?? (npx ? "npx" : void 0)
4787
5243
  };
4788
- 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))}.` });
5244
+ throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
5245
+ binary: product.binary,
5246
+ words: installWords(product, again, name, ["--force"]),
5247
+ say: (command) => `Pass --force to replace it — that is how an upgrade reaches a client: ${command}.`
5248
+ });
4789
5249
  }
4790
5250
  return taken;
4791
5251
  }
@@ -4821,20 +5281,17 @@ function keepNarrowing(product, options, previous) {
4821
5281
  };
4822
5282
  }
4823
5283
  /**
4824
- * The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach.
5284
+ * The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach: its
5285
+ * words after the program.
4825
5286
  *
4826
5287
  * The hints used to be `mcp install --client <c> --force` whatever had been asked. Refused for `--name slack-acme
4827
5288
  * --workspace acme/slack`, the hint registered a second server under the default name, pinned to nothing — every
4828
5289
  * workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
4829
5290
  * rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
4830
5291
  * the pin and the narrowing, so they are repeated as they are.
4831
- *
4832
- * A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
4833
- * command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
4834
5292
  */
4835
- function installCommand$1(product, options, name, extra, platform) {
5293
+ function installWords(product, options, name, extra) {
4836
5294
  const words = [
4837
- product.binary,
4838
5295
  "mcp",
4839
5296
  "install",
4840
5297
  "--client",
@@ -4844,7 +5301,24 @@ function installCommand$1(product, options, name, extra, platform) {
4844
5301
  words.push(...product.serverArgs(options));
4845
5302
  if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
4846
5303
  words.push(...extra);
4847
- return shellCommand(words, platform);
5304
+ return words;
5305
+ }
5306
+ const REMEDIES = /* @__PURE__ */ new WeakMap();
5307
+ /** The remedy a refusal of `mcp install` carries, for the operation that called it to locate; undefined for any other. */
5308
+ function installRemedyOf(error) {
5309
+ return typeof error === "object" && error !== null ? REMEDIES.get(error) : void 0;
5310
+ }
5311
+ /**
5312
+ * A refusal whose remedy is another install: until the caller locates it, its hint says the words in prose — never a
5313
+ * command with a bare program.
5314
+ */
5315
+ function refusedWithRemedy(code, message, remedy, options = {}) {
5316
+ const error = new CommsError(code, message, {
5317
+ hint: remedy.say(`its own \`mcp install\` with ${remedy.words.slice(2).join(" ")}`),
5318
+ ...options.cause === void 0 ? {} : { cause: options.cause }
5319
+ });
5320
+ REMEDIES.set(error, remedy);
5321
+ return error;
4848
5322
  }
4849
5323
  /**
4850
5324
  * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
@@ -4887,16 +5361,16 @@ function capture$1(env, command, args) {
4887
5361
  * or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
4888
5362
  * Nothing codex prints is repeated, because its answer carries the entry's env.
4889
5363
  */
4890
- async function codexRegistration(env, binary, name, path, platform) {
4891
- const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
5364
+ async function codexRegistration(env, cliPath, name, path, platform) {
5365
+ const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${clientCommandHint([
4892
5366
  "codex",
4893
5367
  "mcp",
4894
5368
  "get",
4895
5369
  name
4896
- ], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
5370
+ ], 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.` });
4897
5371
  let answer;
4898
5372
  try {
4899
- answer = await capture$1(env, binary, [
5373
+ answer = await capture$1(env, cliPath, [
4900
5374
  "mcp",
4901
5375
  "get",
4902
5376
  name,
@@ -4951,15 +5425,15 @@ async function backUp(context, client, name, previous, now) {
4951
5425
  async function installTarget(context, options) {
4952
5426
  const apply = options.apply ?? true;
4953
5427
  const cliName = options.client === "claude-code" ? "claude" : options.client === "codex" ? "codex" : null;
4954
- const binary = cliName ? await findClientCli(cliName, context.env) : null;
5428
+ const cliPath = cliName ? await findClientCli(cliName, context.env) : null;
4955
5429
  const own = knownClientConfigs(context.env).find((file) => file.client === options.client)?.path;
4956
5430
  const configPath = options.client === "claude-code" ? void 0 : own;
4957
5431
  return {
4958
5432
  cliName,
4959
- binary,
5433
+ cliPath,
4960
5434
  own,
4961
5435
  configPath,
4962
- writes: apply && (cliName ? binary !== null : configPath !== void 0)
5436
+ writes: apply && (cliName ? cliPath !== null : configPath !== void 0)
4963
5437
  };
4964
5438
  }
4965
5439
  /**
@@ -4991,14 +5465,14 @@ async function preflightInstall(context, product, options) {
4991
5465
  const scan = await scanRegisteredServers(context.env);
4992
5466
  const existing = scan.servers;
4993
5467
  const target = await installTarget(context, options);
4994
- const { binary, own, configPath, writes } = target;
5468
+ const { cliPath, own, configPath, writes } = target;
4995
5469
  if (writes) {
4996
5470
  const blind = scan.unreadable.find((file) => file.path === own);
4997
5471
  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." });
4998
5472
  }
4999
5473
  let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
5000
- if (writes && binary && options.client === "codex") {
5001
- const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
5474
+ if (writes && cliPath && options.client === "codex") {
5475
+ const reported = await codexRegistration(context.env, cliPath, name, configPath ?? "codex", context.platform);
5002
5476
  if (reported) previous = claimName(product, options, name, [reported], context.platform);
5003
5477
  }
5004
5478
  const { options: effective, kept } = keepNarrowing(product, options, previous);
@@ -5043,7 +5517,7 @@ function plannedInstall(preflight) {
5043
5517
  return {
5044
5518
  writes: target.writes,
5045
5519
  cliName: target.cliName,
5046
- binary: target.binary,
5520
+ cliPath: target.cliPath,
5047
5521
  configPath: target.configPath ?? null,
5048
5522
  own: target.own ?? null,
5049
5523
  replaces: previous.map((server) => JSON.stringify([
@@ -5077,7 +5551,7 @@ function planDrift(product, client, name, planned, now) {
5077
5551
  if (planned.writes !== now.writes) {
5078
5552
  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`);
5079
5553
  else drift.push(now.writes ? "it would write the entry rather than print it" : "it would only print the entry");
5080
- } else if (planned.binary !== now.binary) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.binary}`);
5554
+ } else if (planned.cliPath !== now.cliPath) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.cliPath}`);
5081
5555
  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"}`);
5082
5556
  if (JSON.stringify(planned.replaces) !== JSON.stringify(now.replaces)) drift.push(`what is registered as "${name}" is not what it was planned to replace`);
5083
5557
  if (JSON.stringify(planned.narrowing) !== JSON.stringify(now.narrowing)) {
@@ -5123,22 +5597,23 @@ async function mcpInstall(context, product, options, planned) {
5123
5597
  ...pinsOf(planned.narrowing)
5124
5598
  };
5125
5599
  const existing = scan.servers;
5126
- const { cliName, binary, own, configPath, writes } = target;
5600
+ const { cliName, cliPath, own, configPath, writes } = target;
5127
5601
  const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
5128
5602
  if (kept.length > 0) {
5129
- const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
5603
+ const byHand = `delete "${name}" from ${configPath}`;
5604
+ const removal = options.client === "claude-code" ? clientCommandHint([
5130
5605
  "claude",
5131
5606
  "mcp",
5132
5607
  "remove",
5133
5608
  name,
5134
5609
  "--scope",
5135
5610
  "user"
5136
- ], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
5611
+ ], context.platform, byHand) : options.client === "codex" ? clientCommandHint([
5137
5612
  "codex",
5138
5613
  "mcp",
5139
5614
  "remove",
5140
5615
  name
5141
- ], context.platform)) : `delete "${name}" from ${configPath}`;
5616
+ ], context.platform, byHand) : byHand;
5142
5617
  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.`);
5143
5618
  }
5144
5619
  if (!writes) {
@@ -5166,7 +5641,7 @@ async function mcpInstall(context, product, options, planned) {
5166
5641
  let method = "printed";
5167
5642
  let applied = false;
5168
5643
  let notApplied;
5169
- if (cliName && binary && writes) {
5644
+ if (cliName && cliPath && writes) {
5170
5645
  const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
5171
5646
  const args = options.client === "claude-code" ? [
5172
5647
  "mcp",
@@ -5198,13 +5673,13 @@ async function mcpInstall(context, product, options, planned) {
5198
5673
  name
5199
5674
  ];
5200
5675
  try {
5201
- await run(context.env, binary, removal);
5676
+ await run(context.env, cliPath, removal);
5202
5677
  } catch (error) {
5203
5678
  const message = error instanceof CommsError ? error.message : String(error);
5204
5679
  if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
5205
5680
  }
5206
5681
  try {
5207
- await run(context.env, binary, args);
5682
+ await run(context.env, cliPath, args);
5208
5683
  } catch (error) {
5209
5684
  const restore = options.client === "claude-code" ? [
5210
5685
  "mcp",
@@ -5228,31 +5703,36 @@ async function mcpInstall(context, product, options, planned) {
5228
5703
  ];
5229
5704
  let restored = true;
5230
5705
  try {
5231
- await run(context.env, binary, restore);
5706
+ await run(context.env, cliPath, restore);
5232
5707
  } catch {
5233
5708
  restored = false;
5234
5709
  }
5235
- 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}"`, {
5236
- 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}.`,
5710
+ if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
5711
+ hint: "Check the client is not running, then try again.",
5237
5712
  cause: error
5238
5713
  });
5714
+ throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
5715
+ binary: product.binary,
5716
+ words: installWords(product, effective, name, []),
5717
+ say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
5718
+ }, { cause: error });
5239
5719
  }
5240
5720
  } else try {
5241
- await run(context.env, binary, args);
5721
+ await run(context.env, cliPath, args);
5242
5722
  } catch (error) {
5243
5723
  const message = error instanceof CommsError ? error.message : String(error);
5244
5724
  if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
5245
- hint: `Look at it with ${inlineCommand(shellCommand([
5725
+ hint: `Look at it with ${clientCommandHint([
5246
5726
  cliName,
5247
5727
  "mcp",
5248
5728
  "get",
5249
5729
  name
5250
- ], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
5730
+ ], 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([
5251
5731
  cliName,
5252
5732
  "mcp",
5253
5733
  "remove",
5254
5734
  name
5255
- ], context.platform))} and run this again; if not, choose another --name.`,
5735
+ ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
5256
5736
  cause: error
5257
5737
  });
5258
5738
  throw error;
@@ -5527,46 +6007,964 @@ async function pruneManagedRuntimes(context, product, options = {}) {
5527
6007
  });
5528
6008
  continue;
5529
6009
  }
5530
- if (!result.dryRun) await rm(path, { recursive: true });
5531
- result.removed.push({
5532
- path,
5533
- version
5534
- });
5535
- }
5536
- if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
5537
- const gone = result.removed.map((item) => item.path);
5538
- const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
5539
- if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
5540
- }
5541
- return result;
6010
+ if (!result.dryRun) await rm(path, { recursive: true });
6011
+ result.removed.push({
6012
+ path,
6013
+ version
6014
+ });
6015
+ }
6016
+ if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
6017
+ const gone = result.removed.map((item) => item.path);
6018
+ const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
6019
+ if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
6020
+ }
6021
+ return result;
6022
+ }
6023
+ /** Every runtime the installer handed out, or null when the record is there and cannot be read. */
6024
+ async function readHandedOut(path) {
6025
+ let text;
6026
+ try {
6027
+ text = await readFile(path, "utf8");
6028
+ } catch (error) {
6029
+ return error.code === "ENOENT" ? [] : null;
6030
+ }
6031
+ const records = [];
6032
+ for (const line of text.split("\n")) {
6033
+ if (!line.trim()) continue;
6034
+ try {
6035
+ const record = JSON.parse(line);
6036
+ if (typeof record.runtime !== "string") return null;
6037
+ records.push({
6038
+ at: String(record.at ?? ""),
6039
+ client: String(record.client ?? ""),
6040
+ name: String(record.name ?? ""),
6041
+ runtime: record.runtime,
6042
+ ...typeof record.config === "string" ? { config: record.config } : {},
6043
+ ...record.applied === true ? { applied: true } : {}
6044
+ });
6045
+ } catch {
6046
+ return null;
6047
+ }
6048
+ }
6049
+ return records;
6050
+ }
6051
+ /** A channel's registration facts, from its manifest. */
6052
+ function registrationFactsOf({ packageName, manifest }) {
6053
+ return {
6054
+ packageName,
6055
+ npxPackage: manifest.server.npxPackage,
6056
+ binary: manifest.binary,
6057
+ ...manifest.server.bins === void 0 ? {} : { bins: manifest.server.bins },
6058
+ ...manifest.server.entryFiles === void 0 ? {} : { entryFiles: manifest.server.entryFiles }
6059
+ };
6060
+ }
6061
+ /** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
6062
+ function launcherOf(server, facts, platform) {
6063
+ const fold = folding(platform);
6064
+ const parts = [server.command, ...server.args];
6065
+ if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
6066
+ if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
6067
+ const local = new RegExp("[/\\\\]packages[/\\\\][^/\\\\]+[/\\\\](?:src[/\\\\]cli\\.ts|dist[/\\\\]cli\\.mjs)$", platform === "win32" ? "i" : "");
6068
+ if (parts.some((part) => local.test(part))) return "local";
6069
+ return "other";
6070
+ }
6071
+ /** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
6072
+ function registrationVersion(server, facts, platform) {
6073
+ return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
6074
+ }
6075
+ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
6076
+ /**
6077
+ * Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
6078
+ * out, and the package its command line launches is read again from what is left rather than taken from the entry.
6079
+ * A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
6080
+ */
6081
+ function startsProduct(server, facts, platform) {
6082
+ const { words: args } = withoutPathOptions(server.args);
6083
+ const packageName = commandLinePackage(server.command, args);
6084
+ return isProductServer({
6085
+ command: server.command,
6086
+ args,
6087
+ ...packageName === void 0 ? {} : { packageName }
6088
+ }, facts, platform);
6089
+ }
6090
+ /**
6091
+ * A command's name as Windows finds it — its last path segment without `.cmd`, `.exe`, `.bat` or `.ps1` — without case
6092
+ * on Windows (`platform`), and exactly elsewhere.
6093
+ */
6094
+ function commandStem(command, platform) {
6095
+ return folding(platform)((command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
6096
+ }
6097
+ /** Whether npx runs the entry: its program is npx, or it names the product's package at a version, as npx does. */
6098
+ function isNpxRegistration(server, facts, platform) {
6099
+ return commandStem(server.command, platform) === "npx" || launcherOf(server, facts, platform) === "npx";
6100
+ }
6101
+ /**
6102
+ * The files an entry names as what it starts, as it writes them: every script among its words before `--` once the
6103
+ * path options are out (an interpreter's flags may name one too, so each is a candidate, not the answer), and its
6104
+ * program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
6105
+ */
6106
+ function registrationFiles(server, facts, platform) {
6107
+ const { words } = withoutPathOptions(server.args);
6108
+ const sentinel = words.indexOf("--");
6109
+ const options = sentinel < 0 ? words : words.slice(0, sentinel);
6110
+ const fold = folding(platform);
6111
+ return {
6112
+ 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)),
6113
+ command: [facts.binary, ...facts.bins ?? []].map(fold).includes(commandStem(server.command, platform)) ? server.command : null
6114
+ };
6115
+ }
6116
+ function folding(platform) {
6117
+ return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
6118
+ }
6119
+ /** The package version, read from package.json at build time. */
6120
+ const VERSION = "0.13.1";
6121
+ /**
6122
+ * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
6123
+ * to decide whether the release it last heard of is newer than the one running.
6124
+ *
6125
+ * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
6126
+ * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
6127
+ * all of it, so nothing that imported these from there changes.
6128
+ */
6129
+ /**
6130
+ * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
6131
+ *
6132
+ * Anything the registry answers is checked against this before it is used, because a version goes into a directory
6133
+ * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
6134
+ * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
6135
+ */
6136
+ 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-]+)*))?$/;
6137
+ function isVersion(value) {
6138
+ return typeof value === "string" && VERSION_PATTERN.test(value);
6139
+ }
6140
+ /** Two numeric strings with no leading zeros, compared as numbers of any size. */
6141
+ function compareNumeric(a, b) {
6142
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
6143
+ return a < b ? -1 : a > b ? 1 : 0;
6144
+ }
6145
+ /**
6146
+ * Semver precedence: -1, 0 or 1, or null when either is not a version.
6147
+ *
6148
+ * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
6149
+ * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
6150
+ * few packages as it can.
6151
+ */
6152
+ function compareVersions(a, b) {
6153
+ const left = VERSION_PATTERN.exec(a);
6154
+ const right = VERSION_PATTERN.exec(b);
6155
+ if (!left || !right) return null;
6156
+ for (const index of [
6157
+ 1,
6158
+ 2,
6159
+ 3
6160
+ ]) {
6161
+ const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
6162
+ if (order !== 0) return order < 0 ? -1 : 1;
6163
+ }
6164
+ const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
6165
+ const [ours, theirs] = [pre(left), pre(right)];
6166
+ if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
6167
+ for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
6168
+ const x = ours[index];
6169
+ const y = theirs[index];
6170
+ if (x === void 0) return -1;
6171
+ if (y === void 0) return 1;
6172
+ const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
6173
+ if (numericX && numericY) {
6174
+ const order = compareNumeric(x, y);
6175
+ if (order !== 0) return order < 0 ? -1 : 1;
6176
+ } else if (numericX !== numericY) return numericX ? -1 : 1;
6177
+ else if (x !== y) return x < y ? -1 : 1;
6178
+ }
6179
+ return 0;
6180
+ }
6181
+ /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
6182
+ function isBehind(version, latest) {
6183
+ if (version === latest) return false;
6184
+ const order = compareVersions(version, latest);
6185
+ return order === null ? true : order < 0;
6186
+ }
6187
+ /**
6188
+ * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
6189
+ * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
6190
+ */
6191
+ function isPrerelease(version) {
6192
+ return VERSION_PATTERN.exec(version)?.[4] !== void 0;
6193
+ }
6194
+ /**
6195
+ * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
6196
+ *
6197
+ * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
6198
+ * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
6199
+ * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
6200
+ * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
6201
+ */
6202
+ function satisfiesRange(version, range) {
6203
+ const wanted = version.replace(/^v/, "");
6204
+ if (!isVersion(wanted)) return null;
6205
+ const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
6206
+ if (alternatives.some((comparators) => comparators.length === 0)) return null;
6207
+ let any = false;
6208
+ for (const comparators of alternatives) {
6209
+ let all = true;
6210
+ for (const comparator of comparators) {
6211
+ const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
6212
+ const bound = match?.[2] ?? "";
6213
+ if (!isVersion(bound)) return null;
6214
+ const order = compareVersions(wanted, bound);
6215
+ if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
6216
+ }
6217
+ if (all) any = true;
6218
+ }
6219
+ return any;
6220
+ }
6221
+ /**
6222
+ * 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).
6223
+ *
6224
+ * A bare `agent-gmail approve …` is not runnable for most of the people it is printed for: a package run by npx, by a
6225
+ * managed runtime or from a checkout puts nothing on their PATH. So every such command names this process's Node and
6226
+ * a checked file of the product it runs, with the suite directories it uses pinned as options — and when no file can
6227
+ * be checked, there is no command at all, only a sentence saying what is missing. The caller says which package it is
6228
+ * and what to run; it never supplies the program or quotes the line.
6229
+ *
6230
+ * Three directions. A product's own CLI is found from the product itself, walking from one of its own modules to its
6231
+ * manifest: no registration is ever asked. A channel's handoff to core is found the way the channel itself finds core,
6232
+ * through its installed dependency, and only the same release. Another product's command is found among the servers
6233
+ * registered with the person's MCP clients, which the caller scanned and passes in: only a file-backed registration of
6234
+ * exactly the printing package's version, checked on disk. Nothing a registration names is run, or asked anything —
6235
+ * not even its interpreter for a version: no installer wrote down where a scanned entry came from, so none is trusted
6236
+ * to run (D1). This module never scans, spawns or probes.
6237
+ *
6238
+ * What is checked is checked when the command is printed, not for ever (D6): the file it names is real, readable and
6239
+ * inside its package then, and a later change to that installation is a later fact.
6240
+ */
6241
+ /** The gate a constructor call has to pass: only this module holds it. */
6242
+ const GATE = Symbol("locateCliCommand");
6243
+ /**
6244
+ * A command that runs one of this suite's own CLIs, made only by `locateCliCommand`. Rendered as every printed command
6245
+ * is — a line to paste, or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`,
6246
+ * `commandText`).
6247
+ *
6248
+ * A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
6249
+ * a spread copy of one with other words in it, as it would be with a symbol-keyed brand. Only the type is exported;
6250
+ * the class and its gate are not.
6251
+ */
6252
+ var PrintedCommand = class {
6253
+ #entry;
6254
+ /** The words, as the program is to receive them: this Node, its flags, the entry, then the CLI's own words. */
6255
+ words;
6256
+ /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
6257
+ line;
6258
+ platform;
6259
+ constructor(gate, words, platform, entry) {
6260
+ if (gate !== GATE) throw new TypeError("a printed command is made by locateCliCommand, and only there");
6261
+ this.#entry = entry;
6262
+ this.words = Object.freeze([...words]);
6263
+ this.line = quoteCommand(this.words, platform).line;
6264
+ this.platform = platform;
6265
+ Object.freeze(this);
6266
+ }
6267
+ /**
6268
+ * The real path of the file it runs, as it was checked when the command was located: a fact about then (D6). The
6269
+ * command names this path and nothing else, so what runs is whatever is there when the person runs it.
6270
+ */
6271
+ get entry() {
6272
+ return this.#entry;
6273
+ }
6274
+ /**
6275
+ * As JSON — a result's field written out by `--json`, or returned to an MCP client — it is the text it always was:
6276
+ * its line, or its words as JSON with what to do when no Windows line is safe (`commandText`). Rendered where it
6277
+ * leaves the process, so a field can hold the command itself and the wire stays the same.
6278
+ */
6279
+ toJSON() {
6280
+ return quotedText(this);
6281
+ }
6282
+ /** Never interpolated: a sentence gives a command through `handoffSentence`, a value through `handoffText`. */
6283
+ [Symbol.toPrimitive]() {
6284
+ throw new TypeError("a printed command is rendered with handoffText or handoffSentence, never interpolated");
6285
+ }
6286
+ };
6287
+ const STRIP_TYPES = "--experimental-strip-types";
6288
+ const TRANSFORM_TYPES = "--experimental-transform-types";
6289
+ /** The suite's packages, by name, and its channel packages, which reach core through their own dependency on it. */
6290
+ const SUITE_PACKAGES = new Set(CHANNEL_SNAPSHOT.map(({ packageName }) => packageName));
6291
+ const CHANNEL_PACKAGES$1 = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
6292
+ /**
6293
+ * Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
6294
+ *
6295
+ * The program is always this process's Node (`process.execPath`), which must satisfy the target's `engines.node`; no
6296
+ * interpreter named anywhere else is run, or asked its version. A TypeScript entry gets `--experimental-strip-types`,
6297
+ * which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
6298
+ * no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
6299
+ * CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
6300
+ * before any `--`.
6301
+ *
6302
+ * Synchronous, so a hint or a renderer can call it where it builds its sentence; for another product the caller scans
6303
+ * the registrations first and passes them in (`request.registrations`).
6304
+ */
6305
+ function locateCliCommand(request, runtime = {
6306
+ version: process.version,
6307
+ execArgv: process.execArgv
6308
+ }) {
6309
+ const { caller, target } = request;
6310
+ const [first] = request.words;
6311
+ 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`);
6312
+ const platform = request.platform ?? process.platform;
6313
+ let version = VERSION;
6314
+ const fail = (reason, detail, extra = {}) => notLocated(target, version, reason, detail, runtime.version, extra);
6315
+ let callerFile;
6316
+ try {
6317
+ callerFile = fileURLToPath(caller.url);
6318
+ } catch {
6319
+ return fail("caller", `${caller.url} is not a module on this machine`);
6320
+ }
6321
+ const callerSource = callerFile.endsWith(".ts");
6322
+ const nearest = nearestPackage(dirname(callerFile));
6323
+ const callerManifest = nearest?.manifest ?? null;
6324
+ if (nearest === null || callerManifest === null || callerManifest.name !== caller.packageName) {
6325
+ const name = typeof callerManifest?.name === "string" ? callerManifest.name : "no named package";
6326
+ return fail("manifest", `the package ${callerFile} belongs to is ${name}, not ${caller.packageName}`);
6327
+ }
6328
+ const callerVersion = typeof callerManifest.version === "string" ? callerManifest.version : null;
6329
+ if (callerVersion !== null) version = callerVersion;
6330
+ let callerRoot;
6331
+ try {
6332
+ callerRoot = realpathSync(nearest.root);
6333
+ } catch {
6334
+ return fail("caller", `${nearest.root} cannot be resolved`);
6335
+ }
6336
+ if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
6337
+ const pins = {};
6338
+ for (const key of request.uses) pins[key] = request.paths[key];
6339
+ let words;
6340
+ try {
6341
+ words = normalizePathOptionWords(request.words, pins, 0);
6342
+ } catch (error) {
6343
+ return fail("arguments", error instanceof Error ? error.message : String(error));
6344
+ }
6345
+ const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
6346
+ const located = (built, found, direction) => Object.freeze({
6347
+ ok: true,
6348
+ command: built.command,
6349
+ basis: Object.freeze({
6350
+ callerSource,
6351
+ callerRoot,
6352
+ callerVersion,
6353
+ targetRoot: found.root,
6354
+ targetVersion: typeof found.manifest.version === "string" ? found.manifest.version : null,
6355
+ callerWorkspace: null,
6356
+ targetWorkspace: null,
6357
+ entryKind: built.entryKind,
6358
+ ...direction
6359
+ })
6360
+ });
6361
+ if (caller.packageName === target.packageName) {
6362
+ const found = {
6363
+ root: callerRoot,
6364
+ manifest: callerManifest,
6365
+ source: callerSource
6366
+ };
6367
+ const built = build(found);
6368
+ if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
6369
+ return located(built, found, {
6370
+ direction: "own",
6371
+ sameCheckout: true
6372
+ });
6373
+ }
6374
+ if (target.manifest.channel === "core" && CHANNEL_PACKAGES$1.has(caller.packageName)) {
6375
+ const installed = installedPackage(dirname(callerFile), target.packageName);
6376
+ if (installed === null) return fail("core", `${caller.packageName} has no ${target.packageName} installed where it can find one`);
6377
+ const manifest = readPackageManifest(installed);
6378
+ if (manifest === null || manifest.name !== target.packageName) return fail("manifest", `the package found as ${target.packageName}, at ${installed}, is not that package`);
6379
+ if (manifest.version !== version) {
6380
+ const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
6381
+ return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
6382
+ }
6383
+ const callerWorkspace = workspaceOf(callerRoot);
6384
+ const targetWorkspace = workspaceOf(installed);
6385
+ const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
6386
+ const found = {
6387
+ root: installed,
6388
+ manifest,
6389
+ source: callerSource && sameCheckout
6390
+ };
6391
+ const built = build(found);
6392
+ if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
6393
+ return located(built, found, {
6394
+ direction: "channel-to-core",
6395
+ callerWorkspace,
6396
+ targetWorkspace,
6397
+ sameCheckout
6398
+ });
6399
+ }
6400
+ if (!SUITE_PACKAGES.has(caller.packageName)) return fail("direction", `${caller.packageName} is not a package of this suite`);
6401
+ if (request.registrations === void 0) return fail("no-registrations", "nothing registered was given to look through for another product's command");
6402
+ const facts = registrationFactsOf(target);
6403
+ const notUsed = [];
6404
+ const usable = [];
6405
+ let engineRange;
6406
+ for (const server of request.registrations) {
6407
+ if (!startsProduct(server, facts, platform)) continue;
6408
+ const reject = (why) => notUsed.push(Object.freeze({
6409
+ client: server.client,
6410
+ path: server.path,
6411
+ name: server.name,
6412
+ why
6413
+ }));
6414
+ if (isNpxRegistration(server, facts, platform)) {
6415
+ reject("npx");
6416
+ continue;
6417
+ }
6418
+ if (withoutPathOptions(server.args).dangling) {
6419
+ reject("arguments");
6420
+ continue;
6421
+ }
6422
+ const launcher = launcherOf(server, facts, platform);
6423
+ const files = registrationFiles(server, facts, platform);
6424
+ if (![...files.scripts, ...files.command === null ? [] : [files.command]].some((file) => isAbsolute(file))) {
6425
+ reject("no-file");
6426
+ continue;
6427
+ }
6428
+ const found = registeredPackage(files, target, facts);
6429
+ if (found === null) {
6430
+ reject("no-package");
6431
+ continue;
6432
+ }
6433
+ const managedAt = launcher === "managed" ? registrationVersion(server, facts, platform) : version;
6434
+ if (found.manifest.version !== version || managedAt !== version) {
6435
+ reject("version");
6436
+ continue;
6437
+ }
6438
+ const built = build(found);
6439
+ if (!built.ok) {
6440
+ reject(built.reason);
6441
+ if (built.reason === "engine") engineRange ??= built.nodeRange;
6442
+ continue;
6443
+ }
6444
+ usable.push({
6445
+ server,
6446
+ launcher,
6447
+ built,
6448
+ found
6449
+ });
6450
+ }
6451
+ const rank = ({ server, launcher }) => [
6452
+ launcher === "managed" ? "0" : "1",
6453
+ server.client,
6454
+ server.path,
6455
+ server.name,
6456
+ server.command,
6457
+ ...server.args
6458
+ ];
6459
+ const [best] = usable.sort((a, b) => compareWords(rank(a), rank(b)));
6460
+ if (best === void 0) {
6461
+ if (engineRange !== void 0) return fail("engine", `it needs Node ${engineRange}, and this is Node ${runtime.version}`, {
6462
+ nodeRange: engineRange,
6463
+ registrations: notUsed
6464
+ });
6465
+ return fail("not-registered", notRegistered(notUsed, version), { registrations: notUsed });
6466
+ }
6467
+ const { client, path, name } = best.server;
6468
+ return located(best.built, best.found, {
6469
+ direction: "cross-product",
6470
+ sameCheckout: false,
6471
+ registration: Object.freeze({
6472
+ client,
6473
+ path,
6474
+ name,
6475
+ launcher: best.launcher
6476
+ })
6477
+ });
6478
+ }
6479
+ /**
6480
+ * Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
6481
+ * CLI or the manifest's bin for the command it declares — as a readable file inside its package.
6482
+ */
6483
+ function buildCommand(target, found, words, runtime, callerSource, platform) {
6484
+ const engines = found.manifest.engines;
6485
+ const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
6486
+ if (typeof range !== "string") return {
6487
+ ok: false,
6488
+ reason: "engine",
6489
+ detail: `${target.packageName} at ${found.root} declares no Node range this can check`
6490
+ };
6491
+ if (satisfiesRange(runtime.version, range) !== true) return {
6492
+ ok: false,
6493
+ reason: "engine",
6494
+ detail: `it needs Node ${range}, and this is Node ${runtime.version}`,
6495
+ nodeRange: range
6496
+ };
6497
+ let entry;
6498
+ if (found.source) entry = join(found.root, "src", "cli.ts");
6499
+ else {
6500
+ const declared = found.manifest.agentcomms;
6501
+ const binary = typeof declared === "object" && declared !== null ? declared.binary : void 0;
6502
+ const bin = found.manifest.bin;
6503
+ const path = binary === target.manifest.binary && typeof bin === "object" && bin !== null && Object.hasOwn(bin, target.manifest.binary) ? bin[target.manifest.binary] : void 0;
6504
+ if (typeof path !== "string" || path.length === 0) return {
6505
+ ok: false,
6506
+ reason: "bin",
6507
+ detail: `the bin of ${target.packageName} at ${found.root} does not name the command it declares`
6508
+ };
6509
+ entry = resolve(found.root, path);
6510
+ }
6511
+ const checked = checkedEntry(found.root, entry);
6512
+ if (checked.ok === false) return {
6513
+ ok: false,
6514
+ reason: "entry",
6515
+ detail: checked.why
6516
+ };
6517
+ const flags = /\.[cm]?ts$/.test(checked.path) ? [STRIP_TYPES, ...callerSource && runtime.execArgv.includes(TRANSFORM_TYPES) ? [TRANSFORM_TYPES] : []] : [];
6518
+ return {
6519
+ ok: true,
6520
+ command: new PrintedCommand(GATE, [
6521
+ process.execPath,
6522
+ ...flags,
6523
+ checked.path,
6524
+ ...words
6525
+ ], platform, checked.path),
6526
+ entryKind: found.source ? "source" : "bin"
6527
+ };
6528
+ }
6529
+ /**
6530
+ * The target's package a registration's files lead to, or null: a script it starts, real or as written when it is
6531
+ * gone, inside the package (or inside the product's own server wrapper, which depends on it); or, for the product's
6532
+ * own command, the package that command links into or that npm installed beside it.
6533
+ */
6534
+ function registeredPackage(files, target, facts) {
6535
+ for (const script of files.scripts) {
6536
+ if (!isAbsolute(script)) continue;
6537
+ const found = packageAround(script, target, facts);
6538
+ if (found !== null) {
6539
+ const source = join(found.root, "src", "cli.ts");
6540
+ const real = realpathOfExisting(script);
6541
+ return {
6542
+ ...found,
6543
+ source: real !== null && sameFile(real, source)
6544
+ };
6545
+ }
6546
+ }
6547
+ const { command } = files;
6548
+ if (command === null || !isAbsolute(command)) return null;
6549
+ const linked = packageAround(command, target, facts);
6550
+ if (linked !== null) return {
6551
+ ...linked,
6552
+ source: false
6553
+ };
6554
+ const dir = dirname(command);
6555
+ for (const candidate of [join(dir, "node_modules"), join(dir, "..", "lib", "node_modules")]) {
6556
+ let root;
6557
+ try {
6558
+ root = realpathSync(join(candidate, ...target.packageName.split("/")));
6559
+ } catch {
6560
+ continue;
6561
+ }
6562
+ const manifest = readPackageManifest(root);
6563
+ if (manifest?.name === target.packageName) return {
6564
+ root,
6565
+ manifest,
6566
+ source: false
6567
+ };
6568
+ }
6569
+ return null;
6570
+ }
6571
+ /** The target's package that `file` is in — directly, or through the product's server wrapper, which depends on it. */
6572
+ function packageAround(file, target, facts) {
6573
+ const real = realpathOfExisting(file);
6574
+ if (real === null) return null;
6575
+ const nearest = nearestPackage(dirname(real));
6576
+ if (nearest === null || nearest.manifest === null) return null;
6577
+ let root;
6578
+ try {
6579
+ root = realpathSync(nearest.root);
6580
+ } catch {
6581
+ return null;
6582
+ }
6583
+ if (nearest.manifest.name === target.packageName) return {
6584
+ root,
6585
+ manifest: nearest.manifest
6586
+ };
6587
+ if (facts.npxPackage === target.packageName || nearest.manifest.name !== facts.npxPackage) return null;
6588
+ const installed = installedPackage(root, target.packageName);
6589
+ const manifest = installed === null ? null : readPackageManifest(installed);
6590
+ return installed !== null && manifest?.name === target.packageName ? {
6591
+ root: installed,
6592
+ manifest
6593
+ } : null;
6594
+ }
6595
+ function sameFile(a, b) {
6596
+ try {
6597
+ return realpathSync(a) === realpathSync(b);
6598
+ } catch {
6599
+ return false;
6600
+ }
6601
+ }
6602
+ /** Two lists of words in order: the first that differs decides, and a list that runs out first comes first. */
6603
+ function compareWords(a, b) {
6604
+ for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
6605
+ const [x, y] = [a[index], b[index]];
6606
+ if (x !== y) return x < y ? -1 : 1;
6607
+ }
6608
+ return a.length - b.length;
6609
+ }
6610
+ /** Why no registration of another product could be used, in a clause: how many were looked at, and what each was. */
6611
+ function notRegistered(notUsed, version) {
6612
+ if (notUsed.length === 0) return "no MCP client on this machine has it registered";
6613
+ const said = {
6614
+ npx: "run by npx, which is never used for this",
6615
+ arguments: "with a path option that has no value",
6616
+ "no-file": "not started from a file named by its full path",
6617
+ "no-package": "whose package cannot be found or read",
6618
+ version: "of another or an unknown version",
6619
+ engine: "whose Node range this Node is outside, or which declares none",
6620
+ bin: "whose package does not name its command",
6621
+ entry: "whose command file is missing, unreadable or outside its package"
6622
+ };
6623
+ const counts = /* @__PURE__ */ new Map();
6624
+ for (const { why } of notUsed) counts.set(why, (counts.get(why) ?? 0) + 1);
6625
+ const parts = [...counts].map(([why, count]) => `${count} ${said[why]}`);
6626
+ 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("; ")})`;
6627
+ }
6628
+ function notLocated(target, version, reason, detail, nodeVersion, extra) {
6629
+ const named = `${target.manifest.label} ${version} (${target.packageName})`;
6630
+ const { nodeRange, registrations } = extra;
6631
+ 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.`;
6632
+ const notLocated = {
6633
+ ok: false,
6634
+ reason,
6635
+ product: target.manifest.label,
6636
+ package: target.packageName,
6637
+ version,
6638
+ ...nodeRange === void 0 ? {} : { nodeRange },
6639
+ ...registrations === void 0 ? {} : { registrations: Object.freeze([...registrations]) },
6640
+ detail,
6641
+ message
6642
+ };
6643
+ Object.defineProperties(notLocated, {
6644
+ toJSON: { value: () => message },
6645
+ [Symbol.toPrimitive]: { value: () => {
6646
+ throw new TypeError("a missing command is said with handoffText or handoffSentence, never interpolated");
6647
+ } }
6648
+ });
6649
+ return Object.freeze(notLocated);
6650
+ }
6651
+ /**
6652
+ * The entry's real path, when it is a readable regular file inside its package's real root by whole segments — as
6653
+ * written and after links are resolved — or why not.
6654
+ *
6655
+ * Checked now, for the command printed now (D6). This does not make the path durable: an npx cache evicted, a global
6656
+ * install upgraded in place or a file replaced after this is what the command meets when it runs — a missing file
6657
+ * fails with Node's own error naming it, a replaced one runs as it then is. Nothing here, or in the command, looks
6658
+ * anywhere else for the product, PATH included, and a later locate checks the path again.
6659
+ */
6660
+ function checkedEntry(root, entry) {
6661
+ if (!isWithin(root, entry)) return {
6662
+ ok: false,
6663
+ why: `${entry} is outside its package, ${root}`
6664
+ };
6665
+ let real;
6666
+ try {
6667
+ real = realpathSync(entry);
6668
+ } catch {
6669
+ return {
6670
+ ok: false,
6671
+ why: `${entry} is not there`
6672
+ };
6673
+ }
6674
+ if (!isWithin(root, real)) return {
6675
+ ok: false,
6676
+ why: `${entry} leads to ${real}, outside its package, ${root}`
6677
+ };
6678
+ try {
6679
+ if (!statSync(real).isFile()) return {
6680
+ ok: false,
6681
+ why: `${entry} is not a file`
6682
+ };
6683
+ accessSync(real, constants$1.R_OK);
6684
+ } catch {
6685
+ return {
6686
+ ok: false,
6687
+ why: `${entry} cannot be read`
6688
+ };
6689
+ }
6690
+ return {
6691
+ ok: true,
6692
+ path: real
6693
+ };
6694
+ }
6695
+ /**
6696
+ * The real root of the package `name` installed for a module in `start`, found as Node's own resolution finds one: in
6697
+ * `node_modules` of each directory from there up. Not resolved through its `exports` — core's point at a build, and
6698
+ * which entry to run is decided here — and never from a global folder, which `import` does not read either.
6699
+ */
6700
+ function installedPackage(start, name) {
6701
+ let dir = resolve(start);
6702
+ for (;;) {
6703
+ if (basename(dir) !== "node_modules") {
6704
+ const candidate = join(dir, "node_modules", ...name.split("/"));
6705
+ try {
6706
+ if (statSync(candidate).isDirectory()) return realpathSync(candidate);
6707
+ } catch {}
6708
+ }
6709
+ const parent = dirname(dir);
6710
+ if (parent === dir) return null;
6711
+ dir = parent;
6712
+ }
6713
+ }
6714
+ /**
6715
+ * The real repository checkout whose workspace lists `root` as one of its packages, or null: the nearest
6716
+ * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
6717
+ * installed copy in its `node_modules` — is not a workspace package of it.
6718
+ */
6719
+ function workspaceOf(root) {
6720
+ let dir = dirname(root);
6721
+ for (;;) {
6722
+ const globs = workspaceGlobs(dir);
6723
+ if (globs !== null) return inWorkspace(relative(dir, root).split(sep), globs) ? dir : null;
6724
+ const parent = dirname(dir);
6725
+ if (parent === dir) return null;
6726
+ dir = parent;
6727
+ }
6728
+ }
6729
+ /** The `packages` globs of `dir/pnpm-workspace.yaml`, or null when there is no such file. */
6730
+ function workspaceGlobs(dir) {
6731
+ let text;
6732
+ try {
6733
+ text = readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8");
6734
+ } catch (error) {
6735
+ const code = error.code;
6736
+ return code === "ENOENT" || code === "ENOTDIR" ? null : [];
6737
+ }
6738
+ const unquote = (glob) => glob.trim().replace(/^(['"])(.*)\1$/, "$2");
6739
+ const lines = text.split(/\r?\n/);
6740
+ const at = lines.findIndex((line) => /^packages\s*:/.test(line));
6741
+ if (at < 0) return [];
6742
+ const inline = /^packages\s*:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[at]);
6743
+ if (inline) return (inline[1] ?? "").split(",").map(unquote).filter(Boolean);
6744
+ const globs = [];
6745
+ for (const line of lines.slice(at + 1)) {
6746
+ if (/^\s*(?:#.*)?$/.test(line)) continue;
6747
+ const item = /^\s+-\s*(.*?)\s*(?:#.*)?$/.exec(line);
6748
+ if (!item) break;
6749
+ globs.push(unquote(item[1] ?? ""));
6750
+ }
6751
+ return globs;
6752
+ }
6753
+ /** Whether a path, as segments from the workspace root, is one of its packages: some glob takes it and no `!` one. */
6754
+ function inWorkspace(segments, globs) {
6755
+ const matches = (glob) => segmentsMatch(segments, glob.replace(/^\.\//, "").replace(/\/+$/, "").split("/").filter((part) => part !== "."));
6756
+ const included = globs.filter((glob) => !glob.startsWith("!"));
6757
+ const excluded = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
6758
+ return included.some(matches) && !excluded.some(matches);
6759
+ }
6760
+ function segmentsMatch(path, pattern) {
6761
+ const [head, ...rest] = pattern;
6762
+ if (head === void 0) return path.length === 0;
6763
+ if (head === "**") return segmentsMatch(path, rest) || path.length > 0 && segmentsMatch(path.slice(1), pattern);
6764
+ const [segment] = path;
6765
+ if (segment === void 0) return false;
6766
+ return new RegExp(`^${head.split("*").map(escapeRegExp$1).join("[^/]*")}$`).test(segment) && segmentsMatch(path.slice(1), rest);
6767
+ }
6768
+ function escapeRegExp$1(text) {
6769
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6770
+ }
6771
+ /**
6772
+ * The commands a package tells a person to run, made once per printing process (design 2026-10-04, D1 and D5;
6773
+ * CUE-403). How a channel migrates a handoff to these is in CONTRIBUTING.md, "Telling a person what to run".
6774
+ *
6775
+ * A package makes them from where it is — a module of its own and its package name, the caller — with the folders and
6776
+ * shell of the process printing: `openCore({ caller })` puts them on `core.handoffs`. Each method takes the CLI's own
6777
+ * words, never the program, and returns a `Handoff`: a located `PrintedCommand`, or why there is none here. Nothing
6778
+ * here quotes a line by hand, names a bare binary, or guesses an installation.
6779
+ */
6780
+ /** The suite folders every CLI command opens: what a command is pinned to unless it says otherwise. */
6781
+ const HANDOFF_FOLDERS = Object.freeze([
6782
+ "configDir",
6783
+ "stateDir",
6784
+ "dataDir",
6785
+ "secretsDir"
6786
+ ]);
6787
+ Object.freeze({
6788
+ url: import.meta.url,
6789
+ packageName: "@agentcomms/core"
6790
+ });
6791
+ /** A channel of this release, by its word; a programming error for a word that is none. */
6792
+ function entryOf(channel) {
6793
+ const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
6794
+ if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
6795
+ return entry;
6796
+ }
6797
+ /** The folders a command is pinned to. */
6798
+ function usesOf(use) {
6799
+ return [...use?.uses ?? HANDOFF_FOLDERS, ...use?.downloads ? ["downloadsDir"] : []];
6800
+ }
6801
+ /** The commands a package prints, located from `caller` (CONTRIBUTING.md, "Telling a person what to run"). */
6802
+ function cliHandoffs(options) {
6803
+ const { caller, paths, registrations } = options;
6804
+ const platform = options.platform ?? process.platform;
6805
+ const own = CHANNEL_SNAPSHOT.find((entry) => entry.packageName === caller.packageName);
6806
+ if (own === void 0) throw new TypeError(`${caller.packageName} is not a package of this suite`);
6807
+ const locate = (target, words, use) => {
6808
+ const result = locateCliCommand({
6809
+ caller,
6810
+ target,
6811
+ words,
6812
+ uses: usesOf(use),
6813
+ paths,
6814
+ platform,
6815
+ ...registrations === void 0 ? {} : { registrations }
6816
+ }, ...options.runtime === void 0 ? [] : [options.runtime]);
6817
+ return result.ok ? result.command : result;
6818
+ };
6819
+ const handoffs = Object.freeze({
6820
+ caller,
6821
+ platform,
6822
+ own: (words, use) => locate(own, words, use),
6823
+ core: (words, use) => locate(entryOf("core"), words, use),
6824
+ of: (channel, words, use) => locate(entryOf(channel), words, use),
6825
+ on: (other) => other === platform ? handoffs : cliHandoffs({
6826
+ ...options,
6827
+ platform: other
6828
+ }),
6829
+ registered: async () => cliHandoffs({
6830
+ ...options,
6831
+ registrations: (await scanRegisteredServers(options.env ?? process.env, platform)).servers
6832
+ }),
6833
+ withRegistrations: (servers) => cliHandoffs({
6834
+ ...options,
6835
+ registrations: servers
6836
+ })
6837
+ });
6838
+ return handoffs;
6839
+ }
6840
+ /**
6841
+ * `core.handoffs` — quoted for `platform` when one is given — for anything that prints a command. A programming error
6842
+ * when core was opened without its caller: the package has to give it one (`openCore({ caller })`), and there is no
6843
+ * bare name to print instead.
6844
+ */
6845
+ function requireHandoffs(core, platform) {
6846
+ const handoffs = requiredHandoffs(core.handoffs);
6847
+ return platform === void 0 ? handoffs : handoffs.on(platform);
6848
+ }
6849
+ const MAP = {
6850
+ inbox: "inboxes",
6851
+ account: "accounts"
6852
+ };
6853
+ /**
6854
+ * An own property only.
6855
+ *
6856
+ * A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
6857
+ * of an inbox called `constructor`, which version 1 allows, found it.
6858
+ */
6859
+ function own$1(map, key) {
6860
+ return Object.hasOwn(map, key) ? map[key] : void 0;
6861
+ }
6862
+ function resolveName(config, kind, name, notFound) {
6863
+ if (kind === "inbox") {
6864
+ const inbox = own$1(config.inboxes, name);
6865
+ if (inbox) return {
6866
+ alias: name,
6867
+ inbox
6868
+ };
6869
+ } else {
6870
+ const account = own$1(config.accounts, name);
6871
+ if (account) return {
6872
+ alias: name,
6873
+ account
6874
+ };
6875
+ }
6876
+ const renamed = formerNameRefusal(config, kind, name);
6877
+ if (renamed) throw renamed;
6878
+ throw notFound?.() ?? defaultNotFound(config, kind, name);
6879
+ }
6880
+ /** What to say when there are no mailboxes at all: how one is connected, in words (`connectMailboxWords`). */
6881
+ function noInboxesHint() {
6882
+ const connect = connectMailboxWords();
6883
+ return connect ? `No inboxes yet: add one with ${connect}, at a terminal or from a chat.` : "No inboxes yet.";
6884
+ }
6885
+ function defaultNotFound(config, kind, name) {
6886
+ if (kind === "inbox") {
6887
+ const known = Object.keys(config.inboxes);
6888
+ return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
6889
+ }
6890
+ return new CommsError("NOT_FOUND", `no account called "${name}"`);
6891
+ }
6892
+ function lookupName(config, kind, name) {
6893
+ return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
6894
+ }
6895
+ function findById(config, kind, id) {
6896
+ if (kind === "inbox") {
6897
+ for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
6898
+ alias,
6899
+ inbox
6900
+ };
6901
+ } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
6902
+ alias,
6903
+ account
6904
+ };
6905
+ return null;
6906
+ }
6907
+ /**
6908
+ * The refusal for a former name, or null when `name` is not one.
6909
+ *
6910
+ * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
6911
+ * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
6912
+ * name that now belongs to nothing.
6913
+ */
6914
+ function formerNameRefusal(config, kind, name) {
6915
+ if (config.version !== 2) return null;
6916
+ const former = own$1(config.formerNames[MAP[kind]], name);
6917
+ if (!former) return null;
6918
+ const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
6919
+ if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
6920
+ formerName: name,
6921
+ id: former.id
6922
+ } });
6923
+ return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
6924
+ hint: `Use "${current.alias}".`,
6925
+ details: {
6926
+ formerName: name,
6927
+ currentName: current.alias,
6928
+ id: former.id
6929
+ }
6930
+ });
5542
6931
  }
5543
- /** Every runtime the installer handed out, or null when the record is there and cannot be read. */
5544
- async function readHandedOut(path) {
5545
- let text;
5546
- try {
5547
- text = await readFile(path, "utf8");
5548
- } catch (error) {
5549
- return error.code === "ENOENT" ? [] : null;
6932
+ /**
6933
+ * Whether `name` may be given to a new or renamed account of this kind and platform.
6934
+ *
6935
+ * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
6936
+ * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
6937
+ * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
6938
+ * either kind.
6939
+ *
6940
+ * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
6941
+ * again for version 2, because anything can happen between asking and writing.
6942
+ */
6943
+ function nameAvailable(config, kind, name, platform) {
6944
+ const refuse = (error) => ({
6945
+ ok: false,
6946
+ error
6947
+ });
6948
+ if (config.version === 1) {
6949
+ if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
6950
+ 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." }));
6951
+ 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." }));
6952
+ return { ok: true };
5550
6953
  }
5551
- const records = [];
5552
- for (const line of text.split("\n")) {
5553
- if (!line.trim()) continue;
5554
- try {
5555
- const record = JSON.parse(line);
5556
- if (typeof record.runtime !== "string") return null;
5557
- records.push({
5558
- at: String(record.at ?? ""),
5559
- client: String(record.client ?? ""),
5560
- name: String(record.name ?? ""),
5561
- runtime: record.runtime,
5562
- ...typeof record.config === "string" ? { config: record.config } : {},
5563
- ...record.applied === true ? { applied: true } : {}
5564
- });
5565
- } catch {
5566
- return null;
5567
- }
6954
+ const problem = nameShapeProblem(name, platform);
6955
+ if (problem) return refuse(new CommsError("USAGE", problem));
6956
+ if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
6957
+ for (const map of ["inboxes", "accounts"]) {
6958
+ const former = own$1(config.formerNames[map], name);
6959
+ if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
6960
+ hint: "A former name keeps pointing people at the account that had it. Choose another name.",
6961
+ details: {
6962
+ formerName: name,
6963
+ id: former.id
6964
+ }
6965
+ }));
5568
6966
  }
5569
- return records;
6967
+ return { ok: true };
5570
6968
  }
5571
6969
  /**
5572
6970
  * A pattern for each package a channel's manifest names as a rival (`rivals.packages`).
@@ -5577,36 +6975,45 @@ async function readHandedOut(path) {
5577
6975
  function rivalPatterns(packages) {
5578
6976
  return packages.map(({ name, unscoped }) => {
5579
6977
  const bare = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name;
5580
- const whole = name.startsWith("@") ? escapeRegExp$2(name) : `(?<![\\w@/-])${escapeRegExp$2(name)}`;
5581
- const alone = unscoped && name.startsWith("@") ? `|(?<![\\w@/-])${escapeRegExp$2(bare)}` : "";
6978
+ const whole = name.startsWith("@") ? escapeRegExp$3(name) : `(?<![\\w@/-])${escapeRegExp$3(name)}`;
6979
+ const alone = unscoped && name.startsWith("@") ? `|(?<![\\w@/-])${escapeRegExp$3(bare)}` : "";
5582
6980
  return {
5583
6981
  pattern: new RegExp(`${whole}${alone}`),
5584
6982
  name
5585
6983
  };
5586
6984
  });
5587
6985
  }
5588
- function escapeRegExp$2(text) {
6986
+ function escapeRegExp$3(text) {
5589
6987
  return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
5590
6988
  }
5591
6989
  /**
5592
6990
  * The command that removes a registered server, in its client's own terms — the name as the client's file has it,
5593
6991
  * which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
5594
- * (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
6992
+ * (`quoteCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
6993
+ *
6994
+ * The client's own command, so an external one (`externalCommand`). A name that is also one of this suite's commands,
6995
+ * or a path into one of its packages, is refused there, since such words are how a suite command would get round the
6996
+ * locator; that entry is removed by hand instead, from the file that holds it.
5595
6997
  */
5596
- function removalCommand(client, name, platform) {
5597
- return commandText(shellCommand([
5598
- client,
5599
- "mcp",
5600
- "remove",
5601
- name
5602
- ], platform));
6998
+ function removalCommand(client, server, platform) {
6999
+ try {
7000
+ return remedy(externalCommand([
7001
+ client,
7002
+ "mcp",
7003
+ "remove",
7004
+ server.name
7005
+ ], "the MCP client's own command removes an entry from its own configuration", platform));
7006
+ } catch {
7007
+ return remedy(`remove "${server.name}" from ${server.path} by hand, then restart ${server.client}`);
7008
+ }
5603
7009
  }
5604
- function gmailRemoval({ client, name: server, path, scope }, platform) {
5605
- if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
7010
+ function gmailRemoval(registered, platform) {
7011
+ const { client, name: server, path, scope } = registered;
7012
+ if (scope === "project") return remedy(`remove "${server}" from the project entry in ${path} by hand`);
5606
7013
  switch (client) {
5607
- case "claude-code": return removalCommand("claude", server, platform);
5608
- case "codex": return removalCommand("codex", server, platform);
5609
- default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
7014
+ case "claude-code": return removalCommand("claude", registered, platform);
7015
+ case "codex": return removalCommand("codex", registered, platform);
7016
+ default: return remedy(`remove the "${server}" entry from ${path}, then restart ${client}`);
5610
7017
  }
5611
7018
  }
5612
7019
  /**
@@ -5617,7 +7024,7 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
5617
7024
  const patterns = rivalPatterns(packages);
5618
7025
  const findings = [];
5619
7026
  for (const server of servers) {
5620
- const line = [server.command, ...server.args].join(" ");
7027
+ const line = [server.command, ...withoutPathOptions(server.args).words].join(" ");
5621
7028
  const known = patterns.find((candidate) => candidate.pattern.test(line));
5622
7029
  if (!known) continue;
5623
7030
  findings.push({
@@ -5638,11 +7045,11 @@ function rivalPackageWarnings(servers, packages, platform = process.platform) {
5638
7045
  * `product` itself: a channel's `rivals.word`, for a service with too many other servers to list.
5639
7046
  */
5640
7047
  function findRivalWordServers(servers, word, product) {
5641
- const pattern = new RegExp(escapeRegExp$2(word), "i");
7048
+ const pattern = new RegExp(escapeRegExp$3(word), "i");
5642
7049
  return servers.filter((server) => !isProductServer(server, product) && pattern.test([
5643
7050
  server.name,
5644
7051
  server.command,
5645
- ...server.args,
7052
+ ...withoutPathOptions(server.args).words,
5646
7053
  server.url ?? ""
5647
7054
  ].join(" ")));
5648
7055
  }
@@ -5915,10 +7322,6 @@ function renderUpdate(result, color) {
5915
7322
  if (result.next) lines.push("", result.next);
5916
7323
  return lines.join("\n");
5917
7324
  }
5918
- /** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
5919
- function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
5920
- return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
5921
- }
5922
7325
  /**
5923
7326
  * The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
5924
7327
  * touches.
@@ -6030,7 +7433,7 @@ async function prepareChange(core, request, options) {
6030
7433
  change: binding
6031
7434
  }),
6032
7435
  expiresAt: record.expiresAt,
6033
- next: nextStep(record.approvalId, policy, options)
7436
+ next: nextStep(record.approvalId, policy, core, options)
6034
7437
  };
6035
7438
  } catch (error) {
6036
7439
  await auditRefusal(core, "change.prepare", error, {
@@ -6062,8 +7465,9 @@ async function claimChange(core, approvalId, expect, options) {
6062
7465
  change: binding,
6063
7466
  policy
6064
7467
  }, {
6065
- pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
6066
- platform: options.platform
7468
+ 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.`),
7469
+ platform: options.platform,
7470
+ handoffs: core.handoffs
6067
7471
  });
6068
7472
  const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
6069
7473
  await auditChange(core, {
@@ -6081,7 +7485,11 @@ async function claimChange(core, approvalId, expect, options) {
6081
7485
  paths: binding.loosened.map((loosening) => loosening.path),
6082
7486
  changes: binding.loosened
6083
7487
  };
6084
- } catch (error) {
7488
+ } catch (caught) {
7489
+ const error = isMisdirectedSend(caught) ? new CommsError(caught.code, caught.message, {
7490
+ hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId),
7491
+ ...caught.details === void 0 ? {} : { details: caught.details }
7492
+ }) : caught;
6085
7493
  await auditRefusal(core, "change.claim", error, {
6086
7494
  surface: options.surface,
6087
7495
  approvalId,
@@ -6092,11 +7500,11 @@ async function claimChange(core, approvalId, expect, options) {
6092
7500
  }
6093
7501
  }
6094
7502
  /** A change approval, checked to be one and to describe the change its digest binds. */
6095
- async function changeRecord(core, approvalId) {
7503
+ async function changeRecord(core, approvalId, options = {}) {
6096
7504
  const record = await core.approvals.get(approvalId);
6097
7505
  if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
6098
7506
  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 });
6099
- 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 })}.` });
7507
+ 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) });
6100
7508
  if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
6101
7509
  hint: "Nothing was approved. Prepare the change again.",
6102
7510
  details: { approvalId }
@@ -6111,7 +7519,7 @@ async function changeRecord(core, approvalId) {
6111
7519
  async function beginChangeApproval(core, approvalId, options) {
6112
7520
  let record;
6113
7521
  try {
6114
- record = await changeRecord(core, approvalId);
7522
+ record = await changeRecord(core, approvalId, options);
6115
7523
  const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
6116
7524
  return {
6117
7525
  approvalId,
@@ -6132,7 +7540,7 @@ async function beginChangeApproval(core, approvalId, options) {
6132
7540
  async function finishChangeApproval(core, approvalId, answer, options) {
6133
7541
  let record;
6134
7542
  try {
6135
- record = await changeRecord(core, approvalId);
7543
+ record = await changeRecord(core, approvalId, options);
6136
7544
  const digest = changeDigest(record.change);
6137
7545
  const approved = await core.approvals.approve(approvalId, "terminal", {
6138
7546
  draftMessageId: digest,
@@ -6193,8 +7601,8 @@ async function revokeChange(core, approvalId, reason, options) {
6193
7601
  function changePolicyOf$1(record) {
6194
7602
  return record.requiredPolicy === "chat" ? "chat" : "confirm";
6195
7603
  }
6196
- function nextStep(approvalId, policy, options) {
6197
- 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.`;
7604
+ function nextStep(approvalId, policy, core, options) {
7605
+ 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.`);
6198
7606
  }
6199
7607
  /**
6200
7608
  * One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
@@ -6354,13 +7762,11 @@ async function gatedChange(core, change, options) {
6354
7762
  status: "approval-required",
6355
7763
  prepared: await prepareChange(core, request, {
6356
7764
  surface: options.surface,
6357
- approveCommand: options.approveCommand,
6358
7765
  platform: options.platform
6359
7766
  })
6360
7767
  };
6361
7768
  const consent = await claimChange(core, options.approvalId, request, {
6362
7769
  surface: options.surface,
6363
- approveCommand: options.approveCommand,
6364
7770
  platform: options.platform
6365
7771
  });
6366
7772
  return {
@@ -6419,11 +7825,9 @@ function changeToolResult(outcome) {
6419
7825
  */
6420
7826
  async function gatedChangeAtTerminal(core, change, options) {
6421
7827
  const streams = options.streams ?? defaultStreams;
6422
- const { approveCommand } = options;
6423
7828
  const first = await gatedChange(core, change, {
6424
7829
  surface: "cli",
6425
7830
  approvalId: options.approvalId,
6426
- approveCommand,
6427
7831
  platform: options.output.platform
6428
7832
  });
6429
7833
  if (first.status === "applied") return first.result;
@@ -6431,9 +7835,9 @@ async function gatedChangeAtTerminal(core, change, options) {
6431
7835
  if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
6432
7836
  if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
6433
7837
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
6434
- const { command } = options;
7838
+ const handoffs = requireHandoffs(core, options.output.platform);
6435
7839
  throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
6436
- hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
7840
+ hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, handoffs[options.rerunOn ?? "own"](insertWordsBeforeSentinel(options.rerun, ...carrying)), handoffs),
6437
7841
  details: {
6438
7842
  approvalId: prepared.approvalId,
6439
7843
  policy: prepared.policy,
@@ -6454,7 +7858,6 @@ async function gatedChangeAtTerminal(core, change, options) {
6454
7858
  const second = await gatedChange(core, change, {
6455
7859
  surface: "cli",
6456
7860
  approvalId: prepared.approvalId,
6457
- approveCommand,
6458
7861
  platform: options.output.platform
6459
7862
  });
6460
7863
  if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
@@ -6467,9 +7870,13 @@ async function gatedChangeAtTerminal(core, change, options) {
6467
7870
  * Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
6468
7871
  * its registration step and says why in its report — so the two say it in the same words.
6469
7872
  */
6470
- function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
6471
- const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
6472
- 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}.`;
7873
+ function approvalHint(prepared, rerun, maker) {
7874
+ const run = isCommand(rerun) ? inlineCommand(rerun) : null;
7875
+ const rerunSaid = isCommand(rerun) ? "" : rerun.message;
7876
+ if (prepared.policy !== "confirm") return run === null ? `Show the person the preview. ${rerunSaid}` : `Show the person the preview. Once they say yes, run ${run}.`;
7877
+ const approve = maker.own(["approve", prepared.approvalId]);
7878
+ if (isCommand(approve) && run !== null) return `Show the person the preview. They run ${inlineCommand(approve)}; then run ${run}.`;
7879
+ return `Show the person the preview. ${handoffSentence(approve, (command) => `They run ${command}.`)} ${run === null ? rerunSaid : `Then run ${run}.`}`;
6473
7880
  }
6474
7881
  /**
6475
7882
  * Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
@@ -6489,11 +7896,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
6489
7896
  refuseUnlessPerson(env, streams, {
6490
7897
  refusedToAgent: "only a person can approve a change, not an agent",
6491
7898
  refusedWithoutTerminal: "approving a change needs an interactive terminal",
6492
- command: commandText(shellCommand([
6493
- "agentcomms",
6494
- "approve",
6495
- approvalId
6496
- ], output.platform)),
7899
+ command: requireHandoffs(core, output.platform).own(["approve", approvalId]),
6497
7900
  color: output.color,
6498
7901
  json: output.json
6499
7902
  });
@@ -6679,11 +8082,17 @@ const KEYCHAIN_SERVICE = "agent-communications";
6679
8082
  * answer an OS prompt, so it must not hang.
6680
8083
  */
6681
8084
  const KEYCHAIN_TIMEOUT_MS = 12e3;
8085
+ /** "Run the doctor": core's, located from whatever is printing, or why there is none here. */
8086
+ function doctorSentence(handoffs, say) {
8087
+ return handoffSentence(requiredHandoffs(handoffs).core(["doctor"]), say, { instead: "Call comms_doctor from a chat." });
8088
+ }
6682
8089
  var FileSecretStore = class {
6683
8090
  kind = "file";
6684
8091
  directory;
6685
- constructor(directory) {
8092
+ #handoffs;
8093
+ constructor(directory, options = {}) {
6686
8094
  this.directory = directory;
8095
+ this.#handoffs = options.handoffs;
6687
8096
  }
6688
8097
  #path(ref) {
6689
8098
  return join(this.directory, `${createHash("sha256").update(ref).digest("hex").slice(0, 32)}.json`);
@@ -6703,7 +8112,7 @@ var FileSecretStore = class {
6703
8112
  } catch (error) {
6704
8113
  if (error.code === "ENOENT") return null;
6705
8114
  throw new CommsError("SECRET_STORE_UNAVAILABLE", "a stored secret file could not be read", {
6706
- hint: "Run `agentcomms doctor`; re-authorise the inbox if the file is damaged.",
8115
+ hint: `${doctorSentence(this.#handoffs, (command) => `Run ${command}.`)} Re-authorise the inbox if the file is damaged.`,
6707
8116
  cause: error
6708
8117
  });
6709
8118
  }
@@ -6738,10 +8147,10 @@ async function loadKeyringModule() {
6738
8147
  var KeychainTimeout = class extends Error {
6739
8148
  name = "KeychainTimeout";
6740
8149
  };
6741
- function keychainError(action, cause, timeoutMs) {
8150
+ function keychainError(action, cause, timeoutMs, handoffs) {
6742
8151
  const timedOut = cause instanceof KeychainTimeout;
6743
8152
  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)`, {
6744
- 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.",
8153
+ 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.`,
6745
8154
  details: { reason: timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "KEYCHAIN_UNAVAILABLE" },
6746
8155
  cause
6747
8156
  });
@@ -6766,6 +8175,7 @@ var KeychainSecretStore = class {
6766
8175
  #module;
6767
8176
  #timeoutMs;
6768
8177
  #namespace;
8178
+ #handoffs;
6769
8179
  /** One keychain call at a time: parallel calls would each raise their own OS prompt and exhaust the thread pool. */
6770
8180
  #queue = Promise.resolve();
6771
8181
  /**
@@ -6779,17 +8189,18 @@ var KeychainSecretStore = class {
6779
8189
  * @param namespace keeps entries of different config directories apart (a test run or a second setup must never
6780
8190
  * read or overwrite another's `client:default:secret`); see {@link keychainNamespace}.
6781
8191
  */
6782
- constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
8192
+ constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS, options = {}) {
6783
8193
  this.#module = module;
6784
8194
  this.#namespace = namespace;
6785
8195
  this.#timeoutMs = timeoutMs;
8196
+ this.#handoffs = options.handoffs;
6786
8197
  }
6787
8198
  #entry(ref) {
6788
8199
  return new this.#module.AsyncEntry(KEYCHAIN_SERVICE, `${this.#namespace}:${ref}`, { linux: { store: "secret-service" } });
6789
8200
  }
6790
8201
  #serial(action, fn) {
6791
8202
  const run = this.#queue.then(async () => {
6792
- if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs);
8203
+ if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs, this.#handoffs);
6793
8204
  const native = fn();
6794
8205
  try {
6795
8206
  return await raceTimeout(native, this.#timeoutMs);
@@ -6801,7 +8212,7 @@ var KeychainSecretStore = class {
6801
8212
  if (this.#stuck === stuck) this.#stuck = null;
6802
8213
  });
6803
8214
  }
6804
- throw keychainError(action, error, this.#timeoutMs);
8215
+ throw keychainError(action, error, this.#timeoutMs, this.#handoffs);
6805
8216
  }
6806
8217
  });
6807
8218
  this.#queue = run.catch(() => void 0);
@@ -6841,10 +8252,11 @@ function keychainNamespace(configDir) {
6841
8252
  }
6842
8253
  /** Opens the backend recorded in config for an inbox or client. */
6843
8254
  async function openSecretStore(kind, options) {
6844
- if (kind === "file") return new FileSecretStore(options.secretsDir);
8255
+ const told = { handoffs: options.handoffs };
8256
+ if (kind === "file") return new FileSecretStore(options.secretsDir, told);
6845
8257
  const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
6846
8258
  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." });
6847
- return new KeychainSecretStore(keyring, options.namespace);
8259
+ return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
6848
8260
  }
6849
8261
  var InboxStateStore = class {
6850
8262
  directory;
@@ -6878,25 +8290,37 @@ var InboxStateStore = class {
6878
8290
  }
6879
8291
  };
6880
8292
  function openCore(options = {}) {
6881
- const paths = resolvePaths(options);
8293
+ const { paths, pathOverrides } = resolvePathIdentity(options);
6882
8294
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
6883
- const config = new ConfigStore(paths.configDir);
8295
+ const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
8296
+ caller: options.caller,
8297
+ paths,
8298
+ platform: options.platform,
8299
+ env: options.env
8300
+ });
8301
+ const config = new ConfigStore(paths.configDir, { handoffs });
6884
8302
  let cached = null;
6885
8303
  return {
6886
8304
  paths,
8305
+ pathOverrides,
6887
8306
  config,
6888
8307
  states: new InboxStateStore(paths.stateDir),
6889
- approvals: new ApprovalStore(paths.stateDir, { now }),
8308
+ approvals: new ApprovalStore(paths.stateDir, {
8309
+ now,
8310
+ handoffs
8311
+ }),
6890
8312
  ledger: new SendLedger(paths.stateDir, now),
6891
8313
  plans: new PlanStore(paths.stateDir, now),
6892
8314
  taint: new TaintStore(paths.stateDir, now),
6893
8315
  audit: new AuditLog(paths.stateDir, now),
8316
+ handoffs,
6894
8317
  async secrets(kind) {
6895
8318
  const chosen = kind ?? secretsStoreOf(await config.load());
6896
8319
  if (cached?.kind === chosen) return cached.store;
6897
8320
  const store = await openSecretStore(chosen, {
6898
8321
  secretsDir: paths.secretsDir,
6899
- namespace: keychainNamespace(paths.configDir)
8322
+ namespace: keychainNamespace(paths.configDir),
8323
+ handoffs
6900
8324
  });
6901
8325
  cached = {
6902
8326
  kind: chosen,
@@ -7095,79 +8519,6 @@ strictObject({
7095
8519
  appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits").optional(),
7096
8520
  sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
7097
8521
  });
7098
- /**
7099
- * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
7100
- * to decide whether the release it last heard of is newer than the one running.
7101
- *
7102
- * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
7103
- * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
7104
- * all of it, so nothing that imported these from there changes.
7105
- */
7106
- /**
7107
- * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
7108
- *
7109
- * Anything the registry answers is checked against this before it is used, because a version goes into a directory
7110
- * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
7111
- * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
7112
- */
7113
- 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-]+)*))?$/;
7114
- function isVersion(value) {
7115
- return typeof value === "string" && VERSION_PATTERN.test(value);
7116
- }
7117
- /** Two numeric strings with no leading zeros, compared as numbers of any size. */
7118
- function compareNumeric(a, b) {
7119
- if (a.length !== b.length) return a.length < b.length ? -1 : 1;
7120
- return a < b ? -1 : a > b ? 1 : 0;
7121
- }
7122
- /**
7123
- * Semver precedence: -1, 0 or 1, or null when either is not a version.
7124
- *
7125
- * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
7126
- * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
7127
- * few packages as it can.
7128
- */
7129
- function compareVersions(a, b) {
7130
- const left = VERSION_PATTERN.exec(a);
7131
- const right = VERSION_PATTERN.exec(b);
7132
- if (!left || !right) return null;
7133
- for (const index of [
7134
- 1,
7135
- 2,
7136
- 3
7137
- ]) {
7138
- const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
7139
- if (order !== 0) return order < 0 ? -1 : 1;
7140
- }
7141
- const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
7142
- const [ours, theirs] = [pre(left), pre(right)];
7143
- if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
7144
- for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
7145
- const x = ours[index];
7146
- const y = theirs[index];
7147
- if (x === void 0) return -1;
7148
- if (y === void 0) return 1;
7149
- const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
7150
- if (numericX && numericY) {
7151
- const order = compareNumeric(x, y);
7152
- if (order !== 0) return order < 0 ? -1 : 1;
7153
- } else if (numericX !== numericY) return numericX ? -1 : 1;
7154
- else if (x !== y) return x < y ? -1 : 1;
7155
- }
7156
- return 0;
7157
- }
7158
- /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
7159
- function isBehind(version, latest) {
7160
- if (version === latest) return false;
7161
- const order = compareVersions(version, latest);
7162
- return order === null ? true : order < 0;
7163
- }
7164
- /**
7165
- * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
7166
- * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
7167
- */
7168
- function isPrerelease(version) {
7169
- return VERSION_PATTERN.exec(version)?.[4] !== void 0;
7170
- }
7171
8522
  const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
7172
8523
  /**
7173
8524
  * The registry npm itself would read: `npm_config_registry` when it is set — which npm sets for everything it runs,
@@ -7313,8 +8664,6 @@ async function npmInstallGlobal(env, spec) {
7313
8664
  throw new Error(`npm install --global ${spec} failed${said ? `: ${said}` : ""}`);
7314
8665
  }
7315
8666
  }
7316
- /** The package version, read from package.json at build time. */
7317
- const VERSION = "0.13.0";
7318
8667
  /**
7319
8668
  * Registering, pruning and listing the MCP servers of every channel, from the core.
7320
8669
  *
@@ -7515,7 +8864,9 @@ function serverInstallChange(core, env, request, own) {
7515
8864
  checkPin(config, request);
7516
8865
  const product = await productNow();
7517
8866
  const { version } = product;
7518
- const preflight = await preflightInstall(context, product, installOptions(request));
8867
+ const preflight = await preflightInstall(context, product, installOptions(request)).catch(async (error) => {
8868
+ throw await remedyLocated(error, core);
8869
+ });
7519
8870
  const { target, previous, effective, kept, command } = preflight;
7520
8871
  planned = {
7521
8872
  product,
@@ -7546,7 +8897,9 @@ function serverInstallChange(core, env, request, own) {
7546
8897
  },
7547
8898
  apply: async () => {
7548
8899
  if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
7549
- const result = await mcpInstall(context, planned.product, installOptions(request), planned.install);
8900
+ const result = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
8901
+ throw await remedyLocated(error, core);
8902
+ });
7550
8903
  return {
7551
8904
  ...result,
7552
8905
  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
@@ -7555,6 +8908,23 @@ function serverInstallChange(core, env, request, own) {
7555
8908
  };
7556
8909
  }
7557
8910
  /**
8911
+ * A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
8912
+ * the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
8913
+ * installer says only the words; it may not import the locator.
8914
+ */
8915
+ async function remedyLocated(error, core) {
8916
+ const remedy = installRemedyOf(error);
8917
+ if (remedy === void 0 || !(error instanceof CommsError)) return error;
8918
+ const channel = CHANNELS.find((each) => channelServer(each).binary === remedy.binary);
8919
+ if (channel === void 0) return error;
8920
+ const maker = await requireHandoffs(core).registered();
8921
+ return new CommsError(error.code, error.message, {
8922
+ hint: handoffSentence(maker.of(channel, remedy.words), remedy.say),
8923
+ ...error.details === void 0 ? {} : { details: error.details },
8924
+ ...error.cause === void 0 ? {} : { cause: error.cause }
8925
+ });
8926
+ }
8927
+ /**
7558
8928
  * Removing a channel's unused managed runtimes, as a change.
7559
8929
  *
7560
8930
  * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
@@ -7572,7 +8942,7 @@ function serverPruneChange(core, env, request, own) {
7572
8942
  };
7573
8943
  const { packageName, version } = ownProduct(request.channel, own) ?? {
7574
8944
  packageName: channelServer(request.channel).packageName,
7575
- version: "0.13.0"
8945
+ version: "0.13.1"
7576
8946
  };
7577
8947
  const product = {
7578
8948
  packageName,
@@ -7610,14 +8980,6 @@ function serverPruneChange(core, env, request, own) {
7610
8980
  })
7611
8981
  };
7612
8982
  }
7613
- /** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
7614
- function launcherOf(server, facts) {
7615
- const parts = [server.command, ...server.args];
7616
- if (parts.some((part) => managedRuntimeVersion(part, facts.packageName) !== null)) return "managed";
7617
- if (server.args.some((arg) => arg.startsWith(`${facts.npxPackage}@`))) return "npx";
7618
- if (parts.some((part) => /[/\\]packages[/\\][^/\\]+[/\\](?:src[/\\]cli\.ts|dist[/\\]cli\.mjs)$/.test(part))) return "local";
7619
- return "other";
7620
- }
7621
8983
  /**
7622
8984
  * The daily update check, as every server and command reads it (design 2026-09-28): one file per machine,
7623
8985
  * `update-check.json` in the state directory, saying when the registry was last asked, the latest release it named,
@@ -7821,40 +9183,52 @@ async function pendingUpdate(options) {
7821
9183
  };
7822
9184
  return verdict;
7823
9185
  }
7824
- /**
7825
- * The two ways on, as a tool call and as a command — and the command through npx, for a machine with no `agentcomms`
7826
- * installed: one that runs only a plugin's server, say, has neither the core server nor the command.
7827
- */
7828
- const UPDATE_WAYS = Object.freeze({
7829
- update: {
7830
- tool: "comms_update",
7831
- command: "agentcomms update",
7832
- npx: "npx -y @agentcomms/core@latest update"
7833
- },
7834
- later: {
7835
- tool: "comms_update",
7836
- arguments: { later: true },
7837
- command: "agentcomms update --later",
7838
- npx: "npx -y @agentcomms/core@latest update --later"
7839
- }
7840
- });
9186
+ function updateCommands(core, platform) {
9187
+ const handoffs = requireHandoffs(core, platform);
9188
+ return {
9189
+ update: handoffs.core(["update"]),
9190
+ later: handoffs.core(["update", "--later"])
9191
+ };
9192
+ }
9193
+ /** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
9194
+ function updateCommandSaid(commands, which, say) {
9195
+ return handoffSentence(commands[which], say);
9196
+ }
9197
+ function updateWaysOf(commands) {
9198
+ return {
9199
+ update: {
9200
+ tool: "comms_update",
9201
+ command: commands.update
9202
+ },
9203
+ later: {
9204
+ tool: "comms_update",
9205
+ arguments: { later: true },
9206
+ command: commands.later
9207
+ }
9208
+ };
9209
+ }
9210
+ /** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
9211
+ function labelOf(channel) {
9212
+ return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
9213
+ }
7841
9214
  /**
7842
9215
  * What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
7843
9216
  * two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
7844
9217
  */
7845
- function updateStopMessage(pending, where) {
9218
+ function updateStopMessage(pending, where, commands) {
9219
+ const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
7846
9220
  const didNotRun = `Nothing was done: ${where.tool} did not run.`;
7847
- 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).`;
9221
+ 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}.`)}`;
7848
9222
  if (pending.kind === "restart") return [
7849
9223
  "Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
7850
- `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}`,
9224
+ `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}`,
7851
9225
  `Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
7852
9226
  ].join("\n");
7853
9227
  return [
7854
9228
  UPDATE_FIRST,
7855
- `This is ${where.server} ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
9229
+ `This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
7856
9230
  "Ask the person which they want:",
7857
- `- 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.`,
9231
+ `- 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.`,
7858
9232
  `- ${later}`
7859
9233
  ].join("\n");
7860
9234
  }
@@ -7919,15 +9293,15 @@ async function latestReleases(names, latestVersion) {
7919
9293
  return Object.fromEntries(answers.map((answer) => [answer.name, answer.version ?? ""]));
7920
9294
  }
7921
9295
  /**
7922
- * How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach.
9296
+ * How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach —
9297
+ * located as that channel's command among this machine's registrations, or why it is not locatable here (CUE-403).
7923
9298
  *
7924
9299
  * The entry's name and pins are read from the client's file, which may hold anything, so on Windows the command may
7925
- * have no line to paste: the reasons below give it with `inlineCommand`, which shows it as words then.
9300
+ * have no line to paste: the reasons below give it with `handoffSentence`, which shows it as words then.
7926
9301
  */
7927
- function installCommand(item, platform) {
9302
+ function installHandoff(item, handoffs) {
7928
9303
  const facts = channelServer(item.channel);
7929
9304
  const words = [
7930
- facts.binary,
7931
9305
  "mcp",
7932
9306
  "install",
7933
9307
  "--client",
@@ -7935,7 +9309,7 @@ function installCommand(item, platform) {
7935
9309
  ];
7936
9310
  if (item.name !== facts.defaultServerName) words.push("--name", item.name);
7937
9311
  words.push(...item.narrowing, "--force");
7938
- return shellCommand(words, platform);
9312
+ return handoffs.of(item.channel, words);
7939
9313
  }
7940
9314
  /**
7941
9315
  * Why a registration that is behind cannot be registered again from here, or undefined when it can.
@@ -7945,14 +9319,14 @@ function installCommand(item, platform) {
7945
9319
  * whose CLI is not here are each left for a person, with what to run. So is one pinned to an account that has since
7946
9320
  * been renamed or removed: registering it again would register a server that is refused when it starts.
7947
9321
  */
7948
- async function whyNotUpdatable(context, config, item, narrowing) {
7949
- if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with ${inlineCommand(installCommand(item, context.platform))}`;
9322
+ async function whyNotUpdatable(context, config, item, narrowing, handoffs) {
9323
+ if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; ${handoffSentence(installHandoff(item, handoffs), (command) => `register it again with ${command}`)}`;
7950
9324
  if (item.scope !== "user") return `it is registered for one project (in ${item.path}), and \`mcp install\` registers at user scope only; register it again from that project with ${item.client}'s own command`;
7951
9325
  const target = await installTarget(context, {
7952
9326
  client: item.client,
7953
9327
  apply: true
7954
9328
  });
7955
- if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run ${inlineCommand(installCommand(item, context.platform))} where it is` : `this environment names no ${item.client} configuration to write to`;
9329
+ if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; ${handoffSentence(installHandoff(item, handoffs), (command) => `run ${command} where it is`)}` : `this environment names no ${item.client} configuration to write to`;
7956
9330
  const own = pinOption(item.channel);
7957
9331
  const pin = own === void 0 || own === "readOnly" ? void 0 : narrowing[own];
7958
9332
  if (pin !== void 0) {
@@ -8000,6 +9374,7 @@ async function inspect(core, env, deps) {
8000
9374
  platform: deps.platform
8001
9375
  };
8002
9376
  const scan = await scanRegisteredServers(env);
9377
+ const handoffs = requireHandoffs(core, deps.platform).withRegistrations(scan.servers);
8003
9378
  const unreadable = [...scan.unreadable];
8004
9379
  const found = registrations(scan.servers).map(({ channel, server }) => {
8005
9380
  const facts = channelServer(channel);
@@ -8007,7 +9382,7 @@ async function inspect(core, env, deps) {
8007
9382
  channel,
8008
9383
  server,
8009
9384
  narrowing: facts.narrowingOf(server.args),
8010
- version: server.args.map((arg) => pinnedVersion(arg, facts)).find((pinned) => pinned !== null) ?? null,
9385
+ version: registrationVersion(server, facts),
8011
9386
  package: server.args.some((arg) => arg.startsWith(`${facts.npxPackage}@`)) ? facts.npxPackage : facts.packageName
8012
9387
  };
8013
9388
  });
@@ -8063,7 +9438,7 @@ async function inspect(core, env, deps) {
8063
9438
  upToDate.push(item);
8064
9439
  continue;
8065
9440
  }
8066
- const reason = await whyNotUpdatable(context, config, item, entry.narrowing);
9441
+ const reason = await whyNotUpdatable(context, config, item, entry.narrowing, handoffs);
8067
9442
  const listed = {
8068
9443
  ...item,
8069
9444
  updatable: reason === void 0,
@@ -8116,6 +9491,7 @@ async function inspect(core, env, deps) {
8116
9491
  unpinned,
8117
9492
  unreadable
8118
9493
  },
9494
+ handoffs,
8119
9495
  candidates,
8120
9496
  manual,
8121
9497
  globals
@@ -8274,7 +9650,7 @@ function counted(count, one, many) {
8274
9650
  * has. When it is not — the client itself reports the entry otherwise than its file does, say — registering again
8275
9651
  * would change what the server may reach, so the entry is left for a person rather than widened or narrowed.
8276
9652
  */
8277
- async function planRegistrations(context, candidates, request) {
9653
+ async function planRegistrations(context, candidates, request, handoffs) {
8278
9654
  const steps = [];
8279
9655
  const manual = [];
8280
9656
  const leave = (item, reason) => manual.push({
@@ -8306,7 +9682,7 @@ async function planRegistrations(context, candidates, request) {
8306
9682
  }
8307
9683
  const keeps = narrowingArgs(item.channel, preflight.effective);
8308
9684
  if (JSON.stringify(keeps) !== JSON.stringify(item.narrowing)) {
8309
- leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have: ${inlineCommand(installCommand(item, context.platform))}`);
9685
+ leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. ${handoffSentence(installHandoff(item, handoffs), (command) => `Register it again yourself with the pins it should have: ${command}`)}`);
8310
9686
  continue;
8311
9687
  }
8312
9688
  steps.push({
@@ -8338,7 +9714,7 @@ function updateChange(core, env, request = {}, deps = {}) {
8338
9714
  return {
8339
9715
  plan: async (config) => {
8340
9716
  const inspection = await inspect(core, env, deps);
8341
- const { steps, manual } = await planRegistrations(context, inspection.candidates, request);
9717
+ const { steps, manual } = await planRegistrations(context, inspection.candidates, request, inspection.handoffs);
8342
9718
  const runtimes = await runtimesNeeded(core.paths.dataDir, inspection.report.latest, steps.map((step) => step.item));
8343
9719
  planned = {
8344
9720
  report: inspection.report,
@@ -8346,6 +9722,7 @@ function updateChange(core, env, request = {}, deps = {}) {
8346
9722
  runtimes,
8347
9723
  registrations: steps,
8348
9724
  globals: inspection.globals,
9725
+ handoffs: inspection.handoffs,
8349
9726
  manual: [...inspection.manual, ...manual],
8350
9727
  nothingBehind: inspection.report.behind.length === 0
8351
9728
  };
@@ -8390,8 +9767,9 @@ function updateChange(core, env, request = {}, deps = {}) {
8390
9767
  * id that is not one is said to be that.
8391
9768
  */
8392
9769
  async function nothingToApply(core, approvalId, planned) {
9770
+ const check = (say) => handoffSentence(planned.handoffs.core(["update", "--check"]), say);
8393
9771
  if (!APPROVAL_ID_PATTERN.test(approvalId)) return new CommsError("USAGE", `nothing was changed: "${approvalId}" is not an approval id`, {
8394
- hint: "An approval id is the `approvalId` a call that needs approval returns, and nothing here needs one: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows what is behind.",
9772
+ hint: check((command) => `An approval id is the \`approvalId\` a call that needs approval returns, and nothing here needs one: a check — comms_update with \`check\`, or ${command} at a terminal — shows what is behind.`),
8395
9773
  details: { approvalId }
8396
9774
  });
8397
9775
  const record = await core.approvals.get(approvalId);
@@ -8401,11 +9779,11 @@ async function nothingToApply(core, approvalId, planned) {
8401
9779
  ...record ? { state: record.state } : {}
8402
9780
  };
8403
9781
  if (planned.nothingBehind) return new CommsError("USAGE", `nothing was changed: nothing is behind the latest release, ${unused}`, {
8404
- hint: "Nothing needs doing: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows everything here up to date. A release published later is another update, with a preview and an approval of its own.",
9782
+ hint: check((command) => `Nothing needs doing: a check — comms_update with \`check\`, or ${command} at a terminal — shows everything here up to date. A release published later is another update, with a preview and an approval of its own.`),
8405
9783
  details
8406
9784
  });
8407
9785
  return new CommsError("USAGE", `nothing was changed: what is behind cannot be updated from here, ${unused}`, {
8408
- hint: "Each is left for a person: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — lists them, with why and what to run. The same call without the approval reports them too, and changes nothing.",
9786
+ hint: check((command) => `Each is left for a person: a check — comms_update with \`check\`, or ${command} at a terminal — lists them, with why and what to run. The same call without the approval reports them too, and changes nothing.`),
8409
9787
  details
8410
9788
  });
8411
9789
  }
@@ -8435,8 +9813,13 @@ function runtimeProduct(packageName, version) {
8435
9813
  if (channel === void 0) throw new CommsError("UNEXPECTED", `${packageName} is not a channel's package`);
8436
9814
  return channelProduct(channel, "managed", version);
8437
9815
  }
8438
- /** Every channel's `mcp prune`, as a person types it: "`agentcomms mcp prune`, `agent-gmail mcp prune` and …". */
8439
- const PRUNE_COMMANDS = listed(CHANNELS.map((channel) => `\`${channelServer(channel).binary} mcp prune\``), "and");
9816
+ /**
9817
+ * Every channel's `mcp prune`, as a person runs it at a terminal: each located among this machine's registrations, and
9818
+ * for one that is not, why — never a bare name (CUE-403).
9819
+ */
9820
+ function pruneCommands(handoffs) {
9821
+ return handoffChoices(CHANNELS.map((channel) => handoffs.of(channel, ["mcp", "prune"])), "none of their commands is locatable here:", { conjunction: "and" });
9822
+ }
8440
9823
  function message(error) {
8441
9824
  return error instanceof CommsError ? error.message : error instanceof Error ? error.message : String(error);
8442
9825
  }
@@ -8546,7 +9929,7 @@ async function applyUpdate(context, planned, deps) {
8546
9929
  const ok = steps.every(worked);
8547
9930
  const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
8548
9931
  const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
8549
- const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${PRUNE_COMMANDS}.` : null;
9932
+ const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${pruneCommands(planned.handoffs)}.` : null;
8550
9933
  return {
8551
9934
  status,
8552
9935
  latest: planned.latest,
@@ -8664,8 +10047,9 @@ function updateToolGate(options) {
8664
10047
  if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
8665
10048
  return stoppedCall(pending, {
8666
10049
  server: options.server,
10050
+ channel: options.channel,
8667
10051
  tool
8668
- });
10052
+ }, updateCommands(options.core));
8669
10053
  };
8670
10054
  }
8671
10055
  /**
@@ -8673,15 +10057,17 @@ function updateToolGate(options) {
8673
10057
  * a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
8674
10058
  * `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
8675
10059
  */
8676
- function stoppedCall(pending, where) {
8677
- const message = updateStopMessage(pending, where);
10060
+ function stoppedCall(pending, where, commands) {
10061
+ const message = updateStopMessage(pending, where, commands);
10062
+ const ways = updateWaysOf(commands);
10063
+ const leaving = (value) => JSON.parse(JSON.stringify(value));
8678
10064
  return {
8679
10065
  isError: true,
8680
10066
  content: [{
8681
10067
  type: "text",
8682
10068
  text: message
8683
10069
  }],
8684
- structuredContent: { error: {
10070
+ structuredContent: leaving({ error: {
8685
10071
  code: "UPDATE_REQUIRED",
8686
10072
  message,
8687
10073
  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.",
@@ -8691,10 +10077,10 @@ function stoppedCall(pending, where) {
8691
10077
  running: pending.running,
8692
10078
  latest: pending.latest,
8693
10079
  installed: pending.kind === "restart",
8694
- update: UPDATE_WAYS.update,
8695
- later: UPDATE_WAYS.later
10080
+ update: ways.update,
10081
+ later: ways.later
8696
10082
  }
8697
- } }
10083
+ } })
8698
10084
  };
8699
10085
  }
8700
10086
  /** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
@@ -8790,19 +10176,21 @@ async function updateGateAtTerminal(options) {
8790
10176
  ...where
8791
10177
  });
8792
10178
  if (pending === null) return null;
8793
- if (!(agentMarker(env) === null && canPrompt(env, streams, {
10179
+ const person = agentMarker(env) === null && canPrompt(env, streams, {
8794
10180
  json: output.json,
8795
10181
  noInput: options.noInput === true
8796
- }))) throw updateRequired(pending, options.binary);
10182
+ });
10183
+ const commands = updateCommands(core, output.platform);
10184
+ if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
8797
10185
  const bold = (word) => paint(output.color, "bold", word);
8798
10186
  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();
8799
10187
  if (answer === "now" || answer === "update") {
8800
10188
  if (pending.kind === "restart") {
8801
- 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`);
10189
+ 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`);
8802
10190
  return EXIT_CODES.UPDATE;
8803
10191
  }
8804
10192
  if (!options.update) {
8805
- 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`);
10193
+ 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`);
8806
10194
  return EXIT_CODES.UPDATE;
8807
10195
  }
8808
10196
  return afterUpdate(await options.update(), options, pending);
@@ -8811,39 +10199,50 @@ async function updateGateAtTerminal(options) {
8811
10199
  const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
8812
10200
  env,
8813
10201
  output,
8814
- command: UPDATE_WAYS.later.command,
8815
- approveCommand: options.approveCommand,
10202
+ rerun: ["update", "--later"],
10203
+ rerunOn: "core",
8816
10204
  answered: true,
8817
10205
  streams
8818
10206
  });
8819
10207
  streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
8820
10208
  return null;
8821
10209
  }
8822
- 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}\`.` });
10210
+ 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}.`) });
10211
+ }
10212
+ /**
10213
+ * The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
10214
+ * command most people can run, and which a sentence would read as one (CUE-403).
10215
+ */
10216
+ function cliOf(channel) {
10217
+ if (channel === "core") return "core CLI";
10218
+ return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
8823
10219
  }
8824
10220
  /**
8825
10221
  * The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
8826
10222
  * — the update installed, and an older copy running — says to run the command from the installed one.
8827
10223
  */
8828
- function updateRequired(pending, binary) {
8829
- const later = `\`${UPDATE_WAYS.later.command}\` to put it off until tomorrow`;
8830
- const npx = `Where agentcomms is not installed: \`${UPDATE_WAYS.update.npx}\`, and \`${UPDATE_WAYS.later.npx}\`.`;
10224
+ function updateRequired(pending, cli, commands) {
10225
+ const ways = updateWaysOf(commands);
8831
10226
  const details = {
8832
10227
  running: pending.running,
8833
10228
  latest: pending.latest,
8834
10229
  installed: pending.kind === "restart",
8835
- update: UPDATE_WAYS.update,
8836
- later: UPDATE_WAYS.later
10230
+ update: ways.update,
10231
+ later: ways.later
8837
10232
  };
8838
- 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}`, {
8839
- 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}`,
10233
+ 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`)}`, {
10234
+ 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.`,
8840
10235
  details
8841
10236
  });
8842
- return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): run \`${UPDATE_WAYS.update.command}\` first, or ${later}`, {
8843
- 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}`,
10237
+ 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`)}`, {
10238
+ 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.`,
8844
10239
  details
8845
10240
  });
8846
10241
  }
10242
+ /** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
10243
+ function bothSaid(commands, say) {
10244
+ return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
10245
+ }
8847
10246
  /**
8848
10247
  * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
8849
10248
  * the update did everything and this command's release is now installed where it runs from — its global package at
@@ -8851,8 +10250,10 @@ function updateRequired(pending, binary) {
8851
10250
  * script that goes on after `cmd && next` must not take it for the command's success.
8852
10251
  */
8853
10252
  async function afterUpdate(outcome, options, before) {
8854
- const { streams, binary } = options;
8855
- const notRun = `${binary} did not run`;
10253
+ const { streams } = options;
10254
+ const cli = cliOf(options.channel);
10255
+ const notRun = "The command did not run";
10256
+ const commands = updateCommands(options.core, options.output.platform);
8856
10257
  if (outcome === "failed") {
8857
10258
  streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
8858
10259
  return EXIT_CODES.UNAVAILABLE;
@@ -8866,10 +10267,10 @@ async function afterUpdate(outcome, options, before) {
8866
10267
  streams.stdout.write("Updated. Run your command again.\n");
8867
10268
  return EXIT_CODES.OK;
8868
10269
  }
8869
- streams.stdout.write(`${before.latest} is installed here: run your command again from it. ${notRun}.\n`);
10270
+ streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
8870
10271
  return EXIT_CODES.UPDATE;
8871
10272
  }
8872
- 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`);
10273
+ 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`);
8873
10274
  return EXIT_CODES.UPDATE;
8874
10275
  }
8875
10276
  async function askLine(streams, question) {
@@ -9143,18 +10544,20 @@ async function handUpdateCheckToChild(core, env, options) {
9143
10544
  const claimedAt = await claimUpdateCheck(core, { now: options.now });
9144
10545
  if (claimedAt === null) return;
9145
10546
  const entry = options.entry === void 0 ? updateCheckChildEntry() : options.entry;
9146
- if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
10547
+ if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, core.paths, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
9147
10548
  } catch {}
9148
10549
  }
9149
10550
  /**
9150
10551
  * Starts the child and waits, until `deadline` at most, for it to say the ask is over, or to end. False when it could
9151
10552
  * not be started at all; then nothing was asked, and the claim is still this process's to ask under.
9152
10553
  */
9153
- async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
10554
+ async function startUpdateCheckChild(entry, claimedAt, paths, env, deadline) {
9154
10555
  let child;
9155
10556
  try {
10557
+ const pathArgs = PATH_OPTIONS.flatMap(({ key, flag }) => [flag, paths[key]]);
9156
10558
  child = spawn(entry.command, [
9157
10559
  ...entry.args,
10560
+ ...pathArgs,
9158
10561
  UPDATE_CHECK_CHILD_COMMAND,
9159
10562
  claimedAt
9160
10563
  ], {
@@ -9199,7 +10602,7 @@ async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
9199
10602
  * already gone on, and the ask is finished all the same. Results are written only while the claim is this child's.
9200
10603
  */
9201
10604
  async function runUpdateCheckChild(core, env, claimedAt) {
9202
- if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: "Nothing runs this but a command finishing the day’s update check; `agentcomms update --check` asks now." });
10605
+ if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: handoffSentence(requireHandoffs(core).core(["update", "--check"]), (command) => `Nothing runs this but a command finishing the day’s update check; ${command} asks now.`) });
9203
10606
  try {
9204
10607
  if ((await readUpdateCheck(core.paths.stateDir)).checking === claimedAt) await askUnderClaim(core, env, claimedAt);
9205
10608
  } finally {
@@ -9238,8 +10641,8 @@ function terminalUpdateHooks(core, env, options) {
9238
10641
  const result = await gatedChangeAtTerminal(core, updateChange(core, env, {}, options.deps), {
9239
10642
  env,
9240
10643
  output: options.output,
9241
- command: "agentcomms update",
9242
- approveCommand: options.approveCommand,
10644
+ rerun: ["update"],
10645
+ rerunOn: "core",
9243
10646
  streams: options.streams
9244
10647
  });
9245
10648
  writeResult(result, options.output, (r) => renderUpdate(r, options.output.color), options.streams);
@@ -9249,7 +10652,7 @@ function terminalUpdateHooks(core, env, options) {
9249
10652
  };
9250
10653
  }
9251
10654
  //#endregion
9252
- //#region ../core/dist/tool-arguments-JmEBiWPl.mjs
10655
+ //#region ../core/dist/tool-arguments-BcYu_EPp.mjs
9253
10656
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
9254
10657
  const TARGET = "draft-2020-12";
9255
10658
  /**
@@ -19009,7 +20412,7 @@ strictObject({
19009
20412
  }))) !== 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`);
19010
20413
  }
19011
20414
  if (isCore) {
19012
- if (manifest.binary !== CORE_BINARY) issue(["binary"], `the core's command is \`${CORE_BINARY}\``);
20415
+ if (manifest.binary !== CORE_BINARY) issue(["binary"], "is not the core's own command name");
19013
20416
  } else if (!CHANNEL_BINARY.test(manifest.binary)) issue(["binary"], "a channel's command is `agent-<something>`");
19014
20417
  (manifest.server.bins ?? []).forEach((bin, index) => {
19015
20418
  if (!CHANNEL_BINARY.test(bin)) issue([
@@ -19033,7 +20436,7 @@ strictObject({
19033
20436
  if (rivals.word === void 0 && rivals.packages === void 0) issue(["rivals"], "a word or packages");
19034
20437
  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");
19035
20438
  }
19036
- if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"], `the channel's own command: \`${manifest.binary} …\``);
20439
+ 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");
19037
20440
  if (manifest.skills) {
19038
20441
  const family = manifest.skills.prefix.slice(0, -1);
19039
20442
  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`);
@@ -20349,6 +21752,9 @@ function hiddenReason(element) {
20349
21752
  * What stays here is what core cannot say about a Resend account: that its mode is one of the two words (a record
20350
21753
  * with any other is refused, never read as something narrower), what its key can do, and that its secret reference
20351
21754
  * is its own.
21755
+ *
21756
+ * A refusal here names the command that repairs it, made by `handoffs` — the context's, Resend's own CLI as this
21757
+ * installation runs it (CUE-403) — or says why there is none here.
20352
21758
  */
20353
21759
  const PLATFORM = "resend";
20354
21760
  const MODES = ["read", "send"];
@@ -20376,7 +21782,7 @@ function keyPermissionOf(account) {
20376
21782
  * wrong. Core's schema reads any mode word so that one odd account cannot make the whole file unreadable; acting on
20377
21783
  * one is refused here, as Slack refuses its own.
20378
21784
  */
20379
- function checkedAccount(name, account, platform = process.platform) {
21785
+ function checkedAccount(name, account, handoffs) {
20380
21786
  const problem = (() => {
20381
21787
  if (account.platform !== "resend") return `it is a ${account.platform} account`;
20382
21788
  if (!MODES.includes(String(account.mode))) return `its mode is "${String(account.mode)}", and a Resend account is in read or send mode`;
@@ -20386,67 +21792,65 @@ function checkedAccount(name, account, platform = process.platform) {
20386
21792
  if (lock !== void 0 && (typeof lock !== "string" || !DOMAIN.test(lock))) return "its domainLock is not a domain";
20387
21793
  return null;
20388
21794
  })();
20389
- if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: `Remove it with ${inlineCommand(shellCommand([
20390
- "agent-resend",
21795
+ if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: handoffSentence(handoffs.own([
20391
21796
  "account",
20392
21797
  "remove",
20393
21798
  name
20394
- ], platform))} and add it again, or fix it in the configuration file.` });
21799
+ ]), (command) => `Remove it with ${command} and add it again, or fix it in the configuration file.`, { instead: "Fix it in the configuration file." }) });
20395
21800
  return account;
20396
21801
  }
20397
- function notFound(name) {
20398
- return () => new CommsError("NOT_FOUND", `there is no Resend account called "${name}"`, { hint: "List them with `agent-resend account list`; a person adds one with `agent-resend account add <org/resend>`." });
21802
+ function notFound(name, handoffs) {
21803
+ return () => new CommsError("NOT_FOUND", `there is no Resend account called "${name}"`, { hint: handoffSentence(handoffs.own(["account", "list"]), (listed) => handoffSentenceToFill(handoffs.own(["account", "add"]), ["<org/resend>"], (adding) => `List them with ${listed}; a person adds one with ${adding}.`)) });
20399
21804
  }
20400
21805
  /** Every Resend account in `config`, checked, by name. */
20401
- function resendAccounts(config, platform = process.platform) {
21806
+ function resendAccounts(config, handoffs) {
20402
21807
  return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name, account]) => ({
20403
21808
  name,
20404
- account: checkedAccount(name, account, platform)
21809
+ account: checkedAccount(name, account, handoffs)
20405
21810
  })).sort((a, b) => a.name < b.name ? -1 : 1);
20406
21811
  }
20407
21812
  /**
20408
21813
  * The account by name, or a refusal that says what to do — through core's `resolveName`, so a name that was replaced
20409
21814
  * is refused with the one it has now rather than reported as unknown.
20410
21815
  */
20411
- function requireAccount(config, name, platform = process.platform) {
20412
- const { alias, account } = resolveName(config, "account", name, notFound(name));
20413
- if (account.platform !== "resend") throw notFound(name)();
21816
+ function requireAccount(config, name, handoffs) {
21817
+ const { alias, account } = resolveName(config, "account", name, notFound(name, handoffs));
21818
+ if (account.platform !== "resend") throw notFound(name, handoffs)();
20414
21819
  return {
20415
21820
  name: alias,
20416
- account: checkedAccount(alias, account, platform)
21821
+ account: checkedAccount(alias, account, handoffs)
20417
21822
  };
20418
21823
  }
20419
21824
  /** The account under exactly this name, or null: no former names, no refusal. */
20420
- function lookupAccount(config, name, platform = process.platform) {
21825
+ function lookupAccount(config, name, handoffs) {
20421
21826
  const account = lookupName(config, "account", name);
20422
21827
  if (!account || account.platform !== "resend") return null;
20423
21828
  return {
20424
21829
  name,
20425
- account: checkedAccount(name, account, platform)
21830
+ account: checkedAccount(name, account, handoffs)
20426
21831
  };
20427
21832
  }
20428
21833
  /** The account by its immutable id, under whatever name it has now. */
20429
- function accountById(config, id, platform = process.platform) {
21834
+ function accountById(config, id, handoffs) {
20430
21835
  const found = findById(config, "account", id);
20431
21836
  if (!found || found.account.platform !== "resend") return null;
20432
21837
  return {
20433
21838
  name: found.alias,
20434
- account: checkedAccount(found.alias, found.account, platform)
21839
+ account: checkedAccount(found.alias, found.account, handoffs)
20435
21840
  };
20436
21841
  }
20437
21842
  /**
20438
21843
  * Refuses a name a new account cannot take — core's rule, for either config version: the grammar and the platform,
20439
21844
  * free in both maps, and never a former name — with this package's own hint for one already connected.
20440
21845
  */
20441
- function checkNewName(config, name, platform = process.platform) {
21846
+ function checkNewName(config, name, handoffs) {
20442
21847
  const check = nameAvailable(config, "account", name, PLATFORM);
20443
21848
  if (check.ok) return;
20444
- if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint: `Choose another name, or remove that one first with ${inlineCommand(shellCommand([
20445
- "agent-resend",
21849
+ if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint: handoffSentence(handoffs.own([
20446
21850
  "account",
20447
21851
  "remove",
20448
21852
  name
20449
- ], platform))}.` });
21853
+ ]), (command) => `Choose another name, or remove that one first with ${command}.`, { instead: "Choose another name." }) });
20450
21854
  throw check.error;
20451
21855
  }
20452
21856
  /** `config` with `account` under `name`. */
@@ -20482,22 +21886,22 @@ function changePolicyIn(config, account) {
20482
21886
  */
20483
21887
  var AccountStore = class {
20484
21888
  #load;
20485
- #platform;
20486
- constructor(load, platform = process.platform) {
21889
+ #handoffs;
21890
+ constructor(load, handoffs) {
20487
21891
  this.#load = load;
20488
- this.#platform = platform;
21892
+ this.#handoffs = handoffs;
20489
21893
  }
20490
21894
  async list() {
20491
- return resendAccounts(await this.#load(), this.#platform);
21895
+ return resendAccounts(await this.#load(), this.#handoffs);
20492
21896
  }
20493
21897
  async find(name) {
20494
- return lookupAccount(await this.#load(), name, this.#platform);
21898
+ return lookupAccount(await this.#load(), name, this.#handoffs);
20495
21899
  }
20496
21900
  async findById(id) {
20497
- return accountById(await this.#load(), id, this.#platform);
21901
+ return accountById(await this.#load(), id, this.#handoffs);
20498
21902
  }
20499
21903
  async require(name) {
20500
- return requireAccount(await this.#load(), name, this.#platform);
21904
+ return requireAccount(await this.#load(), name, this.#handoffs);
20501
21905
  }
20502
21906
  };
20503
21907
  var Throttle = class {
@@ -20628,23 +22032,44 @@ var Throttle = class {
20628
22032
  //#endregion
20629
22033
  //#region src/context.ts
20630
22034
  const TEAM_DOMAINS_TTL_MS = 3e5;
22035
+ /**
22036
+ * The commands this package prints, located from it (CUE-403): core has to have been opened with Resend as its caller.
22037
+ * A core opened without one — or as another package — would print bare names, or that package's commands for Resend's,
22038
+ * so it is a programming error here rather than a sentence a person reads.
22039
+ */
22040
+ function resendHandoffs(core) {
22041
+ const handoffs = requireHandoffs(core);
22042
+ if (handoffs.caller.packageName !== "@agentcomms/resend") throw new TypeError(`core was opened as ${handoffs.caller.packageName}, so it would print that package's commands for Resend's: open it with RESEND_CALLER`);
22043
+ return handoffs;
22044
+ }
20631
22045
  var ResendContext = class {
20632
22046
  core;
20633
22047
  env;
20634
22048
  now;
20635
22049
  platform;
20636
22050
  surface;
22051
+ /**
22052
+ * What every result, refusal and hint names for a person to run: Resend's own CLI as this installation runs it, or
22053
+ * core's through its installed dependency (`own`, `core`), quoted for `platform` — or why there is none here.
22054
+ */
22055
+ handoffs;
20637
22056
  accounts;
20638
22057
  #fetch;
20639
22058
  #throttle;
20640
22059
  #teamDomains = /* @__PURE__ */ new Map();
20641
22060
  constructor(options = {}) {
20642
22061
  this.env = options.env ?? process.env;
20643
- this.core = options.core ?? openCore({ env: this.env });
22062
+ this.core = options.core ?? openCore({
22063
+ env: this.env,
22064
+ platform: options.platform ?? process.platform,
22065
+ ...options.pathOverrides ? { pathOverrides: options.pathOverrides } : {},
22066
+ caller: RESEND_CALLER
22067
+ });
20644
22068
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
20645
22069
  this.platform = options.platform ?? process.platform;
20646
22070
  this.surface = options.surface ?? "cli";
20647
- this.accounts = new AccountStore(() => this.core.config.load(), this.platform);
22071
+ this.handoffs = resendHandoffs(this.core).on(this.platform);
22072
+ this.accounts = new AccountStore(() => this.core.config.load(), this.handoffs);
20648
22073
  this.#fetch = options.fetch;
20649
22074
  this.#throttle = new Throttle(this.core.paths.stateDir, options.throttle);
20650
22075
  }
@@ -20693,12 +22118,11 @@ var ResendContext = class {
20693
22118
  /** A transport for a connected account: its key from the secret store, the machine's throttle, and any open permit. */
20694
22119
  async transport(named, permit) {
20695
22120
  const key = await (await this.secrets()).get(named.account.secretRef);
20696
- if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint: `A person removes the account and adds it again: ${inlineCommand(shellCommand([
20697
- "agent-resend",
22121
+ if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint: handoffSentence(this.handoffs.own([
20698
22122
  "account",
20699
22123
  "add",
20700
22124
  named.name
20701
- ], this.platform))}.` });
22125
+ ]), (command) => `A person removes the account and adds it again: ${command}.`) });
20702
22126
  return {
20703
22127
  fetch: this.#fetch,
20704
22128
  key,
@@ -20818,7 +22242,7 @@ function codeFor(status, name) {
20818
22242
  if (name === "restricted_api_key" && status === 401) return {
20819
22243
  code: "SCOPE_MISSING",
20820
22244
  message: "this account’s key can only send email; Resend refuses everything else with it",
20821
- hint: "Reads need a full-access key. Add one under another name with `agent-resend account add`."
22245
+ hint: "Reads need a full-access key. A person adds one under another account name, at their own terminal."
20822
22246
  };
20823
22247
  if (status === 401 || name === "restricted_api_key" || name === "suspended_api_key") return {
20824
22248
  code: "AUTH_REQUIRED",
@@ -21049,7 +22473,8 @@ async function readAttachment(path, policy) {
21049
22473
  const real = await checkAttachable(path, {
21050
22474
  roots: [...policy.roots],
21051
22475
  deny: [...defaultAttachDeny(policy.configDir, policy.env), ...policy.deny],
21052
- home
22476
+ home,
22477
+ handoffs: policy.handoffs
21053
22478
  });
21054
22479
  if ((await stat(real)).size > 41943040) throw bad(`${basename(real)} is larger than Resend accepts`);
21055
22480
  const bytes = new Uint8Array(await readFile(real));
@@ -21251,8 +22676,8 @@ function keyFingerprint(key) {
21251
22676
  }
21252
22677
  /** The plain statement `account show` and `doctor` make about who enforces what. */
21253
22678
  function guaranteeOf(account) {
21254
- if (keyPermissionOf(account) === "sending_access") return `This key can only send: Resend enforces that, and refuses it every read.${account.domainLock ? ` It was declared restricted to ${account.domainLock} when it was added; that is not something agent-resend can check without sending, so Resend enforces it, not this package.` : ""} agent-resend still sends only after a person approves each email.`;
21255
- return account.mode === "read" ? "Read-only is enforced by agent-resend’s own code, not by the key. Resend has no read-only key: this full-access key could send mail, delete domains and create API keys in anything else that held it. agent-resend refuses every send from this account while it is in read mode." : "This full-access key can do anything at Resend. agent-resend sends only after a person approves each email, and never manages domains, keys or webhooks — but the key itself would not stop anything else that held it.";
22679
+ if (keyPermissionOf(account) === "sending_access") return `This key can only send: Resend enforces that, and refuses it every read.${account.domainLock ? ` It was declared restricted to ${account.domainLock} when it was added; that is not something this package can check without sending, so Resend enforces it, not this package.` : ""} This package still sends only after a person approves each email.`;
22680
+ return account.mode === "read" ? "Read-only is enforced by this package’s own code, not by the key. Resend has no read-only key: this full-access key could send mail, delete domains and create API keys in anything else that held it. This package refuses every send from this account while it is in read mode." : "This full-access key can do anything at Resend. This package sends only after a person approves each email, and never manages domains, keys or webhooks — but the key itself would not stop anything else that held it.";
21256
22681
  }
21257
22682
  function viewOf(named, config) {
21258
22683
  const { account } = named;
@@ -21275,11 +22700,11 @@ function viewOf(named, config) {
21275
22700
  }
21276
22701
  async function listAccounts(context) {
21277
22702
  const config = await context.config();
21278
- return { accounts: resendAccounts(config, context.platform).map((named) => viewOf(named, config)) };
22703
+ return { accounts: resendAccounts(config, context.handoffs).map((named) => viewOf(named, config)) };
21279
22704
  }
21280
22705
  async function showAccount(context, name) {
21281
22706
  const config = await context.config();
21282
- return viewOf(requireAccount(config, name, context.platform), config);
22707
+ return viewOf(requireAccount(config, name, context.handoffs), config);
21283
22708
  }
21284
22709
  /** A mode word checked by the operation, so both surfaces refuse it in the same words. */
21285
22710
  function modeOf(raw) {
@@ -21298,9 +22723,13 @@ function changePolicyOf(raw) {
21298
22723
  throw new CommsError("USAGE", `"${String(raw)}" is not a change policy: use chat or confirm`);
21299
22724
  }
21300
22725
  /** Refuses a key that is already connected, under any name: one key, one account. */
21301
- function refuseConnectedKey(config, fingerprint, platform) {
21302
- const same = resendAccounts(config, platform).find((named) => named.account.workspace === fingerprint);
21303
- if (same) throw new CommsError("USAGE", `this key is already connected as "${same.name}"`, { hint: "One key, one account. Change that one with `agent-resend account policy`." });
22726
+ function refuseConnectedKey(config, fingerprint, handoffs) {
22727
+ const same = resendAccounts(config, handoffs).find((named) => named.account.workspace === fingerprint);
22728
+ if (same) throw new CommsError("USAGE", `this key is already connected as "${same.name}"`, { hint: handoffSentence(handoffs.own([
22729
+ "account",
22730
+ "policy",
22731
+ same.name
22732
+ ]), (command) => `One key, one account. Change that one with ${command}, adding --send, --mode or --change.`) });
21304
22733
  }
21305
22734
  /**
21306
22735
  * Connects an account, once the key has been inspected — a `GatedChange`, so connecting one in `send` mode, or with
@@ -21334,8 +22763,8 @@ function addAccountChange(context, request, inspection) {
21334
22763
  ...domainLock ? { domainLock } : {}
21335
22764
  };
21336
22765
  const connect = (config) => {
21337
- checkNewName(config, request.name, context.platform);
21338
- refuseConnectedKey(config, fingerprint, context.platform);
22766
+ checkNewName(config, request.name, context.handoffs);
22767
+ refuseConnectedKey(config, fingerprint, context.handoffs);
21339
22768
  return withAccount(config, request.name, account);
21340
22769
  };
21341
22770
  return {
@@ -21351,7 +22780,7 @@ function addAccountChange(context, request, inspection) {
21351
22780
  };
21352
22781
  },
21353
22782
  apply: async (consent) => {
21354
- const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.platform);
22783
+ const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.handoffs);
21355
22784
  const secrets = await context.core.secrets(store);
21356
22785
  await secrets.set(account.secretRef, request.key.trim());
21357
22786
  let written;
@@ -21392,7 +22821,7 @@ function addAccountChange(context, request, inspection) {
21392
22821
  function removeAccountChange(context, name) {
21393
22822
  return {
21394
22823
  plan: (config) => {
21395
- const found = requireAccount(config, name, context.platform);
22824
+ const found = requireAccount(config, name, context.handoffs);
21396
22825
  return {
21397
22826
  account: found.name,
21398
22827
  before: config,
@@ -21402,25 +22831,23 @@ function removeAccountChange(context, name) {
21402
22831
  };
21403
22832
  },
21404
22833
  apply: async (_consent, request) => {
21405
- const approved = requireAccount(request.before, name, context.platform).account;
22834
+ const approved = requireAccount(request.before, name, context.handoffs).account;
21406
22835
  const removed = await withCredentialsLock(context.core.paths.configDir, async () => {
21407
- const found = requireAccount(await context.config(), name, context.platform);
21408
- if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint: `Look at it with ${inlineCommand(shellCommand([
21409
- "agent-resend",
22836
+ const found = requireAccount(await context.config(), name, context.handoffs);
22837
+ if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint: handoffSentence(context.handoffs.own([
21410
22838
  "account",
21411
22839
  "show",
21412
22840
  name
21413
- ], context.platform))}, and remove it again if you still want it gone.` });
22841
+ ]), (command) => `Look at it with ${command}, and remove it again if you still want it gone.`) });
21414
22842
  const secrets = await context.secrets();
21415
22843
  await secrets.delete(found.account.secretRef).catch(() => false);
21416
22844
  await context.core.config.update((current) => {
21417
- if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: `Run ${inlineCommand(shellCommand([
21418
- "agent-resend",
22845
+ if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: handoffSentence(context.handoffs.own([
21419
22846
  "account",
21420
22847
  "remove",
21421
22848
  name
21422
- ], context.platform))} again.` });
21423
- const held = accountById(current, found.account.id, context.platform);
22849
+ ]), (command) => `Run ${command} again.`) });
22850
+ const held = accountById(current, found.account.id, context.handoffs);
21424
22851
  if (!held) return current;
21425
22852
  return withoutAccount(current, held.name);
21426
22853
  });
@@ -21472,7 +22899,7 @@ function reportOf(named, config) {
21472
22899
  }
21473
22900
  async function policyReport(context, name) {
21474
22901
  const config = await context.config();
21475
- return reportOf(requireAccount(config, name, context.platform), config);
22902
+ return reportOf(requireAccount(config, name, context.handoffs), config);
21476
22903
  }
21477
22904
  /**
21478
22905
  * Why a policy report refuses the approval it is handed, in the words of the surface it came from, as core's
@@ -21514,7 +22941,7 @@ function policyChange(context, name, wanted) {
21514
22941
  ].filter(Boolean).join(", ");
21515
22942
  return {
21516
22943
  plan: (config) => {
21517
- const found = checked(requireAccount(config, name, context.platform));
22944
+ const found = checked(requireAccount(config, name, context.handoffs));
21518
22945
  return {
21519
22946
  account: found.name,
21520
22947
  before: config,
@@ -21523,11 +22950,11 @@ function policyChange(context, name, wanted) {
21523
22950
  };
21524
22951
  },
21525
22952
  apply: async (consent, request) => {
21526
- const approved = requireAccount(request.before, name, context.platform).account;
22953
+ const approved = requireAccount(request.before, name, context.handoffs).account;
21527
22954
  let written;
21528
22955
  const config = await context.core.config.update((current) => {
21529
- const held = accountById(current, approved.id, context.platform);
21530
- if (!held) throw new CommsError("CONFIG", `"${name}" changed while its policy was being set, so nothing was set`, { hint: "Check it with `agent-resend account list`, then set the policy again." });
22956
+ const held = accountById(current, approved.id, context.handoffs);
22957
+ if (!held) throw new CommsError("CONFIG", `"${name}" changed while its policy was being set, so nothing was set`, { hint: handoffSentence(context.handoffs.own(["account", "list"]), (command) => `Check it with ${command}, then set the policy again.`) });
21531
22958
  written = {
21532
22959
  name: held.name,
21533
22960
  account: next(checked(held).account)
@@ -21559,7 +22986,17 @@ function policyChange(context, name, wanted) {
21559
22986
  * own promise rather than the key's: a person deciding how much to trust an agent with a full-access key should not
21560
22987
  * have to find that out from the source.
21561
22988
  */
21562
- const READ_ONLY_STATEMENT = "Read-only is enforced by agent-resend’s own code, not by the key: Resend has no read-only API key, and a full-access key can also send, delete domains and create keys. A sending-only key can only send; Resend enforces that one.";
22989
+ const READ_ONLY_STATEMENT = "Read-only is enforced by this package’s own code, not by the key: Resend has no read-only API key, and a full-access key can also send, delete domains and create keys. A sending-only key can only send; Resend enforces that one.";
22990
+ /** Two commands to run one after the other, as a fix: both located, or the reason there is none — said once. */
22991
+ function thenFix(first, second) {
22992
+ if (!isCommand(first)) return remedy(first);
22993
+ if (!isCommand(second)) return remedy(second);
22994
+ return remedy([
22995
+ first,
22996
+ ", then ",
22997
+ second
22998
+ ]);
22999
+ }
21563
23000
  async function checkAccount(context, named, offline) {
21564
23001
  const checks = [];
21565
23002
  let key = null;
@@ -21573,24 +23010,22 @@ async function checkAccount(context, named, offline) {
21573
23010
  name: "key stored",
21574
23011
  ok: false,
21575
23012
  detail: "the key is missing from the secret store",
21576
- fix: `${commandText(shellCommand([
21577
- "agent-resend",
23013
+ fix: thenFix(context.handoffs.own([
21578
23014
  "account",
21579
23015
  "remove",
21580
23016
  named.name
21581
- ], context.platform))}, then ${commandText(shellCommand([
21582
- "agent-resend",
23017
+ ]), context.handoffs.own([
21583
23018
  "account",
21584
23019
  "add",
21585
23020
  named.name
21586
- ], context.platform))}`
23021
+ ]))
21587
23022
  });
21588
23023
  } catch (error) {
21589
23024
  checks.push({
21590
23025
  name: "key stored",
21591
23026
  ok: false,
21592
23027
  detail: toCommsError(error).message,
21593
- fix: "agentcomms doctor"
23028
+ fix: remedy(context.handoffs.core(["doctor"]))
21594
23029
  });
21595
23030
  }
21596
23031
  const blocked = await context.throttle().blockedUntil();
@@ -21610,7 +23045,7 @@ async function checkAccount(context, named, offline) {
21610
23045
  name: "key permission",
21611
23046
  ok: false,
21612
23047
  detail: "recorded as sending-only, but Resend let it read — it has full access",
21613
- fix: `remove and add ${named.name} again so its permission is recorded as it is`
23048
+ fix: remedy(`remove and add ${named.name} again so its permission is recorded as it is`)
21614
23049
  });
21615
23050
  else checks.push({
21616
23051
  name: "key works",
@@ -21627,13 +23062,13 @@ async function checkAccount(context, named, offline) {
21627
23062
  name: "key permission",
21628
23063
  ok: false,
21629
23064
  detail: "recorded as full access, but Resend now refuses it every read",
21630
- fix: `remove and add ${named.name} again with the key you mean`
23065
+ fix: remedy(`remove and add ${named.name} again with the key you mean`)
21631
23066
  });
21632
23067
  else checks.push({
21633
23068
  name: "key works",
21634
23069
  ok: false,
21635
23070
  detail: comms.message,
21636
- ...comms.hint ? { fix: comms.hint } : {}
23071
+ ...comms.hint ? { fix: remedy(comms.hint) } : {}
21637
23072
  });
21638
23073
  }
21639
23074
  return {
@@ -21653,7 +23088,7 @@ async function runDoctor(context, options = {}) {
21653
23088
  name: "node",
21654
23089
  ok: nodeOk,
21655
23090
  detail: `Node ${process.versions.node}`,
21656
- ...nodeOk ? {} : { fix: "Install Node 22.12 or newer." }
23091
+ ...nodeOk ? {} : { fix: remedy("Install Node 22.12 or newer.") }
21657
23092
  });
21658
23093
  let store = "unknown";
21659
23094
  try {
@@ -21668,7 +23103,7 @@ async function runDoctor(context, options = {}) {
21668
23103
  name: "config",
21669
23104
  ok: false,
21670
23105
  detail: toCommsError(error).message,
21671
- fix: "agentcomms doctor"
23106
+ fix: remedy(context.handoffs.core(["doctor"]))
21672
23107
  });
21673
23108
  }
21674
23109
  let named = [];
@@ -22086,12 +23521,16 @@ function domainRow(entry) {
22086
23521
  }
22087
23522
  /** The team's domains; with `domain`, that one with the DNS records it needs. */
22088
23523
  async function listDomains(context, name, options = {}) {
22089
- return whenReadable(context, name, async (_named, transport) => {
23524
+ return whenReadable(context, name, async (named, transport) => {
22090
23525
  const domains = ((await resendRequest(transport, "GET", "/domains")).data ?? []).map(domainRow);
22091
23526
  if (options.domain === void 0) return { domains };
22092
23527
  const wanted = options.domain.trim().toLowerCase();
22093
23528
  const found = domains.find((domain) => domain.name.toLowerCase() === wanted || domain.id === wanted);
22094
- if (!found) throw new CommsError("NOT_FOUND", `"${options.domain}" is not one of this team’s domains`, { hint: "List them with `agent-resend domains --account <org/resend>`." });
23529
+ if (!found) throw new CommsError("NOT_FOUND", `"${options.domain}" is not one of this team’s domains`, { hint: handoffSentence(context.handoffs.own([
23530
+ "domains",
23531
+ "--account",
23532
+ named.name
23533
+ ]), (command) => `List them with ${command}.`) });
22095
23534
  const detail = await resendRequest(transport, "GET", `/domains/${resendId(found.id, "domain id")}`);
22096
23535
  const records = (Array.isArray(detail.records) ? detail.records : []).map((record) => ({
22097
23536
  record: text(record.record),
@@ -22371,7 +23810,7 @@ const DAY = /^\d{4}-\d{2}-\d{2}/;
22371
23810
  /** The downloads root: `~/Downloads/agent-communications` unless core's config says otherwise. */
22372
23811
  async function downloadsRoot(context) {
22373
23812
  const configured = (await context.config()).defaults.downloadsDir;
22374
- const root = configured ? expandHome(configured, homeDirectory(context.env)) : context.core.paths.downloadsDir;
23813
+ const root = context.core.pathOverrides.downloadsDir ?? (configured ? expandHome(configured, homeDirectory(context.env)) : context.core.paths.downloadsDir);
22375
23814
  await ensurePrivateDir(root);
22376
23815
  return root;
22377
23816
  }
@@ -22630,15 +24069,28 @@ async function ownRecord(context, named, approvalId) {
22630
24069
  if (!record || approvalKind(record) !== "send" || record.inboxId !== named.account.id) throw new CommsError("NOT_FOUND", `there is no send approval ${approvalId} for "${named.name}"`, { hint: "Approvals last ten minutes. Prepare the send again." });
22631
24070
  return record;
22632
24071
  }
22633
- function requireSendMode(named, platform) {
22634
- if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint: `A person can allow sending with ${inlineCommand(shellCommand([
22635
- "agent-resend",
24072
+ function requireSendMode(named, handoffs) {
24073
+ if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint: handoffSentence(handoffs.own([
22636
24074
  "account",
22637
24075
  "policy",
22638
24076
  named.name,
22639
24077
  "--mode",
22640
24078
  "send"
22641
- ], platform))}, which is a change they approve.` });
24079
+ ]), (command) => `A person can allow sending with ${command}, which is a change they approve.`) });
24080
+ }
24081
+ /** Resend's own `approve` for this send, located: what a person runs at their own terminal under `confirm`. */
24082
+ function approveCommand(handoffs, approvalId) {
24083
+ return handoffs.own(["approve", approvalId]);
24084
+ }
24085
+ /** Resend's own `send status` for this send, located: how a send whose outcome is not known is checked. */
24086
+ function statusCommand(handoffs, approvalId, name) {
24087
+ return handoffs.own([
24088
+ "send",
24089
+ "status",
24090
+ approvalId,
24091
+ "--account",
24092
+ name
24093
+ ]);
22642
24094
  }
22643
24095
  /**
22644
24096
  * Whether the From domain may send, checked before an approval exists.
@@ -22653,8 +24105,18 @@ async function checkFromDomain(context, named, domain) {
22653
24105
  return `From domain ${domain}: not checked — a sending-only key cannot read the domain list; Resend refuses an unverified domain`;
22654
24106
  }
22655
24107
  const found = ((await resendRequest(await context.transport(named), "GET", "/domains")).data ?? []).find((entry) => String(entry.name ?? "").toLowerCase() === domain);
22656
- if (!found) throw new CommsError("BAD_DATA", `${domain} is not one of this Resend team's domains, so nothing can be sent from it`, { hint: "Send from an address at a verified domain: see `agent-resend domains`." });
22657
- if (found.status !== "verified") throw new CommsError("BAD_DATA", `${domain} is not verified at Resend (status: ${String(found.status)})`, { hint: "A person finishes the DNS records for it first: `agent-resend domains --domain <name>` lists them." });
24108
+ if (!found) throw new CommsError("BAD_DATA", `${domain} is not one of this Resend team's domains, so nothing can be sent from it`, { hint: handoffSentence(context.handoffs.own([
24109
+ "domains",
24110
+ "--account",
24111
+ named.name
24112
+ ]), (command) => `Send from an address at a verified domain: see ${command}.`) });
24113
+ if (found.status !== "verified") throw new CommsError("BAD_DATA", `${domain} is not verified at Resend (status: ${String(found.status)})`, { hint: handoffSentence(context.handoffs.own([
24114
+ "domains",
24115
+ "--account",
24116
+ named.name,
24117
+ "--domain",
24118
+ domain
24119
+ ]), (command) => `A person finishes the DNS records for it first: ${command} lists them.`) });
22658
24120
  const sending = found.capabilities?.sending;
22659
24121
  if (sending !== void 0 && sending !== "enabled") throw new CommsError("BAD_DATA", `${domain} is verified, but sending from it is ${String(sending)}`);
22660
24122
  return `From domain ${domain}: verified for sending at Resend`;
@@ -22705,12 +24167,12 @@ function expectationOf(message) {
22705
24167
  subject: message.subject
22706
24168
  };
22707
24169
  }
22708
- function describePolicy(effective, approvalId, flags, platform) {
22709
- if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — a person runs ${inlineCommand(shellCommand([
22710
- "agent-resend",
22711
- "approve",
22712
- approvalId
22713
- ], platform))} at their own terminal before this can go.`;
24170
+ /**
24171
+ * The preview's policy line. Under `confirm` it names Resend's own `approve`, located by the process that renders it —
24172
+ * the preparing one, then the approving one: a send's approval is bound to the message's digest, not to this line.
24173
+ */
24174
+ function describePolicy(effective, approvalId, flags, handoffs) {
24175
+ if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — ${handoffSentence(approveCommand(handoffs, approvalId), (command) => `a person runs ${command} at their own terminal before this can go.`, { instead: "a person approves it at their own terminal before this can go." })}`;
22714
24176
  return "Policy: chat — send only after the user approves this exact preview.";
22715
24177
  }
22716
24178
  function previewOf(options) {
@@ -22720,7 +24182,12 @@ function previewOf(options) {
22720
24182
  `Reach: ${reach.length} unique recipient(s) — To ${message.to.length} · Cc ${message.cc.length} · Bcc ${message.bcc.length}${reach.length > 10 ? ` — more than 10, so a person approves it at a terminal` : ""}`,
22721
24183
  options.domainNote,
22722
24184
  ...message.bcc.length > 0 ? [`${message.bcc.length} blind recipient(s) — the others will not see them`] : [],
22723
- ...message.scheduledAt ? [`Scheduled for ${message.scheduledAt}; until then it can be cancelled with \`agent-resend scheduled cancel\``] : ["Sends as soon as it is approved and executed"],
24185
+ ...message.scheduledAt ? [handoffSentenceToFill(options.handoffs.own([
24186
+ "scheduled",
24187
+ "cancel",
24188
+ "--account",
24189
+ options.name
24190
+ ]), ["<id>"], (command) => `Scheduled for ${message.scheduledAt}; until then it can be cancelled with ${command}`, { instead: `Scheduled for ${message.scheduledAt}; until then it can be cancelled with resend_scheduled_cancel.` })] : ["Sends as soon as it is approved and executed"],
22724
24191
  ...options.study.warnings,
22725
24192
  ...options.built.warnings
22726
24193
  ];
@@ -22748,7 +24215,7 @@ function previewOf(options) {
22748
24215
  thread: message.inReplyTo ? `reply to ${message.inReplyTo}${message.references.length > 1 ? ` (${message.references.length} references)` : ""}` : void 0,
22749
24216
  links: options.built.links,
22750
24217
  warnings,
22751
- policy: describePolicy(options.effective, options.approvalId, options.flags, options.platform)
24218
+ policy: describePolicy(options.effective, options.approvalId, options.flags, options.handoffs)
22752
24219
  });
22753
24220
  }
22754
24221
  async function attachPolicy(context) {
@@ -22757,7 +24224,8 @@ async function attachPolicy(context) {
22757
24224
  configDir: context.core.paths.configDir,
22758
24225
  env: context.env,
22759
24226
  roots: config.defaults.attachRoots,
22760
- deny: config.defaults.attachDeny
24227
+ deny: config.defaults.attachDeny,
24228
+ handoffs: context.handoffs
22761
24229
  };
22762
24230
  }
22763
24231
  /**
@@ -22766,17 +24234,16 @@ async function attachPolicy(context) {
22766
24234
  */
22767
24235
  async function prepareSend(context, name, input) {
22768
24236
  const named = await context.accounts.require(name);
22769
- requireSendMode(named, context.platform);
24237
+ requireSendMode(named, context.handoffs);
22770
24238
  const config = await context.config();
22771
24239
  const livePolicy = named.account.sendPolicy ?? config.defaults.sendPolicy;
22772
- if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: `A person can change it with ${inlineCommand(shellCommand([
22773
- "agent-resend",
24240
+ if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: handoffSentence(context.handoffs.own([
22774
24241
  "account",
22775
24242
  "policy",
22776
24243
  name,
22777
24244
  "--send",
22778
24245
  "confirm"
22779
- ], context.platform))}.` });
24246
+ ]), (command) => `A person can change it with ${command}.`) });
22780
24247
  const built = await buildMessage(input, {
22781
24248
  now: context.now(),
22782
24249
  attach: await attachPolicy(context)
@@ -22829,7 +24296,7 @@ async function prepareSend(context, name, input) {
22829
24296
  domainNote,
22830
24297
  effective,
22831
24298
  flags: study.flags,
22832
- platform: context.platform
24299
+ handoffs: context.handoffs
22833
24300
  }),
22834
24301
  policy: livePolicy,
22835
24302
  effectivePolicy: effective,
@@ -22837,11 +24304,7 @@ async function prepareSend(context, name, input) {
22837
24304
  reach,
22838
24305
  expect: record.expect,
22839
24306
  expiresAt: record.expiresAt,
22840
- nextStep: effective === "confirm" ? `Show the preview to the user, then have them run ${inlineCommand(shellCommand([
22841
- "agent-resend",
22842
- "approve",
22843
- record.approvalId
22844
- ], context.platform))} in their own terminal. You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above."
24307
+ nextStep: effective === "confirm" ? `${handoffSentence(approveCommand(context.handoffs, record.approvalId), (command) => `Show the preview to the user, then have them run ${command} in their own terminal.`, { instead: "Show the preview to the user; they approve it at their own terminal." })} You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above."
22845
24308
  };
22846
24309
  }
22847
24310
  /** The stored message and its attachments' bytes, checked again: a file changed since the preview voids the send. */
@@ -22970,17 +24433,10 @@ async function recordNoSend(context, records, options, error) {
22970
24433
  */
22971
24434
  async function executeSend(context, name, options) {
22972
24435
  const named = await context.accounts.require(name);
22973
- requireSendMode(named, context.platform);
24436
+ requireSendMode(named, context.handoffs);
22974
24437
  const known = await ownRecord(context, named, options.approvalId);
22975
24438
  if (known.state !== "pending" && known.state !== "approved") throw new CommsError("APPROVAL_VOID", `nothing was sent: ${describeState(known.state)}`, {
22976
- hint: known.state === "unknown" ? `Check what happened with ${inlineCommand(shellCommand([
22977
- "agent-resend",
22978
- "send",
22979
- "status",
22980
- options.approvalId,
22981
- "--account",
22982
- name
22983
- ], context.platform))} before anything else.` : "Prepare the send again if it should still go.",
24439
+ hint: known.state === "unknown" ? handoffSentence(statusCommand(context.handoffs, options.approvalId, name), (command) => `Check what happened with ${command} before anything else.`, { instead: "Check what happened with resend_send_status before anything else." }) : "Prepare the send again if it should still go.",
22984
24440
  details: {
22985
24441
  approvalId: options.approvalId,
22986
24442
  state: known.state
@@ -23004,11 +24460,7 @@ async function executeSend(context, name, options) {
23004
24460
  policy: livePolicy,
23005
24461
  expect: options.expect
23006
24462
  }, {
23007
- pendingHint: `Ask the user to run ${inlineCommand(shellCommand([
23008
- "agent-resend",
23009
- "approve",
23010
- options.approvalId
23011
- ], context.platform))} in their own terminal, then execute it again with the same approval. You cannot approve it yourself.`,
24463
+ pendingHint: `${handoffSentence(approveCommand(context.handoffs, options.approvalId), (command) => `Ask the user to run ${command} in their own terminal, then execute it again with the same approval.`, { instead: "Ask the user to approve it at their own terminal, then execute it again with the same approval." })} You cannot approve it yourself.`,
23012
24464
  platform: context.platform
23013
24465
  });
23014
24466
  const records = new SendRecords(context.core.paths.stateDir, context.now);
@@ -23090,14 +24542,7 @@ async function executeSend(context, name, options) {
23090
24542
  unrecorded.push(`the audit log could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
23091
24543
  }
23092
24544
  throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
23093
- hint: [`Do not send it again. Check the Resend dashboard or ask the recipient, and check with ${inlineCommand(shellCommand([
23094
- "agent-resend",
23095
- "send",
23096
- "status",
23097
- options.approvalId,
23098
- "--account",
23099
- name
23100
- ], context.platform))}; this approval is not used again.`, ...unrecorded].join(" "),
24545
+ hint: [`Do not send it again. ${handoffSentence(statusCommand(context.handoffs, options.approvalId, name), (command) => `Check the Resend dashboard or ask the recipient, and check with ${command}; this approval is not used again.`, { instead: "Check the Resend dashboard or ask the recipient, and check with resend_send_status; this approval is not used again." })}`, ...unrecorded].join(" "),
23101
24546
  details: {
23102
24547
  ...error instanceof CommsError ? error.details : {},
23103
24548
  approvalId: options.approvalId,
@@ -23262,7 +24707,7 @@ async function beginSendApproval(context, approvalId) {
23262
24707
  domainNote: prepared.warnings[0] ?? "",
23263
24708
  effective,
23264
24709
  flags: record.riskFlags,
23265
- platform: context.platform
24710
+ handoffs: context.handoffs
23266
24711
  }),
23267
24712
  challenge
23268
24713
  };
@@ -23299,4 +24744,4 @@ async function revokeSendApproval(context, approvalId) {
23299
24744
  });
23300
24745
  }
23301
24746
  //#endregion
23302
- export { inlineCommand as $, CHANGE_POLICIES as A, agentMarker as B, policyChange as C, sendPolicyOf as D, removeAccountChange as E, CHANNEL_SERVERS as F, changeToolResult as G, approvalsOf as H, CommsError as I, commandPathOf as J, checkForUpdates as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, gatedChangeAtTerminal as Q, SEND_LOOKUP as R, policyApprovalRefusal as S, policyWanted as T, approveChangeAtTerminal as U, approvalKind as V, canPrompt as W, exemptFromUpdateGate as X, escapeForDisplay as Y, gatedChange as Z, runDoctor as _, REFUSED as _t, revokeSendApproval as a, renderPrune as at, listAccounts as b, ROUTES as bt, downloadReceived as c, serverInstallChange as ct, listReceived as d, terminalUpdateHooks as dt, installExitStatus as et, listScheduled as f, toCommsError as ft, showSentEmail as g, writeResult as gt, showReceived as h, updateToolGate as ht, prepareSend as i, renderInstall as it, MODES as j, ResendContext as k, getMetrics as l, serverPruneChange as lt, listSuppressions as m, updateGateAtTerminal as mt, executeSend as n, paint as nt, sendStatus as o, runCommand as ot, listSentEmails as p, truncateDisplay as pt, colorEnabled as q, finishSendApproval as r, refuseUnclaimedApproval as rt, cancelScheduledChange as s, runUpdateCheckChild as st, beginSendApproval as t, openCore as tt, listDomains as u, shellCommand as ut, addAccountChange as v, RESEND_API_ORIGIN as vt, policyReport as w, modeOf as x, VERSION$1 as xt, inspectKey as y, RESEND_DOWNLOAD_ORIGIN as yt, UPDATE_CHECK_CHILD_COMMAND as z };
24747
+ export { gatedChange as $, CHANGE_POLICIES as A, UPDATE_CHECK_CHILD_COMMAND as B, policyChange as C, REFUSED as Ct, sendPolicyOf as D, VERSION$1 as Dt, removeAccountChange as E, ROUTES as Et, CHANNEL_SERVERS as F, canPrompt as G, approvalKind as H, CommsError as I, cliHandoffs as J, changeToolResult as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, exemptFromUpdateGate as Q, PATH_OPTIONS as R, policyApprovalRefusal as S, RESEND_CALLER as St, policyWanted as T, RESEND_DOWNLOAD_ORIGIN as Tt, approvalsOf as U, agentMarker as V, approveChangeAtTerminal as W, commandPathOf as X, colorEnabled as Y, escapeForDisplay as Z, runDoctor as _, truncateDisplay as _t, revokeSendApproval as a, paint as at, listAccounts as b, writeResult as bt, downloadReceived as c, renderInstall as ct, listReceived as d, runCommand as dt, gatedChangeAtTerminal as et, listScheduled as f, runUpdateCheckChild as ft, showSentEmail as g, toCommsError as gt, showReceived as h, terminalUpdateHooks as ht, prepareSend as i, openCore as it, MODES as j, ResendContext as k, getMetrics as l, renderPrune as lt, listSuppressions as m, serverPruneChange as mt, executeSend as n, handoffSentenceToFill as nt, sendStatus as o, pathOverridesFromCliOptions as ot, listSentEmails as p, serverInstallChange as pt, checkForUpdates as q, finishSendApproval as r, installExitStatus as rt, cancelScheduledChange as s, refuseUnclaimedApproval as st, beginSendApproval as t, handoffSentence as tt, listDomains as u, resolvePaths as ut, addAccountChange as v, updateGateAtTerminal as vt, policyReport as w, RESEND_API_ORIGIN as wt, modeOf as x, PACKAGE_NAME as xt, inspectKey as y, updateToolGate as yt, SEND_LOOKUP as z };