@agentcomms/resend 0.12.1 → 0.12.3

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 "node:readline/promises";
8
+ import { styleText } from "node:util";
7
9
  import { homedir, platform, userInfo } from "node:os";
8
10
  import { constants as constants$1, realpathSync } from "node:fs";
9
11
  import { execFile, spawn } from "node:child_process";
10
- import "node:readline/promises";
11
- import { styleText } from "node:util";
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.12.1";
15
+ const VERSION$1 = "0.12.3";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -187,7 +187,7 @@ function refusedBecause(method, pathname) {
187
187
  return null;
188
188
  }
189
189
  //#endregion
190
- //#region ../core/dist/update-check-Ba6REc8M.mjs
190
+ //#region ../core/dist/update-check-Dq-D4bAD.mjs
191
191
  /**
192
192
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
193
193
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -564,9 +564,15 @@ async function takeOverStale(lockPath, staleMs) {
564
564
  async function withFileLock(lockPath, fn, options = {}) {
565
565
  const timeoutMs = options.timeoutMs ?? 5e3;
566
566
  const staleMs = options.staleMs ?? 3e4;
567
- const deadline = Date.now() + timeoutMs;
567
+ const started = Date.now();
568
+ let deadline = started + timeoutMs;
569
+ /** `maxWaitMs`'s end: fixed when the wait begins, whatever the lock does. */
570
+ const limit = options.maxWaitMs === void 0 ? Number.POSITIVE_INFINITY : started + options.maxWaitMs;
568
571
  const token = randomBytes(12).toString("hex");
569
572
  let lastCode = "EEXIST";
573
+ /** The token of the holder this caller saw last, and how many times it has seen the lock change hands. */
574
+ let holder;
575
+ let handOvers = 0;
570
576
  await ensurePrivateDir(dirname(lockPath));
571
577
  for (;;) try {
572
578
  const handle = await open(lockPath, "wx", 384);
@@ -581,10 +587,20 @@ async function withFileLock(lockPath, fn, options = {}) {
581
587
  const code = error.code;
582
588
  if (!CONTENDED.has(code ?? "")) throw error;
583
589
  lastCode = code ?? lastCode;
584
- if (code === "EEXIST" && await isStale(lockPath, await readLock(lockPath), staleMs)) {
585
- await takeOverStale(lockPath, staleMs);
586
- continue;
590
+ if (code === "EEXIST") {
591
+ const body = await readLock(lockPath);
592
+ if (await isStale(lockPath, body, staleMs)) {
593
+ await takeOverStale(lockPath, staleMs);
594
+ continue;
595
+ }
596
+ const seen = typeof body?.token === "string" ? body.token : void 0;
597
+ if (seen !== void 0 && seen !== holder) {
598
+ if (holder !== void 0) handOvers += 1;
599
+ holder = seen;
600
+ if (options.timeoutPerHolder) deadline = Date.now() + timeoutMs;
601
+ }
587
602
  }
603
+ if (Date.now() > limit) throw new CommsError("LOCK_TIMEOUT", `gave up waiting for ${lockPath}: the wait hit its overall limit of ${(limit - started) / 1e3} s`, { hint: (handOvers > 0 ? `It changed hands ${handOvers} time(s) while this waited, never to this process: busy rather than stuck. Retry in a moment.` : "Retry in a moment. If it persists and no other process is running, delete the lock file.") + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
588
604
  if (Date.now() > deadline) throw new CommsError("LOCK_TIMEOUT", `another agent-communications process is holding ${lockPath}`, { hint: `Retry in a moment. If it persists and no other process is running, delete the lock file.` + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
589
605
  await setTimeout$1(25 + Math.floor(Math.random() * 50));
590
606
  }
@@ -1195,6 +1211,253 @@ function connectMailboxCommand() {
1195
1211
  const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
1196
1212
  return mail ? `${mail.binary} inbox add` : void 0;
1197
1213
  }
1214
+ /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
1215
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1216
+ function randomString(length) {
1217
+ const bytes = randomBytes(length);
1218
+ let out = "";
1219
+ for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
1220
+ return out;
1221
+ }
1222
+ /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
1223
+ const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
1224
+ const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
1225
+ function newApprovalId() {
1226
+ return `ap_${randomString(26)}`;
1227
+ }
1228
+ function newPlanToken() {
1229
+ return `pl_${randomString(26)}`;
1230
+ }
1231
+ const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
1232
+ /** A short challenge a human types back to approve: letters only, no look-alikes. */
1233
+ function newChallenge(length = 4) {
1234
+ let out = "";
1235
+ for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
1236
+ return out;
1237
+ }
1238
+ /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
1239
+ function hashChallenge(challenge) {
1240
+ return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
1241
+ }
1242
+ /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
1243
+ function challengeMatches(answer, storedHash) {
1244
+ const a = Buffer.from(hashChallenge(answer), "hex");
1245
+ const b = Buffer.from(storedHash, "hex");
1246
+ return a.length === b.length && timingSafeEqual(a, b);
1247
+ }
1248
+ function okEnvelope(data) {
1249
+ return {
1250
+ ok: true,
1251
+ schemaVersion: 1,
1252
+ data
1253
+ };
1254
+ }
1255
+ function errorEnvelope(error) {
1256
+ const body = {
1257
+ code: error.code,
1258
+ message: error.message
1259
+ };
1260
+ if (error.hint !== void 0) body.hint = error.hint;
1261
+ if (error.details !== void 0) body.details = error.details;
1262
+ return {
1263
+ ok: false,
1264
+ schemaVersion: 1,
1265
+ error: body
1266
+ };
1267
+ }
1268
+ const defaultStreams = {
1269
+ stdout: process.stdout,
1270
+ stderr: process.stderr,
1271
+ stdin: process.stdin
1272
+ };
1273
+ function colorEnabled(env, stream, flag) {
1274
+ if (flag === false) return false;
1275
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1276
+ if (env.TERM === "dumb") return false;
1277
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1278
+ return Boolean(stream.isTTY);
1279
+ }
1280
+ /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1281
+ function canPrompt(env, streams, options) {
1282
+ if (options.json || options.noInput) return false;
1283
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1284
+ return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1285
+ }
1286
+ /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1287
+ const AGENT_MARKERS = [
1288
+ "CLAUDECODE",
1289
+ "CLAUDE_CODE_ENTRYPOINT",
1290
+ "CODEX_SANDBOX",
1291
+ "CODEX_HOME",
1292
+ "CURSOR_AGENT",
1293
+ "GEMINI_CLI",
1294
+ "AGENT_COMMS_AGENT"
1295
+ ];
1296
+ function agentMarker(env) {
1297
+ for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1298
+ return null;
1299
+ }
1300
+ function paint(color, format, text) {
1301
+ return color ? styleText(format, text, { validateStream: false }) : text;
1302
+ }
1303
+ /**
1304
+ * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
1305
+ * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
1306
+ * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
1307
+ *
1308
+ * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
1309
+ * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
1310
+ * as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
1311
+ * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
1312
+ * has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
1313
+ * the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
1314
+ * hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
1315
+ * reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
1316
+ * line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
1317
+ * applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
1318
+ * 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
1319
+ * `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
1320
+ * old way, and cmd.exe then reads that new line. So on Windows:
1321
+ *
1322
+ * - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
1323
+ * does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
1324
+ * splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
1325
+ * that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
1326
+ * is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
1327
+ * - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
1328
+ * `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
1329
+ * backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
1330
+ * quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
1331
+ * expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
1332
+ * expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
1333
+ * escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
1334
+ * tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
1335
+ * that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
1336
+ * backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
1337
+ * backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
1338
+ * which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
1339
+ * - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
1340
+ * as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
1341
+ * still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
1342
+ * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
1343
+ * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
1344
+ *
1345
+ * Everywhere else there is always a line: single quotes make any word safe.
1346
+ */
1347
+ function shellCommand(words, platform = process.platform) {
1348
+ if (platform !== "win32") return {
1349
+ words,
1350
+ line: words.map(posixShellWord).join(" "),
1351
+ platform
1352
+ };
1353
+ const printed = words.map(windowsShellWord);
1354
+ return {
1355
+ words,
1356
+ line: printed.includes(null) ? null : printed.join(" "),
1357
+ platform
1358
+ };
1359
+ }
1360
+ /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
1361
+ function withWords(command, ...more) {
1362
+ return shellCommand([...command.words, ...more], command.platform);
1363
+ }
1364
+ function posixShellWord(word) {
1365
+ return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1366
+ }
1367
+ /**
1368
+ * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
1369
+ * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1370
+ */
1371
+ function windowsShellWord(word) {
1372
+ if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
1373
+ if (word === "" || word.endsWith("\\")) return null;
1374
+ if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
1375
+ if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
1376
+ return `"${word}"`;
1377
+ }
1378
+ /**
1379
+ * The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
1380
+ * parse, and nothing to run.
1381
+ *
1382
+ * Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
1383
+ * opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
1384
+ * finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
1385
+ * `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
1386
+ * JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
1387
+ * as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
1388
+ * so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
1389
+ * doubled, as JSON has it.
1390
+ */
1391
+ function commandAsJson(words) {
1392
+ const word = (text) => {
1393
+ let json = "";
1394
+ for (let index = 0; index < text.length; index++) {
1395
+ const code = text.charCodeAt(index);
1396
+ const char = text[index];
1397
+ if (char === "\\") json += "\\\\";
1398
+ else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
1399
+ else json += `\\u${code.toString(16).padStart(4, "0")}`;
1400
+ }
1401
+ return `"${json}"`;
1402
+ };
1403
+ return `[${words.map(word).join(",")}]`;
1404
+ }
1405
+ /** What is said beside a command shown as JSON. */
1406
+ 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";
1407
+ /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1408
+ function inlineCommand(command) {
1409
+ return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
1410
+ }
1411
+ /** 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. */
1412
+ function commandText(command) {
1413
+ return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
1414
+ }
1415
+ /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1416
+ function writeResult(data, options, human, streams = defaultStreams) {
1417
+ if (options.json) {
1418
+ streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1419
+ return;
1420
+ }
1421
+ const text = human(data);
1422
+ if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1423
+ }
1424
+ /** Writes an error and returns the exit code to use. */
1425
+ function writeError(error, options, streams = defaultStreams) {
1426
+ const commsError = toCommsError(error);
1427
+ if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1428
+ else {
1429
+ streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1430
+ if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1431
+ }
1432
+ return commsError.exitCode;
1433
+ }
1434
+ /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1435
+ async function runCommand(options, body, streams = defaultStreams) {
1436
+ try {
1437
+ await body();
1438
+ return EXIT_CODES.OK;
1439
+ } catch (error) {
1440
+ return writeError(error, options, streams);
1441
+ }
1442
+ }
1443
+ /**
1444
+ * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1445
+ * whose code is not its own to make up.
1446
+ *
1447
+ * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1448
+ * but it refuses in exactly the same order and words, because it is the same gate.
1449
+ */
1450
+ function refuseUnlessPerson(env, streams, gate) {
1451
+ const marker = agentMarker(env);
1452
+ if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1453
+ hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
1454
+ details: { marker }
1455
+ });
1456
+ const prompting = {};
1457
+ if (gate.json !== void 0) prompting.json = gate.json;
1458
+ if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1459
+ if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
1460
+ }
1198
1461
  /**
1199
1462
  * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1200
1463
  * and the preview renderer (which makes them visible), so the two can never disagree.
@@ -1855,13 +2118,19 @@ function committedSecretsStore(config) {
1855
2118
  * A different backend asked for is refused, not taken: one backend holds everything here, and changing it has to move
1856
2119
  * what is already stored, which is what `agentcomms secrets migrate` does and nothing else does.
1857
2120
  */
1858
- function secretsStoreFor(config, requested) {
2121
+ function secretsStoreFor(config, requested, platform = process.platform) {
1859
2122
  const committed = committedSecretsStore(config);
1860
2123
  if (committed === null) return {
1861
2124
  store: requested ?? "keychain",
1862
2125
  choosing: true
1863
2126
  };
1864
- 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 \`agentcomms secrets migrate --to ${requested}\`, then run this again.` });
2127
+ 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([
2128
+ "agentcomms",
2129
+ "secrets",
2130
+ "migrate",
2131
+ "--to",
2132
+ requested
2133
+ ], platform))}, then run this again.` });
1865
2134
  return {
1866
2135
  store: committed,
1867
2136
  choosing: false
@@ -2404,40 +2673,6 @@ function canonicalLoosening(loosening) {
2404
2673
  function sameLoosening(a, b) {
2405
2674
  return canonicalLoosening(a) === canonicalLoosening(b);
2406
2675
  }
2407
- /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
2408
- const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2409
- function randomString(length) {
2410
- const bytes = randomBytes(length);
2411
- let out = "";
2412
- for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
2413
- return out;
2414
- }
2415
- /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
2416
- const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
2417
- const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
2418
- function newApprovalId() {
2419
- return `ap_${randomString(26)}`;
2420
- }
2421
- function newPlanToken() {
2422
- return `pl_${randomString(26)}`;
2423
- }
2424
- const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
2425
- /** A short challenge a human types back to approve: letters only, no look-alikes. */
2426
- function newChallenge(length = 4) {
2427
- let out = "";
2428
- for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
2429
- return out;
2430
- }
2431
- /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
2432
- function hashChallenge(challenge) {
2433
- return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
2434
- }
2435
- /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
2436
- function challengeMatches(answer, storedHash) {
2437
- const a = Buffer.from(hashChallenge(answer), "hex");
2438
- const b = Buffer.from(storedHash, "hex");
2439
- return a.length === b.length && timingSafeEqual(a, b);
2440
- }
2441
2676
  /**
2442
2677
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
2443
2678
  * names it showed them under.
@@ -2588,6 +2823,32 @@ function refusalFor(record) {
2588
2823
  const kind = approvalKind(record);
2589
2824
  return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse$1;
2590
2825
  }
2826
+ /**
2827
+ * A claim whose caller was cancelled before it changed anything (`ClaimOptions.signal`).
2828
+ *
2829
+ * `USAGE` and `cancelled:`, the words every cancellation in this repository is reported in, with `details.reason`
2830
+ * saying so for a caller that branches on it — and in the record's own kind: a question is not an approval, and
2831
+ * nothing is sent by answering one. The record is as it was, and the hint says so.
2832
+ */
2833
+ function cancelledClaim(record) {
2834
+ const kind = approvalKind(record);
2835
+ if (kind === "download") return new CommsError("USAGE", "cancelled: nothing was saved", {
2836
+ hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
2837
+ details: {
2838
+ choiceId: record.approvalId,
2839
+ state: record.state,
2840
+ reason: "cancelled"
2841
+ }
2842
+ });
2843
+ return new CommsError("USAGE", `cancelled: ${kind === "change" ? "nothing was changed" : "nothing was sent"}`, {
2844
+ hint: "The approval was not used: the same call, made again, can still use it until it expires.",
2845
+ details: {
2846
+ approvalId: record.approvalId,
2847
+ state: record.state,
2848
+ reason: "cancelled"
2849
+ }
2850
+ });
2851
+ }
2591
2852
  /** The record as it may be shown to anyone, agents included: never the challenge hash. */
2592
2853
  function publicView(record) {
2593
2854
  const { challengeHash: _hidden, ...rest } = record;
@@ -2705,19 +2966,23 @@ var ApprovalStore = class {
2705
2966
  * Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
2706
2967
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
2707
2968
  */
2708
- #requireKind(record, kind) {
2969
+ #requireKind(record, kind, platform) {
2709
2970
  const actual = approvalKind(record);
2710
2971
  if (actual === kind) return;
2711
2972
  const id = record.approvalId;
2712
2973
  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);
2713
2974
  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.");
2714
- throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with \`agentcomms approve ${id}\` — 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.`);
2975
+ throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
2976
+ "agentcomms",
2977
+ "approve",
2978
+ id
2979
+ ], 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.`);
2715
2980
  }
2716
2981
  /** Issues a new challenge to show a human; only its hash is kept. */
2717
- async issueChallenge(approvalId, kind = "send") {
2982
+ async issueChallenge(approvalId, kind = "send", platform = process.platform) {
2718
2983
  const challenge = newChallenge();
2719
2984
  await this.#transition(approvalId, (current) => {
2720
- this.#requireKind(current, kind);
2985
+ this.#requireKind(current, kind, platform);
2721
2986
  if (current.state !== "pending") throw this.#stateError(current);
2722
2987
  return {
2723
2988
  ...current,
@@ -2734,10 +2999,10 @@ var ApprovalStore = class {
2734
2999
  * A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
2735
3000
  * `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
2736
3001
  */
2737
- async approve(approvalId, via, live, answer, kind = "send") {
3002
+ async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
2738
3003
  let failure = null;
2739
3004
  const result = await this.#transition(approvalId, (current) => {
2740
- this.#requireKind(current, kind);
3005
+ this.#requireKind(current, kind, platform);
2741
3006
  if (current.state !== "pending") throw this.#stateError(current);
2742
3007
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
2743
3008
  if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
@@ -2794,8 +3059,9 @@ var ApprovalStore = class {
2794
3059
  async claimForSend(approvalId, live, options = {}) {
2795
3060
  let failure = null;
2796
3061
  const result = await this.#transition(approvalId, (current) => {
2797
- this.#requireKind(current, "send");
3062
+ this.#requireKind(current, "send", options.platform ?? process.platform);
2798
3063
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3064
+ if (options.signal?.aborted) throw cancelledClaim(current);
2799
3065
  const voidWith = (code, reason) => {
2800
3066
  failure = {
2801
3067
  code,
@@ -2904,8 +3170,9 @@ var ApprovalStore = class {
2904
3170
  const digest = changeDigest(live.change);
2905
3171
  let failure = null;
2906
3172
  const result = await this.#transition(approvalId, (current) => {
2907
- this.#requireKind(current, "change");
3173
+ this.#requireKind(current, "change", options.platform ?? process.platform);
2908
3174
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3175
+ if (options.signal?.aborted) throw cancelledClaim(current);
2909
3176
  const voidWith = (reason) => {
2910
3177
  failure = {
2911
3178
  code: "APPROVAL_VOID",
@@ -2919,7 +3186,11 @@ var ApprovalStore = class {
2919
3186
  };
2920
3187
  if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
2921
3188
  if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
2922
- 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 \`agentcomms approve ${approvalId}\` in their own terminal, then try again with the same approval.`);
3189
+ 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([
3190
+ "agentcomms",
3191
+ "approve",
3192
+ approvalId
3193
+ ], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
2923
3194
  if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
2924
3195
  }
2925
3196
  return {
@@ -2996,13 +3267,13 @@ var ApprovalStore = class {
2996
3267
  *
2997
3268
  * Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
2998
3269
  */
2999
- async answerDownload(approvalId, via, answer) {
3270
+ async answerDownload(approvalId, via, answer, platform = process.platform) {
3000
3271
  const recorded = answer.choice === "other" ? {
3001
3272
  choice: "other",
3002
3273
  folder: answer.folder
3003
3274
  } : { choice: answer.choice };
3004
3275
  return await this.#transition(approvalId, (current) => {
3005
- this.#requireKind(current, "download");
3276
+ this.#requireKind(current, "download", platform);
3006
3277
  if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
3007
3278
  if (current.state !== "pending") throw this.#stateError(current);
3008
3279
  if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
@@ -3033,13 +3304,17 @@ var ApprovalStore = class {
3033
3304
  * gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
3034
3305
  * form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
3035
3306
  * expired is refused as an approval in that state is.
3307
+ *
3308
+ * `options.signal` is the download's cancellation, asked under the lock as a send's is (`ClaimOptions.signal`): a
3309
+ * download cancelled while this waited saves nothing and leaves the question open, the answer still unused.
3036
3310
  */
3037
3311
  async claimForDownload(approvalId, live, options = {}) {
3038
3312
  const digest = downloadDigest(live);
3039
3313
  let failure = null;
3040
3314
  const result = await this.#transition(approvalId, (current) => {
3041
- this.#requireKind(current, "download");
3315
+ this.#requireKind(current, "download", options.platform ?? process.platform);
3042
3316
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3317
+ if (options.signal?.aborted) throw cancelledClaim(current);
3043
3318
  const voidWith = (reason) => {
3044
3319
  failure = {
3045
3320
  code: "APPROVAL_VOID",
@@ -4374,13 +4649,13 @@ function snippetFor(client, name, entry) {
4374
4649
  * Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
4375
4650
  * replaced.
4376
4651
  */
4377
- function claimName(product, options, name, existing) {
4652
+ function claimName(product, options, name, existing, platform) {
4378
4653
  const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
4379
4654
  const foreign = taken.find((server) => !isProductServer(server, product));
4380
4655
  if (foreign) {
4381
4656
  const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
4382
4657
  const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
4383
- 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: \`${installCommand$1(product, options, other)}\`. --force does not replace a server this did not install.` });
4658
+ 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.` });
4384
4659
  }
4385
4660
  if (taken.length > 0 && !options.force) {
4386
4661
  for (const pin of PIN_OPTIONS) {
@@ -4388,14 +4663,14 @@ function claimName(product, options, name, existing) {
4388
4663
  const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
4389
4664
  if (!wanted || !served || served === wanted) continue;
4390
4665
  const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
4391
- 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: \`${installCommand$1(product, options, second === name ? `${name}-2` : second)}\`.` });
4666
+ 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))}.` });
4392
4667
  }
4393
4668
  const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
4394
4669
  const again = {
4395
4670
  ...keepNarrowing(product, options, taken).options,
4396
4671
  launcher: options.launcher ?? (npx ? "npx" : void 0)
4397
4672
  };
4398
- 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: \`${installCommand$1(product, again, name, ["--force"])}\`.` });
4673
+ 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))}.` });
4399
4674
  }
4400
4675
  return taken;
4401
4676
  }
@@ -4438,8 +4713,11 @@ function keepNarrowing(product, options, previous) {
4438
4713
  * workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
4439
4714
  * rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
4440
4715
  * the pin and the narrowing, so they are repeated as they are.
4716
+ *
4717
+ * A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
4718
+ * command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
4441
4719
  */
4442
- function installCommand$1(product, options, name, extra = []) {
4720
+ function installCommand$1(product, options, name, extra, platform) {
4443
4721
  const words = [
4444
4722
  product.binary,
4445
4723
  "mcp",
@@ -4451,7 +4729,7 @@ function installCommand$1(product, options, name, extra = []) {
4451
4729
  words.push(...product.serverArgs(options));
4452
4730
  if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
4453
4731
  words.push(...extra);
4454
- return words.map((word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
4732
+ return shellCommand(words, platform);
4455
4733
  }
4456
4734
  /**
4457
4735
  * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
@@ -4494,8 +4772,13 @@ function capture$1(env, command, args) {
4494
4772
  * or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
4495
4773
  * Nothing codex prints is repeated, because its answer carries the entry's env.
4496
4774
  */
4497
- async function codexRegistration(env, binary, name, path) {
4498
- const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with \`codex mcp get ${name}\`. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
4775
+ async function codexRegistration(env, binary, name, path, platform) {
4776
+ const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
4777
+ "codex",
4778
+ "mcp",
4779
+ "get",
4780
+ name
4781
+ ], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
4499
4782
  let answer;
4500
4783
  try {
4501
4784
  answer = await capture$1(env, binary, [
@@ -4598,10 +4881,10 @@ async function preflightInstall(context, product, options) {
4598
4881
  const blind = scan.unreadable.find((file) => file.path === own);
4599
4882
  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." });
4600
4883
  }
4601
- let previous = writes ? claimName(product, options, name, existing) : [];
4884
+ let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
4602
4885
  if (writes && binary && options.client === "codex") {
4603
- const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
4604
- if (reported) previous = claimName(product, options, name, [reported]);
4886
+ const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
4887
+ if (reported) previous = claimName(product, options, name, [reported], context.platform);
4605
4888
  }
4606
4889
  const { options: effective, kept } = keepNarrowing(product, options, previous);
4607
4890
  const node = await resolveNode(context.env);
@@ -4726,9 +5009,21 @@ async function mcpInstall(context, product, options, planned) {
4726
5009
  };
4727
5010
  const existing = scan.servers;
4728
5011
  const { cliName, binary, own, configPath, writes } = target;
4729
- const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
5012
+ const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
4730
5013
  if (kept.length > 0) {
4731
- const removal = options.client === "claude-code" ? `\`claude mcp remove ${name} --scope user\`` : options.client === "codex" ? `\`codex mcp remove ${name}\`` : `delete "${name}" from ${configPath}`;
5014
+ const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
5015
+ "claude",
5016
+ "mcp",
5017
+ "remove",
5018
+ name,
5019
+ "--scope",
5020
+ "user"
5021
+ ], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
5022
+ "codex",
5023
+ "mcp",
5024
+ "remove",
5025
+ name
5026
+ ], context.platform)) : `delete "${name}" from ${configPath}`;
4732
5027
  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.`);
4733
5028
  }
4734
5029
  if (!writes) {
@@ -4823,7 +5118,7 @@ async function mcpInstall(context, product, options, planned) {
4823
5118
  restored = false;
4824
5119
  }
4825
5120
  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}"`, {
4826
- hint: restored ? "Check the client is not running, then try again." : `Re-register it with \`${installCommand$1(product, effective, name)}\`. The old entry is in ${backupPath}.`,
5121
+ 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}.`,
4827
5122
  cause: error
4828
5123
  });
4829
5124
  }
@@ -4832,7 +5127,17 @@ async function mcpInstall(context, product, options, planned) {
4832
5127
  } catch (error) {
4833
5128
  const message = error instanceof CommsError ? error.message : String(error);
4834
5129
  if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
4835
- hint: `Look at it with \`${cliName} mcp get ${name}\`. If it is an older ${product.binary}, remove it with \`${cliName} mcp remove ${name}\` and run this again; if not, choose another --name.`,
5130
+ hint: `Look at it with ${inlineCommand(shellCommand([
5131
+ cliName,
5132
+ "mcp",
5133
+ "get",
5134
+ name
5135
+ ], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
5136
+ cliName,
5137
+ "mcp",
5138
+ "remove",
5139
+ name
5140
+ ], context.platform))} and run this again; if not, choose another --name.`,
4836
5141
  cause: error
4837
5142
  });
4838
5143
  throw error;
@@ -5168,19 +5473,32 @@ function rivalPatterns(packages) {
5168
5473
  function escapeRegExp$2(text) {
5169
5474
  return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
5170
5475
  }
5171
- function gmailRemoval({ client, name: server, path, scope }) {
5476
+ /**
5477
+ * The command that removes a registered server, in its client's own terms — the name as the client's file has it,
5478
+ * which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
5479
+ * (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
5480
+ */
5481
+ function removalCommand(client, name, platform) {
5482
+ return commandText(shellCommand([
5483
+ client,
5484
+ "mcp",
5485
+ "remove",
5486
+ name
5487
+ ], platform));
5488
+ }
5489
+ function gmailRemoval({ client, name: server, path, scope }, platform) {
5172
5490
  if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
5173
5491
  switch (client) {
5174
- case "claude-code": return `claude mcp remove ${server}`;
5175
- case "codex": return `codex mcp remove ${server}`;
5492
+ case "claude-code": return removalCommand("claude", server, platform);
5493
+ case "codex": return removalCommand("codex", server, platform);
5176
5494
  default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
5177
5495
  }
5178
5496
  }
5179
5497
  /**
5180
5498
  * Registered servers that are one of `packages` — another server for the same service whose send tools no approval
5181
- * step gates — each with why it matters and how to remove it.
5499
+ * step gates — each with why it matters and how to remove it. `platform` is the shell the removal is quoted for.
5182
5500
  */
5183
- function findRivalPackageServers(servers, packages) {
5501
+ function findRivalPackageServers(servers, packages, platform = process.platform) {
5184
5502
  const patterns = rivalPatterns(packages);
5185
5503
  const findings = [];
5186
5504
  for (const server of servers) {
@@ -5191,14 +5509,14 @@ function findRivalPackageServers(servers, packages) {
5191
5509
  ...server,
5192
5510
  packageName: known.name,
5193
5511
  reason: `${known.name} exposes send tools that no approval step gates`,
5194
- removal: gmailRemoval(server)
5512
+ removal: gmailRemoval(server, platform)
5195
5513
  });
5196
5514
  }
5197
5515
  return findings;
5198
5516
  }
5199
5517
  /** What registering a server says about the rival packages its manifest names: one line each. */
5200
- function rivalPackageWarnings(servers, packages) {
5201
- return findRivalPackageServers(servers, packages).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
5518
+ function rivalPackageWarnings(servers, packages, platform = process.platform) {
5519
+ return findRivalPackageServers(servers, packages, platform).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
5202
5520
  }
5203
5521
  /**
5204
5522
  * Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
@@ -5284,7 +5602,7 @@ function serverFactsOf({ packageName, manifest }) {
5284
5602
  if (rivals === void 0) return facts;
5285
5603
  return {
5286
5604
  ...facts,
5287
- warnAbout: (servers) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
5605
+ warnAbout: (servers, platform) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages, platform) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
5288
5606
  };
5289
5607
  }
5290
5608
  const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
@@ -5317,107 +5635,6 @@ function requireChannelManifest(channel) {
5317
5635
  if (manifest === void 0) throw notAChannel(channel);
5318
5636
  return manifest;
5319
5637
  }
5320
- function okEnvelope(data) {
5321
- return {
5322
- ok: true,
5323
- schemaVersion: 1,
5324
- data
5325
- };
5326
- }
5327
- function errorEnvelope(error) {
5328
- const body = {
5329
- code: error.code,
5330
- message: error.message
5331
- };
5332
- if (error.hint !== void 0) body.hint = error.hint;
5333
- if (error.details !== void 0) body.details = error.details;
5334
- return {
5335
- ok: false,
5336
- schemaVersion: 1,
5337
- error: body
5338
- };
5339
- }
5340
- const defaultStreams = {
5341
- stdout: process.stdout,
5342
- stderr: process.stderr,
5343
- stdin: process.stdin
5344
- };
5345
- function colorEnabled(env, stream, flag) {
5346
- if (flag === false) return false;
5347
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
5348
- if (env.TERM === "dumb") return false;
5349
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
5350
- return Boolean(stream.isTTY);
5351
- }
5352
- /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
5353
- function canPrompt(env, streams, options) {
5354
- if (options.json || options.noInput) return false;
5355
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
5356
- return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
5357
- }
5358
- /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
5359
- const AGENT_MARKERS = [
5360
- "CLAUDECODE",
5361
- "CLAUDE_CODE_ENTRYPOINT",
5362
- "CODEX_SANDBOX",
5363
- "CODEX_HOME",
5364
- "CURSOR_AGENT",
5365
- "GEMINI_CLI",
5366
- "AGENT_COMMS_AGENT"
5367
- ];
5368
- function agentMarker(env) {
5369
- for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
5370
- return null;
5371
- }
5372
- function paint(color, format, text) {
5373
- return color ? styleText(format, text, { validateStream: false }) : text;
5374
- }
5375
- /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
5376
- function writeResult(data, options, human, streams = defaultStreams) {
5377
- if (options.json) {
5378
- streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
5379
- return;
5380
- }
5381
- const text = human(data);
5382
- if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
5383
- }
5384
- /** Writes an error and returns the exit code to use. */
5385
- function writeError(error, options, streams = defaultStreams) {
5386
- const commsError = toCommsError(error);
5387
- if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
5388
- else {
5389
- streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
5390
- if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
5391
- }
5392
- return commsError.exitCode;
5393
- }
5394
- /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
5395
- async function runCommand(options, body, streams = defaultStreams) {
5396
- try {
5397
- await body();
5398
- return EXIT_CODES.OK;
5399
- } catch (error) {
5400
- return writeError(error, options, streams);
5401
- }
5402
- }
5403
- /**
5404
- * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
5405
- * whose code is not its own to make up.
5406
- *
5407
- * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
5408
- * but it refuses in exactly the same order and words, because it is the same gate.
5409
- */
5410
- function refuseUnlessPerson(env, streams, gate) {
5411
- const marker = agentMarker(env);
5412
- if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
5413
- hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
5414
- details: { marker }
5415
- });
5416
- const prompting = {};
5417
- if (gate.json !== void 0) prompting.json = gate.json;
5418
- if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
5419
- if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
5420
- }
5421
5638
  /**
5422
5639
  * One renderer for every surface a send preview is shown on — the chat, an elicitation form, a terminal. Text the
5423
5640
  * draft's author controls (body, subject, display names, file names, link text) must not be able to change how the
@@ -5583,9 +5800,9 @@ function renderUpdate(result, color) {
5583
5800
  if (result.next) lines.push("", result.next);
5584
5801
  return lines.join("\n");
5585
5802
  }
5586
- /** The terminal command that approves a change, for the channel that asked. */
5587
- function approveCommandOf(options) {
5588
- return options.approveCommand ?? "agentcomms approve";
5803
+ /** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
5804
+ function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
5805
+ return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
5589
5806
  }
5590
5807
  /**
5591
5808
  * The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
@@ -5696,7 +5913,7 @@ async function prepareChange(core, request, options) {
5696
5913
  change: binding
5697
5914
  }),
5698
5915
  expiresAt: record.expiresAt,
5699
- next: nextStep(record.approvalId, policy, approveCommandOf(options))
5916
+ next: nextStep(record.approvalId, policy, options)
5700
5917
  };
5701
5918
  } catch (error) {
5702
5919
  await auditRefusal(core, "change.prepare", error, {
@@ -5727,7 +5944,10 @@ async function claimChange(core, approvalId, expect, options) {
5727
5944
  const record = await core.approvals.claimForChange(approvalId, {
5728
5945
  change: binding,
5729
5946
  policy
5730
- }, { pendingHint: `Ask the user to run \`${approveCommandOf(options)} ${approvalId}\` in their own terminal, then try again with the same approval.` });
5947
+ }, {
5948
+ pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
5949
+ platform: options.platform
5950
+ });
5731
5951
  const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
5732
5952
  await auditChange(core, {
5733
5953
  operation: "change.claim",
@@ -5775,7 +5995,7 @@ async function beginChangeApproval(core, approvalId, options) {
5775
5995
  let record;
5776
5996
  try {
5777
5997
  record = await changeRecord(core, approvalId);
5778
- const challenge = await core.approvals.issueChallenge(approvalId, "change");
5998
+ const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
5779
5999
  return {
5780
6000
  approvalId,
5781
6001
  preview: renderChangePreview(record),
@@ -5800,7 +6020,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
5800
6020
  const approved = await core.approvals.approve(approvalId, "terminal", {
5801
6021
  draftMessageId: digest,
5802
6022
  digest
5803
- }, answer, "change");
6023
+ }, answer, "change", options.platform);
5804
6024
  await auditChange(core, {
5805
6025
  operation: "change.approve",
5806
6026
  outcome: "ok",
@@ -5856,8 +6076,8 @@ async function revokeChange(core, approvalId, reason, options) {
5856
6076
  function changePolicyOf$1(record) {
5857
6077
  return record.requiredPolicy === "chat" ? "chat" : "confirm";
5858
6078
  }
5859
- function nextStep(approvalId, policy, approveCommand) {
5860
- 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 \`${approveCommand} ${approvalId}\` in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
6079
+ function nextStep(approvalId, policy, options) {
6080
+ 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.`;
5861
6081
  }
5862
6082
  /**
5863
6083
  * One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
@@ -5978,21 +6198,25 @@ function renderChangePreview(record) {
5978
6198
  * it on the second call, with its id.
5979
6199
  *
5980
6200
  * A change that loosens no setting and does nothing irreversible is applied directly: asking a person to agree to
5981
- * something that needs no agreement teaches them to agree without reading. It refuses an approval id, as a report
5982
- * refuses one (`refuseApprovalWithoutChange`), rather than applying and dropping it: the update check's stop lets a
5983
- * call claiming an approval through (design 2026-09-28 §2), and one that is then never claimed would carry any
5984
- * approval the store holds — one an agent had just had prepared for "not now", say — past the stop, to a tightening
5985
- * or a cancellation made where the call itself was stopped.
6201
+ * something that needs no agreement teaches them to agree without reading. It refuses an approval id — in the change's
6202
+ * own words when it has them (`refuseApproval`) — as a report refuses one (`refuseApprovalWithoutChange`), rather than
6203
+ * applying and dropping it: the update check's stop lets a call claiming an approval through (design 2026-09-28 §2),
6204
+ * and one that is then never claimed would carry any approval the store holds — one an agent had just had prepared for
6205
+ * "not now", say — past the stop, to a tightening or a cancellation made where the call itself was stopped.
5986
6206
  */
5987
6207
  async function gatedChange(core, change, options) {
5988
6208
  const request = await change.plan(await core.config.load());
5989
6209
  const loosens = classifyChange(request.before, request.after).loosened.length > 0;
5990
6210
  const acts = (request.effects ?? []).length > 0;
5991
6211
  if (!loosens && !acts) {
5992
- if (options.approvalId) throw new CommsError("USAGE", "nothing was changed: as things stand this change needs no approval, so it takes none", {
5993
- hint: "Make the same call again without the approval: it applies at once and asks nobody. An approval is spent only on the change it was prepared for.",
5994
- details: { approvalId: options.approvalId }
5995
- });
6212
+ if (options.approvalId) {
6213
+ const refusal = await change.refuseApproval?.(options.approvalId);
6214
+ if (refusal !== void 0) throw refusal;
6215
+ throw new CommsError("USAGE", "nothing was changed: as things stand this change needs no approval, so it takes none", {
6216
+ hint: "Make the same call again without the approval: it applies at once and asks nobody. An approval is spent only on the change it was prepared for.",
6217
+ details: { approvalId: options.approvalId }
6218
+ });
6219
+ }
5996
6220
  return {
5997
6221
  status: "applied",
5998
6222
  result: await change.apply(void 0, request)
@@ -6002,12 +6226,14 @@ async function gatedChange(core, change, options) {
6002
6226
  status: "approval-required",
6003
6227
  prepared: await prepareChange(core, request, {
6004
6228
  surface: options.surface,
6005
- approveCommand: options.approveCommand
6229
+ approveCommand: options.approveCommand,
6230
+ platform: options.platform
6006
6231
  })
6007
6232
  };
6008
6233
  const consent = await claimChange(core, options.approvalId, request, {
6009
6234
  surface: options.surface,
6010
- approveCommand: options.approveCommand
6235
+ approveCommand: options.approveCommand,
6236
+ platform: options.platform
6011
6237
  });
6012
6238
  return {
6013
6239
  status: "applied",
@@ -6069,14 +6295,17 @@ async function gatedChangeAtTerminal(core, change, options) {
6069
6295
  const first = await gatedChange(core, change, {
6070
6296
  surface: "cli",
6071
6297
  approvalId: options.approvalId,
6072
- approveCommand
6298
+ approveCommand,
6299
+ platform: options.output.platform
6073
6300
  });
6074
6301
  if (first.status === "applied") return first.result;
6075
6302
  const { prepared } = first;
6076
6303
  if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
6077
6304
  if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
6305
+ const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
6306
+ const { command } = options;
6078
6307
  throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
6079
- hint: approvalHint(prepared, `${options.command} ${options.approvalFlag ?? "--approval"} ${prepared.approvalId}`, approveCommand),
6308
+ hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
6080
6309
  details: {
6081
6310
  approvalId: prepared.approvalId,
6082
6311
  policy: prepared.policy,
@@ -6097,7 +6326,8 @@ async function gatedChangeAtTerminal(core, change, options) {
6097
6326
  const second = await gatedChange(core, change, {
6098
6327
  surface: "cli",
6099
6328
  approvalId: prepared.approvalId,
6100
- approveCommand
6329
+ approveCommand,
6330
+ platform: options.output.platform
6101
6331
  });
6102
6332
  if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
6103
6333
  return second.result;
@@ -6109,8 +6339,9 @@ async function gatedChangeAtTerminal(core, change, options) {
6109
6339
  * Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
6110
6340
  * its registration step and says why in its report — so the two say it in the same words.
6111
6341
  */
6112
- function approvalHint(prepared, rerun, approveCommand) {
6113
- return prepared.policy === "confirm" ? `Show the person the preview. They run \`${approveCommandOf({ approveCommand })} ${prepared.approvalId}\`; then run \`${rerun}\`.` : `Show the person the preview. Once they say yes, run \`${rerun}\`.`;
6342
+ function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
6343
+ const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
6344
+ 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}.`;
6114
6345
  }
6115
6346
  /**
6116
6347
  * Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
@@ -6130,7 +6361,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
6130
6361
  refuseUnlessPerson(env, streams, {
6131
6362
  refusedToAgent: "only a person can approve a change, not an agent",
6132
6363
  refusedWithoutTerminal: "approving a change needs an interactive terminal",
6133
- command: `agentcomms approve ${approvalId}`,
6364
+ command: commandText(shellCommand([
6365
+ "agentcomms",
6366
+ "approve",
6367
+ approvalId
6368
+ ], output.platform)),
6134
6369
  color: output.color,
6135
6370
  json: output.json
6136
6371
  });
@@ -6792,7 +7027,7 @@ async function npmInstallGlobal(env, spec) {
6792
7027
  }
6793
7028
  }
6794
7029
  /** The package version, read from package.json at build time. */
6795
- const VERSION = "0.12.1";
7030
+ const VERSION = "0.12.3";
6796
7031
  /**
6797
7032
  * Registering, pruning and listing the MCP servers of every channel, from the core.
6798
7033
  *
@@ -7050,7 +7285,7 @@ function serverPruneChange(core, env, request, own) {
7050
7285
  };
7051
7286
  const { packageName, version } = ownProduct(request.channel, own) ?? {
7052
7287
  packageName: channelServer(request.channel).packageName,
7053
- version: "0.12.1"
7288
+ version: "0.12.3"
7054
7289
  };
7055
7290
  const product = {
7056
7291
  packageName,
@@ -7396,8 +7631,13 @@ async function latestReleases(names, latestVersion) {
7396
7631
  if (first?.problem) throw new CommsError("PROVIDER_UNAVAILABLE", `${first.problem}${problems.length > 1 ? ` (and ${problems.length - 1} more)` : ""}`, { hint: "Nothing was checked or changed. Try again once the registry can be reached; `npm view <package> version` shows what npm itself reads." });
7397
7632
  return Object.fromEntries(answers.map((answer) => [answer.name, answer.version ?? ""]));
7398
7633
  }
7399
- /** How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach. */
7400
- function installCommand(item) {
7634
+ /**
7635
+ * How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach.
7636
+ *
7637
+ * The entry's name and pins are read from the client's file, which may hold anything, so on Windows the command may
7638
+ * have no line to paste: the reasons below give it with `inlineCommand`, which shows it as words then.
7639
+ */
7640
+ function installCommand(item, platform) {
7401
7641
  const facts = channelServer(item.channel);
7402
7642
  const words = [
7403
7643
  facts.binary,
@@ -7408,7 +7648,7 @@ function installCommand(item) {
7408
7648
  ];
7409
7649
  if (item.name !== facts.defaultServerName) words.push("--name", item.name);
7410
7650
  words.push(...item.narrowing, "--force");
7411
- return words.join(" ");
7651
+ return shellCommand(words, platform);
7412
7652
  }
7413
7653
  /**
7414
7654
  * Why a registration that is behind cannot be registered again from here, or undefined when it can.
@@ -7419,13 +7659,13 @@ function installCommand(item) {
7419
7659
  * been renamed or removed: registering it again would register a server that is refused when it starts.
7420
7660
  */
7421
7661
  async function whyNotUpdatable(context, config, item, narrowing) {
7422
- 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 \`${installCommand(item)}\``;
7662
+ 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))}`;
7423
7663
  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`;
7424
7664
  const target = await installTarget(context, {
7425
7665
  client: item.client,
7426
7666
  apply: true
7427
7667
  });
7428
- if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run \`${installCommand(item)}\` where it is` : `this environment names no ${item.client} configuration to write to`;
7668
+ 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`;
7429
7669
  const own = pinOption(item.channel);
7430
7670
  const pin = own === void 0 || own === "readOnly" ? void 0 : narrowing[own];
7431
7671
  if (pin !== void 0) {
@@ -7469,7 +7709,8 @@ async function runtimesNeeded(dataDir, latest, managed) {
7469
7709
  async function inspect(core, env, deps) {
7470
7710
  const context = {
7471
7711
  env,
7472
- core
7712
+ core,
7713
+ platform: deps.platform
7473
7714
  };
7474
7715
  const scan = await scanRegisteredServers(env);
7475
7716
  const unreadable = [...scan.unreadable];
@@ -7778,7 +8019,7 @@ async function planRegistrations(context, candidates, request) {
7778
8019
  }
7779
8020
  const keeps = narrowingArgs(item.channel, preflight.effective);
7780
8021
  if (JSON.stringify(keeps) !== JSON.stringify(item.narrowing)) {
7781
- 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: \`${installCommand(item)}\``);
8022
+ 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))}`);
7782
8023
  continue;
7783
8024
  }
7784
8025
  steps.push({
@@ -7803,7 +8044,8 @@ async function planRegistrations(context, candidates, request) {
7803
8044
  function updateChange(core, env, request = {}, deps = {}) {
7804
8045
  const context = {
7805
8046
  env,
7806
- core
8047
+ core,
8048
+ platform: deps.platform
7807
8049
  };
7808
8050
  let planned = null;
7809
8051
  return {
@@ -7837,9 +8079,69 @@ function updateChange(core, env, request = {}, deps = {}) {
7837
8079
  const result = await applyUpdate(context, planned, deps);
7838
8080
  await recordFound(core, env, planned.report, deps, result);
7839
8081
  return result;
8082
+ },
8083
+ refuseApproval: (approvalId) => {
8084
+ if (planned === null) throw new CommsError("UNEXPECTED", "the update was refused before it was planned");
8085
+ return nothingToApply(core, approvalId, planned);
7840
8086
  }
7841
8087
  };
7842
8088
  }
8089
+ /**
8090
+ * The refusal for an approval handed to an update with no step left in it (CUE-303).
8091
+ *
8092
+ * An update needs an approval only for its steps, so one with none takes no approval, and the general refusal said to
8093
+ * call again without it, as "it applies at once". An approval is prepared for an update while something is behind,
8094
+ * and it is usually claimed minutes later; meanwhile another session, or the person at a terminal, may have applied
8095
+ * the update. Then nothing is behind, calling again applies nothing, and "applies at once" read as an update about to
8096
+ * happen. So this says what is so: there is nothing to apply — nothing behind at all, or nothing that can be updated
8097
+ * from here — and the approval was not used, with what had become of it.
8098
+ *
8099
+ * The store is asked as it is. Its `get` answers null for an approval it does not have, and throws for anything else:
8100
+ * an id that is not one, a record it cannot read or parse. Every one of those was caught and said as "not used" with
8101
+ * nothing more, so a corrupt approval file or a permissions problem read as an approval that was simply not there.
8102
+ * Now a record that cannot be read goes up as itself, as it would from any other call that reads an approval, and an
8103
+ * id that is not one is said to be that.
8104
+ */
8105
+ async function nothingToApply(core, approvalId, planned) {
8106
+ if (!APPROVAL_ID_PATTERN.test(approvalId)) return new CommsError("USAGE", `nothing was changed: "${approvalId}" is not an approval id`, {
8107
+ 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.",
8108
+ details: { approvalId }
8109
+ });
8110
+ const record = await core.approvals.get(approvalId);
8111
+ const unused = `so there is nothing to apply; the approval ${approvalId} was not used (${whatBecameOf(record)})`;
8112
+ const details = {
8113
+ approvalId,
8114
+ ...record ? { state: record.state } : {}
8115
+ };
8116
+ if (planned.nothingBehind) return new CommsError("USAGE", `nothing was changed: nothing is behind the latest release, ${unused}`, {
8117
+ 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.",
8118
+ details
8119
+ });
8120
+ return new CommsError("USAGE", `nothing was changed: what is behind cannot be updated from here, ${unused}`, {
8121
+ 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.",
8122
+ details
8123
+ });
8124
+ }
8125
+ /**
8126
+ * What had become of an approval, for `nothingToApply`: none at all, or each state an approval can be in, by name.
8127
+ *
8128
+ * A record, so that a state added to the store is a type error here rather than a refusal that says nothing. The
8129
+ * send states are here too: any approval's id can be handed to an update, a send's among them, and this reads the
8130
+ * record before anything looks at its kind.
8131
+ */
8132
+ function whatBecameOf(record) {
8133
+ if (record === null) return "there is no approval by that id";
8134
+ return {
8135
+ pending: `it was still waiting to be approved, and lapses at ${record.expiresAt}`,
8136
+ approved: `it had been approved, and lapses unspent at ${record.expiresAt}`,
8137
+ sending: "it is an approval to send, and that send is under way",
8138
+ used: "it had been spent already",
8139
+ failed: "it had been spent on a send that failed",
8140
+ unknown: "it had been spent on a send whose outcome was never recorded",
8141
+ expired: `it had expired at ${record.expiresAt}`,
8142
+ revoked: "it had been revoked"
8143
+ }[record.state] ?? `it is ${JSON.stringify(record.state)}, a state this version does not know`;
8144
+ }
7843
8145
  /** The product a runtime of `packageName` is installed as: the channel whose package it is. */
7844
8146
  function runtimeProduct(packageName, version) {
7845
8147
  const channel = CHANNELS.find((each) => channelServer(each).packageName === packageName);
@@ -8660,7 +8962,7 @@ function terminalUpdateHooks(core, env, options) {
8660
8962
  };
8661
8963
  }
8662
8964
  //#endregion
8663
- //#region ../core/dist/tool-arguments-CohGlMUZ.mjs
8965
+ //#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
8664
8966
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
8665
8967
  const TARGET = "draft-2020-12";
8666
8968
  /**
@@ -19865,7 +20167,7 @@ function keyPermissionOf(account) {
19865
20167
  * wrong. Core's schema reads any mode word so that one odd account cannot make the whole file unreadable; acting on
19866
20168
  * one is refused here, as Slack refuses its own.
19867
20169
  */
19868
- function checkedAccount(name, account) {
20170
+ function checkedAccount(name, account, platform = process.platform) {
19869
20171
  const problem = (() => {
19870
20172
  if (account.platform !== "resend") return `it is a ${account.platform} account`;
19871
20173
  if (!MODES.includes(String(account.mode))) return `its mode is "${String(account.mode)}", and a Resend account is in read or send mode`;
@@ -19875,57 +20177,67 @@ function checkedAccount(name, account) {
19875
20177
  if (lock !== void 0 && (typeof lock !== "string" || !DOMAIN.test(lock))) return "its domainLock is not a domain";
19876
20178
  return null;
19877
20179
  })();
19878
- if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: `Remove it with \`agent-resend account remove ${name}\` and add it again, or fix it in the configuration file.` });
20180
+ 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([
20181
+ "agent-resend",
20182
+ "account",
20183
+ "remove",
20184
+ name
20185
+ ], platform))} and add it again, or fix it in the configuration file.` });
19879
20186
  return account;
19880
20187
  }
19881
20188
  function notFound(name) {
19882
20189
  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>`." });
19883
20190
  }
19884
20191
  /** Every Resend account in `config`, checked, by name. */
19885
- function resendAccounts(config) {
20192
+ function resendAccounts(config, platform = process.platform) {
19886
20193
  return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name, account]) => ({
19887
20194
  name,
19888
- account: checkedAccount(name, account)
20195
+ account: checkedAccount(name, account, platform)
19889
20196
  })).sort((a, b) => a.name < b.name ? -1 : 1);
19890
20197
  }
19891
20198
  /**
19892
20199
  * The account by name, or a refusal that says what to do — through core's `resolveName`, so a name that was replaced
19893
20200
  * is refused with the one it has now rather than reported as unknown.
19894
20201
  */
19895
- function requireAccount(config, name) {
20202
+ function requireAccount(config, name, platform = process.platform) {
19896
20203
  const { alias, account } = resolveName(config, "account", name, notFound(name));
19897
20204
  if (account.platform !== "resend") throw notFound(name)();
19898
20205
  return {
19899
20206
  name: alias,
19900
- account: checkedAccount(alias, account)
20207
+ account: checkedAccount(alias, account, platform)
19901
20208
  };
19902
20209
  }
19903
20210
  /** The account under exactly this name, or null: no former names, no refusal. */
19904
- function lookupAccount(config, name) {
20211
+ function lookupAccount(config, name, platform = process.platform) {
19905
20212
  const account = lookupName(config, "account", name);
19906
20213
  if (!account || account.platform !== "resend") return null;
19907
20214
  return {
19908
20215
  name,
19909
- account: checkedAccount(name, account)
20216
+ account: checkedAccount(name, account, platform)
19910
20217
  };
19911
20218
  }
19912
20219
  /** The account by its immutable id, under whatever name it has now. */
19913
- function accountById(config, id) {
20220
+ function accountById(config, id, platform = process.platform) {
19914
20221
  const found = findById(config, "account", id);
19915
20222
  if (!found || found.account.platform !== "resend") return null;
19916
20223
  return {
19917
20224
  name: found.alias,
19918
- account: checkedAccount(found.alias, found.account)
20225
+ account: checkedAccount(found.alias, found.account, platform)
19919
20226
  };
19920
20227
  }
19921
20228
  /**
19922
20229
  * Refuses a name a new account cannot take — core's rule, for either config version: the grammar and the platform,
19923
20230
  * free in both maps, and never a former name — with this package's own hint for one already connected.
19924
20231
  */
19925
- function checkNewName(config, name) {
20232
+ function checkNewName(config, name, platform = process.platform) {
19926
20233
  const check = nameAvailable(config, "account", name, PLATFORM);
19927
20234
  if (check.ok) return;
19928
- 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 \`agent-resend account remove ${name}\`.` });
20235
+ 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([
20236
+ "agent-resend",
20237
+ "account",
20238
+ "remove",
20239
+ name
20240
+ ], platform))}.` });
19929
20241
  throw check.error;
19930
20242
  }
19931
20243
  /** `config` with `account` under `name`. */
@@ -19961,20 +20273,22 @@ function changePolicyIn(config, account) {
19961
20273
  */
19962
20274
  var AccountStore = class {
19963
20275
  #load;
19964
- constructor(load) {
20276
+ #platform;
20277
+ constructor(load, platform = process.platform) {
19965
20278
  this.#load = load;
20279
+ this.#platform = platform;
19966
20280
  }
19967
20281
  async list() {
19968
- return resendAccounts(await this.#load());
20282
+ return resendAccounts(await this.#load(), this.#platform);
19969
20283
  }
19970
20284
  async find(name) {
19971
- return lookupAccount(await this.#load(), name);
20285
+ return lookupAccount(await this.#load(), name, this.#platform);
19972
20286
  }
19973
20287
  async findById(id) {
19974
- return accountById(await this.#load(), id);
20288
+ return accountById(await this.#load(), id, this.#platform);
19975
20289
  }
19976
20290
  async require(name) {
19977
- return requireAccount(await this.#load(), name);
20291
+ return requireAccount(await this.#load(), name, this.#platform);
19978
20292
  }
19979
20293
  };
19980
20294
  var Throttle = class {
@@ -19982,12 +20296,38 @@ var Throttle = class {
19982
20296
  #now;
19983
20297
  #sleep;
19984
20298
  #interval;
20299
+ /**
20300
+ * Five seconds **for each holder** of the lock, not for the whole line of callers ahead.
20301
+ *
20302
+ * Every Resend request on this machine takes this one lock, and an agent asking six accounts at once puts six
20303
+ * callers on it at the same moment. With the timeout counted from when each began to wait, the sixth had to see
20304
+ * five holders through inside one five-second budget; on a heavily loaded machine (a load of 97 on ten cores, in
20305
+ * the pre-push run that showed it) the read and the fsynced write under the lock slow down with everything else,
20306
+ * and it gave up with "another process is holding" while the lock was being handed on exactly as it should be. A
20307
+ * person with several accounts and a busy machine would have had requests refused for no reason but the queue.
20308
+ * Counted per holder, a caller gives up only once one holder has kept the lock for five seconds: the stuck lock
20309
+ * the timeout is there for.
20310
+ *
20311
+ * Each request takes the lock once, to reserve its slot, and does its waiting outside it, so the line is only the
20312
+ * requests in flight. But the lock is polled, not queued: whoever looks first after a release takes it, and a
20313
+ * request that keeps looking at the wrong moment watches later ones go ahead of it, for as long as later ones keep
20314
+ * arriving — and several servers each paging through sent mail keep them arriving. So the wait also has an end
20315
+ * that no hand-over moves, `LOCK_MAX_WAIT_MS`, and past it the request is refused as a busy lock rather than left
20316
+ * hanging. `after` waits the same way, because a stop it gave up on recording is a 429 that every other account
20317
+ * carries on into.
20318
+ */
20319
+ #lock;
19985
20320
  /** Takes no account on purpose: see above. Every throttle on a state directory is the same one. */
19986
20321
  constructor(stateDir, options = {}) {
19987
20322
  this.path = join(stateDir, "resend", "throttle.json");
19988
20323
  this.#now = options.now ?? Date.now;
19989
20324
  this.#sleep = options.sleep ?? ((ms) => setTimeout$1(ms).then(() => void 0));
19990
20325
  this.#interval = options.intervalMs ?? 500;
20326
+ this.#lock = {
20327
+ timeoutPerHolder: true,
20328
+ maxWaitMs: options.lockMaxWaitMs ?? 3e4,
20329
+ ...options.lockTimeoutMs === void 0 ? {} : { timeoutMs: options.lockTimeoutMs }
20330
+ };
19991
20331
  }
19992
20332
  async #read() {
19993
20333
  try {
@@ -20004,6 +20344,10 @@ var Throttle = class {
20004
20344
  throw error;
20005
20345
  }
20006
20346
  }
20347
+ /** Runs `fn` holding the machine's one throttle lock, waiting for it as `#lock` says. */
20348
+ #locked(fn) {
20349
+ return withFileLock(`${this.path}.lock`, fn, this.#lock);
20350
+ }
20007
20351
  /** When Resend will next be asked anything from this machine, or null when it is not holding off. */
20008
20352
  async blockedUntil() {
20009
20353
  const state = await this.#read();
@@ -20031,7 +20375,7 @@ var Throttle = class {
20031
20375
  * later.
20032
20376
  */
20033
20377
  async before() {
20034
- const wait = await withFileLock(`${this.path}.lock`, async () => {
20378
+ const wait = await this.#locked(async () => {
20035
20379
  const state = await this.#read();
20036
20380
  this.#refuseIfHeld(state);
20037
20381
  const now = this.#now();
@@ -20062,7 +20406,7 @@ var Throttle = class {
20062
20406
  else if (seconds("ratelimit-remaining") === 0) stopFor = seconds("ratelimit-reset") ?? 1;
20063
20407
  if (stopFor === null) return null;
20064
20408
  const until = this.#now() + Math.max(1, stopFor) * 1e3;
20065
- await withFileLock(`${this.path}.lock`, async () => {
20409
+ await this.#locked(async () => {
20066
20410
  const state = await this.#read();
20067
20411
  await writeFileAtomic(this.path, JSON.stringify({
20068
20412
  ...state,
@@ -20079,6 +20423,7 @@ var ResendContext = class {
20079
20423
  core;
20080
20424
  env;
20081
20425
  now;
20426
+ platform;
20082
20427
  surface;
20083
20428
  accounts;
20084
20429
  #fetch;
@@ -20088,8 +20433,9 @@ var ResendContext = class {
20088
20433
  this.env = options.env ?? process.env;
20089
20434
  this.core = options.core ?? openCore({ env: this.env });
20090
20435
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
20436
+ this.platform = options.platform ?? process.platform;
20091
20437
  this.surface = options.surface ?? "cli";
20092
- this.accounts = new AccountStore(() => this.core.config.load());
20438
+ this.accounts = new AccountStore(() => this.core.config.load(), this.platform);
20093
20439
  this.#fetch = options.fetch;
20094
20440
  this.#throttle = new Throttle(this.core.paths.stateDir, options.throttle);
20095
20441
  }
@@ -20138,7 +20484,12 @@ var ResendContext = class {
20138
20484
  /** A transport for a connected account: its key from the secret store, the machine's throttle, and any open permit. */
20139
20485
  async transport(named, permit) {
20140
20486
  const key = await (await this.secrets()).get(named.account.secretRef);
20141
- 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: \`agent-resend account add ${named.name}\`.` });
20487
+ 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([
20488
+ "agent-resend",
20489
+ "account",
20490
+ "add",
20491
+ named.name
20492
+ ], this.platform))}.` });
20142
20493
  return {
20143
20494
  fetch: this.#fetch,
20144
20495
  key,
@@ -20286,15 +20637,17 @@ function codeFor(status, name) {
20286
20637
  message: "Resend refused the request"
20287
20638
  };
20288
20639
  }
20289
- /**
20290
- * Whether a failed write can have taken effect.
20291
- *
20292
- * A 4xx is Resend refusing to act — except 409, which means another request with this idempotency key exists or is
20293
- * in flight, so something may already have gone. A 5xx, a timeout or a dropped connection says nothing either way.
20294
- */
20295
- function outcomeOf(status) {
20296
- if (status === void 0) return "unknown";
20297
- return status >= 400 && status < 500 && status !== 409 && status !== 408 ? "not-sent" : "unknown";
20640
+ const REFUSED_BEFORE_ACTING = /* @__PURE__ */ new Set([
20641
+ 400,
20642
+ 401,
20643
+ 403,
20644
+ 404,
20645
+ 422,
20646
+ 429
20647
+ ]);
20648
+ /** Whether a failed write can have taken effect. */
20649
+ function writeOutcomeOf(status) {
20650
+ return status !== void 0 && REFUSED_BEFORE_ACTING.has(status) ? "not-sent" : "unknown";
20298
20651
  }
20299
20652
  /** One API request. Resolves to the parsed JSON body of a 2xx, or throws a `CommsError` whose text holds no key. */
20300
20653
  async function resendRequest(transport, method, path, options = {}) {
@@ -20367,7 +20720,7 @@ async function resendRequest(transport, method, path, options = {}) {
20367
20720
  resendError: name,
20368
20721
  stage: "http",
20369
20722
  ...stopFor !== null ? { retryAfterSeconds: stopFor } : {},
20370
- ...writing ? { outcome: outcomeOf(response.status) } : {}
20723
+ ...writing ? { outcome: writeOutcomeOf(response.status) } : {}
20371
20724
  }
20372
20725
  });
20373
20726
  }
@@ -20713,11 +21066,11 @@ function viewOf(named, config) {
20713
21066
  }
20714
21067
  async function listAccounts(context) {
20715
21068
  const config = await context.config();
20716
- return { accounts: resendAccounts(config).map((named) => viewOf(named, config)) };
21069
+ return { accounts: resendAccounts(config, context.platform).map((named) => viewOf(named, config)) };
20717
21070
  }
20718
21071
  async function showAccount(context, name) {
20719
21072
  const config = await context.config();
20720
- return viewOf(requireAccount(config, name), config);
21073
+ return viewOf(requireAccount(config, name, context.platform), config);
20721
21074
  }
20722
21075
  /** A mode word checked by the operation, so both surfaces refuse it in the same words. */
20723
21076
  function modeOf(raw) {
@@ -20736,8 +21089,8 @@ function changePolicyOf(raw) {
20736
21089
  throw new CommsError("USAGE", `"${String(raw)}" is not a change policy: use chat or confirm`);
20737
21090
  }
20738
21091
  /** Refuses a key that is already connected, under any name: one key, one account. */
20739
- function refuseConnectedKey(config, fingerprint) {
20740
- const same = resendAccounts(config).find((named) => named.account.workspace === fingerprint);
21092
+ function refuseConnectedKey(config, fingerprint, platform) {
21093
+ const same = resendAccounts(config, platform).find((named) => named.account.workspace === fingerprint);
20741
21094
  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`." });
20742
21095
  }
20743
21096
  /**
@@ -20772,8 +21125,8 @@ function addAccountChange(context, request, inspection) {
20772
21125
  ...domainLock ? { domainLock } : {}
20773
21126
  };
20774
21127
  const connect = (config) => {
20775
- checkNewName(config, request.name);
20776
- refuseConnectedKey(config, fingerprint);
21128
+ checkNewName(config, request.name, context.platform);
21129
+ refuseConnectedKey(config, fingerprint, context.platform);
20777
21130
  return withAccount(config, request.name, account);
20778
21131
  };
20779
21132
  return {
@@ -20789,7 +21142,7 @@ function addAccountChange(context, request, inspection) {
20789
21142
  };
20790
21143
  },
20791
21144
  apply: async (consent) => {
20792
- const { store, choosing } = secretsStoreFor(await context.config(), void 0);
21145
+ const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.platform);
20793
21146
  const secrets = await context.core.secrets(store);
20794
21147
  await secrets.set(account.secretRef, request.key.trim());
20795
21148
  let written;
@@ -20830,7 +21183,7 @@ function addAccountChange(context, request, inspection) {
20830
21183
  function removeAccountChange(context, name) {
20831
21184
  return {
20832
21185
  plan: (config) => {
20833
- const found = requireAccount(config, name);
21186
+ const found = requireAccount(config, name, context.platform);
20834
21187
  return {
20835
21188
  account: found.name,
20836
21189
  before: config,
@@ -20840,15 +21193,25 @@ function removeAccountChange(context, name) {
20840
21193
  };
20841
21194
  },
20842
21195
  apply: async (_consent, request) => {
20843
- const approved = requireAccount(request.before, name).account;
21196
+ const approved = requireAccount(request.before, name, context.platform).account;
20844
21197
  const removed = await withCredentialsLock(context.core.paths.configDir, async () => {
20845
- const found = requireAccount(await context.config(), name);
20846
- 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 \`agent-resend account show ${name}\`, and remove it again if you still want it gone.` });
21198
+ const found = requireAccount(await context.config(), name, context.platform);
21199
+ 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([
21200
+ "agent-resend",
21201
+ "account",
21202
+ "show",
21203
+ name
21204
+ ], context.platform))}, and remove it again if you still want it gone.` });
20847
21205
  const secrets = await context.secrets();
20848
21206
  await secrets.delete(found.account.secretRef).catch(() => false);
20849
21207
  await context.core.config.update((current) => {
20850
- if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: `Run \`agent-resend account remove ${name}\` again.` });
20851
- const held = accountById(current, found.account.id);
21208
+ if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: `Run ${inlineCommand(shellCommand([
21209
+ "agent-resend",
21210
+ "account",
21211
+ "remove",
21212
+ name
21213
+ ], context.platform))} again.` });
21214
+ const held = accountById(current, found.account.id, context.platform);
20852
21215
  if (!held) return current;
20853
21216
  return withoutAccount(current, held.name);
20854
21217
  });
@@ -20900,7 +21263,7 @@ function reportOf(named, config) {
20900
21263
  }
20901
21264
  async function policyReport(context, name) {
20902
21265
  const config = await context.config();
20903
- return reportOf(requireAccount(config, name), config);
21266
+ return reportOf(requireAccount(config, name, context.platform), config);
20904
21267
  }
20905
21268
  /**
20906
21269
  * Why a policy report refuses the approval it is handed, in the words of the surface it came from, as core's
@@ -20942,7 +21305,7 @@ function policyChange(context, name, wanted) {
20942
21305
  ].filter(Boolean).join(", ");
20943
21306
  return {
20944
21307
  plan: (config) => {
20945
- const found = checked(requireAccount(config, name));
21308
+ const found = checked(requireAccount(config, name, context.platform));
20946
21309
  return {
20947
21310
  account: found.name,
20948
21311
  before: config,
@@ -20951,10 +21314,10 @@ function policyChange(context, name, wanted) {
20951
21314
  };
20952
21315
  },
20953
21316
  apply: async (consent, request) => {
20954
- const approved = requireAccount(request.before, name).account;
21317
+ const approved = requireAccount(request.before, name, context.platform).account;
20955
21318
  let written;
20956
21319
  const config = await context.core.config.update((current) => {
20957
- const held = accountById(current, approved.id);
21320
+ const held = accountById(current, approved.id, context.platform);
20958
21321
  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." });
20959
21322
  written = {
20960
21323
  name: held.name,
@@ -21001,7 +21364,17 @@ async function checkAccount(context, named, offline) {
21001
21364
  name: "key stored",
21002
21365
  ok: false,
21003
21366
  detail: "the key is missing from the secret store",
21004
- fix: `agent-resend account remove ${named.name}, then agent-resend account add ${named.name}`
21367
+ fix: `${commandText(shellCommand([
21368
+ "agent-resend",
21369
+ "account",
21370
+ "remove",
21371
+ named.name
21372
+ ], context.platform))}, then ${commandText(shellCommand([
21373
+ "agent-resend",
21374
+ "account",
21375
+ "add",
21376
+ named.name
21377
+ ], context.platform))}`
21005
21378
  });
21006
21379
  } catch (error) {
21007
21380
  checks.push({
@@ -22048,8 +22421,15 @@ async function ownRecord(context, named, approvalId) {
22048
22421
  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." });
22049
22422
  return record;
22050
22423
  }
22051
- function requireSendMode(named) {
22052
- 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 \`agent-resend account policy ${named.name} --mode send\`, which is a change they approve.` });
22424
+ function requireSendMode(named, platform) {
22425
+ 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([
22426
+ "agent-resend",
22427
+ "account",
22428
+ "policy",
22429
+ named.name,
22430
+ "--mode",
22431
+ "send"
22432
+ ], platform))}, which is a change they approve.` });
22053
22433
  }
22054
22434
  /**
22055
22435
  * Whether the From domain may send, checked before an approval exists.
@@ -22116,8 +22496,12 @@ function expectationOf(message) {
22116
22496
  subject: message.subject
22117
22497
  };
22118
22498
  }
22119
- function describePolicy(effective, approvalId, flags) {
22120
- if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — a person runs \`agent-resend approve ${approvalId}\` at their own terminal before this can go.`;
22499
+ function describePolicy(effective, approvalId, flags, platform) {
22500
+ if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — a person runs ${inlineCommand(shellCommand([
22501
+ "agent-resend",
22502
+ "approve",
22503
+ approvalId
22504
+ ], platform))} at their own terminal before this can go.`;
22121
22505
  return "Policy: chat — send only after the user approves this exact preview.";
22122
22506
  }
22123
22507
  function previewOf(options) {
@@ -22155,7 +22539,7 @@ function previewOf(options) {
22155
22539
  thread: message.inReplyTo ? `reply to ${message.inReplyTo}${message.references.length > 1 ? ` (${message.references.length} references)` : ""}` : void 0,
22156
22540
  links: options.built.links,
22157
22541
  warnings,
22158
- policy: describePolicy(options.effective, options.approvalId, options.flags)
22542
+ policy: describePolicy(options.effective, options.approvalId, options.flags, options.platform)
22159
22543
  });
22160
22544
  }
22161
22545
  async function attachPolicy(context) {
@@ -22173,10 +22557,17 @@ async function attachPolicy(context) {
22173
22557
  */
22174
22558
  async function prepareSend(context, name, input) {
22175
22559
  const named = await context.accounts.require(name);
22176
- requireSendMode(named);
22560
+ requireSendMode(named, context.platform);
22177
22561
  const config = await context.config();
22178
22562
  const livePolicy = named.account.sendPolicy ?? config.defaults.sendPolicy;
22179
- if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: `A person can change it with \`agent-resend account policy ${name} --send confirm\`.` });
22563
+ 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([
22564
+ "agent-resend",
22565
+ "account",
22566
+ "policy",
22567
+ name,
22568
+ "--send",
22569
+ "confirm"
22570
+ ], context.platform))}.` });
22180
22571
  const built = await buildMessage(input, {
22181
22572
  now: context.now(),
22182
22573
  attach: await attachPolicy(context)
@@ -22228,7 +22619,8 @@ async function prepareSend(context, name, input) {
22228
22619
  study,
22229
22620
  domainNote,
22230
22621
  effective,
22231
- flags: study.flags
22622
+ flags: study.flags,
22623
+ platform: context.platform
22232
22624
  }),
22233
22625
  policy: livePolicy,
22234
22626
  effectivePolicy: effective,
@@ -22236,7 +22628,11 @@ async function prepareSend(context, name, input) {
22236
22628
  reach,
22237
22629
  expect: record.expect,
22238
22630
  expiresAt: record.expiresAt,
22239
- nextStep: effective === "confirm" ? `Show the preview to the user, then have them run \`agent-resend approve ${record.approvalId}\` 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."
22631
+ nextStep: effective === "confirm" ? `Show the preview to the user, then have them run ${inlineCommand(shellCommand([
22632
+ "agent-resend",
22633
+ "approve",
22634
+ record.approvalId
22635
+ ], 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."
22240
22636
  };
22241
22637
  }
22242
22638
  /** The stored message and its attachments' bytes, checked again: a file changed since the preview voids the send. */
@@ -22299,13 +22695,64 @@ function describeState(state) {
22299
22695
  switch (state) {
22300
22696
  case "used": return "this approval has already been used — the email was sent once, and is not sent again";
22301
22697
  case "sending": return "this approval is being sent by another process right now";
22302
- case "failed": return "the send under this approval failed or its outcome is unknown; it is never sent again under it";
22698
+ case "failed": return "the send under this approval was refused; nothing was sent";
22303
22699
  case "unknown": return "a process stopped mid-send under this approval; whether the email went is not known";
22304
22700
  case "expired": return "this approval has expired";
22305
22701
  case "revoked": return "this approval was cancelled or voided";
22306
22702
  default: return `this approval is ${state}`;
22307
22703
  }
22308
22704
  }
22705
+ function messageOf(error) {
22706
+ return error instanceof Error ? error.message : String(error);
22707
+ }
22708
+ /** Keeps the failure that stopped the send visible, adding only what could not be settled afterwards. */
22709
+ function noSendError(error, unrecorded) {
22710
+ const original = error instanceof CommsError ? error : new CommsError("UNEXPECTED", messageOf(error), { cause: error });
22711
+ const hint = [original.hint, ...unrecorded].filter((part) => part !== void 0);
22712
+ return new CommsError(original.code, original.message, {
22713
+ ...hint.length === 0 ? {} : { hint: hint.join(" ") },
22714
+ ...original.details === void 0 ? {} : { details: original.details },
22715
+ cause: error
22716
+ });
22717
+ }
22718
+ /** Settles a failure known to have happened before Resend sent anything, without one failed write skipping another. */
22719
+ async function recordNoSend(context, records, options, error) {
22720
+ const unrecorded = [];
22721
+ const said = messageOf(error);
22722
+ try {
22723
+ await records.release(options.accountId, options.approvalId);
22724
+ } catch (failure) {
22725
+ unrecorded.push(`the capacity slot could not be released (${messageOf(failure)})`);
22726
+ }
22727
+ try {
22728
+ await context.core.approvals.complete(options.approvalId, { error: said });
22729
+ } catch (failure) {
22730
+ unrecorded.push(`the approval could not be marked failed (${messageOf(failure)})`);
22731
+ }
22732
+ try {
22733
+ await records.record(options.accountId, {
22734
+ approvalId: options.approvalId,
22735
+ event: "failed",
22736
+ error: said.slice(0, 300)
22737
+ });
22738
+ } catch (failure) {
22739
+ unrecorded.push(`the send record could not record the failure (${messageOf(failure)})`);
22740
+ }
22741
+ try {
22742
+ await context.core.audit.append({
22743
+ inboxId: options.accountId,
22744
+ alias: options.name,
22745
+ operation: "resend.send.execute",
22746
+ outcome: "failed",
22747
+ surface: context.surface,
22748
+ approvalId: options.approvalId,
22749
+ reason: [said.slice(0, 200), ...unrecorded].join("; ")
22750
+ });
22751
+ } catch (failure) {
22752
+ unrecorded.push(`the audit log could not record the failure (${messageOf(failure)})`);
22753
+ }
22754
+ return noSendError(error, unrecorded);
22755
+ }
22309
22756
  /**
22310
22757
  * Step three, and the only place mail leaves.
22311
22758
  *
@@ -22314,10 +22761,17 @@ function describeState(state) {
22314
22761
  */
22315
22762
  async function executeSend(context, name, options) {
22316
22763
  const named = await context.accounts.require(name);
22317
- requireSendMode(named);
22764
+ requireSendMode(named, context.platform);
22318
22765
  const known = await ownRecord(context, named, options.approvalId);
22319
22766
  if (known.state !== "pending" && known.state !== "approved") throw new CommsError("APPROVAL_VOID", `nothing was sent: ${describeState(known.state)}`, {
22320
- hint: known.state === "failed" || known.state === "unknown" ? `Check what happened with \`agent-resend send status ${options.approvalId} --account ${name}\` before anything else.` : "Prepare the send again if it should still go.",
22767
+ hint: known.state === "unknown" ? `Check what happened with ${inlineCommand(shellCommand([
22768
+ "agent-resend",
22769
+ "send",
22770
+ "status",
22771
+ options.approvalId,
22772
+ "--account",
22773
+ name
22774
+ ], context.platform))} before anything else.` : "Prepare the send again if it should still go.",
22321
22775
  details: {
22322
22776
  approvalId: options.approvalId,
22323
22777
  state: known.state
@@ -22340,89 +22794,143 @@ async function executeSend(context, name, options) {
22340
22794
  inboxSub: named.account.userId,
22341
22795
  policy: livePolicy,
22342
22796
  expect: options.expect
22343
- }, { pendingHint: `Ask the user to run \`agent-resend approve ${options.approvalId}\` in their own terminal, then execute it again with the same approval. You cannot approve it yourself.` });
22797
+ }, {
22798
+ pendingHint: `Ask the user to run ${inlineCommand(shellCommand([
22799
+ "agent-resend",
22800
+ "approve",
22801
+ options.approvalId
22802
+ ], context.platform))} in their own terminal, then execute it again with the same approval. You cannot approve it yourself.`,
22803
+ platform: context.platform
22804
+ });
22344
22805
  const records = new SendRecords(context.core.paths.stateDir, context.now);
22345
22806
  const message = prepared.message;
22346
22807
  const recipients = uniqueRecipients(message);
22808
+ const bookkeeping = {
22809
+ name,
22810
+ accountId: named.account.id,
22811
+ approvalId: options.approvalId
22812
+ };
22347
22813
  try {
22348
22814
  await records.reserve(named.account.id, options.approvalId, config.defaults.sendCaps);
22349
22815
  } catch (error) {
22350
- await context.core.approvals.complete(options.approvalId, { error: "rate capped" });
22351
- throw error;
22816
+ throw await recordNoSend(context, records, bookkeeping, error);
22817
+ }
22818
+ try {
22819
+ await records.record(named.account.id, {
22820
+ approvalId: options.approvalId,
22821
+ event: "attempt",
22822
+ recipients,
22823
+ subject: message.subject,
22824
+ scheduledAt: message.scheduledAt
22825
+ });
22826
+ await context.core.audit.append({
22827
+ inboxId: named.account.id,
22828
+ alias: name,
22829
+ operation: "resend.send.execute",
22830
+ outcome: "started",
22831
+ surface: context.surface,
22832
+ approvalId: options.approvalId,
22833
+ recipients: recipients.map(canonicalAddress)
22834
+ }, { durable: true });
22835
+ } catch (error) {
22836
+ throw await recordNoSend(context, records, bookkeeping, error);
22352
22837
  }
22353
- await records.record(named.account.id, {
22354
- approvalId: options.approvalId,
22355
- event: "attempt",
22356
- recipients,
22357
- subject: message.subject,
22358
- scheduledAt: message.scheduledAt
22359
- });
22360
- await context.core.audit.append({
22361
- inboxId: named.account.id,
22362
- alias: name,
22363
- operation: "resend.send.execute",
22364
- outcome: "started",
22365
- surface: context.surface,
22366
- approvalId: options.approvalId,
22367
- recipients: recipients.map(canonicalAddress)
22368
- }, { durable: true });
22369
22838
  let resendId;
22839
+ let requestIssued = false;
22840
+ const innerFetch = transport.fetch ?? fetch;
22841
+ const trackedTransport = {
22842
+ ...transport,
22843
+ fetch: async (...args) => {
22844
+ requestIssued = true;
22845
+ return innerFetch(...args);
22846
+ }
22847
+ };
22370
22848
  try {
22371
- const response = await spendOn(permit, options.approvalId, "emails.send", () => resendRequest(transport, "POST", "/emails", {
22849
+ const response = await spendOn(permit, options.approvalId, "emails.send", () => resendRequest(trackedTransport, "POST", "/emails", {
22372
22850
  body: payloadOf(message, options.approvalId, contents),
22373
22851
  idempotencyKey: options.approvalId
22374
22852
  }));
22375
22853
  if (typeof response.id !== "string" || response.id === "") throw new CommsError("PROVIDER_UNAVAILABLE", "Resend accepted the send but returned no email id", { details: { outcome: "unknown" } });
22376
22854
  resendId = response.id;
22377
22855
  } catch (error) {
22856
+ if (!requestIssued) throw await recordNoSend(context, records, bookkeeping, error);
22378
22857
  const outcome = error instanceof CommsError && error.details?.outcome === "not-sent" ? "not-sent" : error instanceof CommsError && error.code === "SEND_REFUSED" ? "not-sent" : "unknown";
22379
22858
  const said = error instanceof Error ? error.message : String(error);
22380
- await context.core.approvals.complete(options.approvalId, { error: outcome === "unknown" ? `outcome unknown: ${said}` : said });
22381
- if (outcome === "not-sent") await records.release(named.account.id, options.approvalId);
22859
+ if (outcome === "not-sent") throw await recordNoSend(context, records, bookkeeping, error);
22860
+ const unrecorded = [];
22861
+ try {
22862
+ await records.record(named.account.id, {
22863
+ approvalId: options.approvalId,
22864
+ event: "unknown",
22865
+ error: said.slice(0, 300)
22866
+ });
22867
+ } catch (failure) {
22868
+ unrecorded.push(`the send record could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
22869
+ }
22870
+ try {
22871
+ await context.core.audit.append({
22872
+ inboxId: named.account.id,
22873
+ alias: name,
22874
+ operation: "resend.send.execute",
22875
+ outcome: "failed",
22876
+ surface: context.surface,
22877
+ approvalId: options.approvalId,
22878
+ reason: `outcome unknown: ${said.slice(0, 200)}`
22879
+ });
22880
+ } catch (failure) {
22881
+ unrecorded.push(`the audit log could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
22882
+ }
22883
+ throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
22884
+ hint: [`Do not send it again. Check the Resend dashboard or ask the recipient, and check with ${inlineCommand(shellCommand([
22885
+ "agent-resend",
22886
+ "send",
22887
+ "status",
22888
+ options.approvalId,
22889
+ "--account",
22890
+ name
22891
+ ], context.platform))}; this approval is not used again.`, ...unrecorded].join(" "),
22892
+ details: {
22893
+ ...error instanceof CommsError ? error.details : {},
22894
+ approvalId: options.approvalId,
22895
+ outcome: "unknown"
22896
+ },
22897
+ cause: error
22898
+ });
22899
+ }
22900
+ const unrecorded = [];
22901
+ try {
22902
+ await context.core.approvals.complete(options.approvalId, { sentMessageId: resendId });
22903
+ } catch (error) {
22904
+ unrecorded.push(`the approval could not be marked used (${error instanceof Error ? error.message : String(error)}), so it will read as unknown`);
22905
+ }
22906
+ try {
22382
22907
  await records.record(named.account.id, {
22383
22908
  approvalId: options.approvalId,
22384
- event: outcome === "unknown" ? "unknown" : "failed",
22385
- error: said.slice(0, 300)
22909
+ event: "sent",
22910
+ resendId
22386
22911
  });
22912
+ } catch (error) {
22913
+ unrecorded.push(`the send record could not record it (${error instanceof Error ? error.message : String(error)})`);
22914
+ }
22915
+ try {
22387
22916
  await context.core.audit.append({
22388
22917
  inboxId: named.account.id,
22389
22918
  alias: name,
22390
22919
  operation: "resend.send.execute",
22391
- outcome: "failed",
22920
+ outcome: "ok",
22392
22921
  surface: context.surface,
22393
22922
  approvalId: options.approvalId,
22394
- reason: outcome === "unknown" ? `outcome unknown: ${said.slice(0, 200)}` : said.slice(0, 200)
22923
+ ids: { resendIds: [resendId] },
22924
+ recipients: [
22925
+ ...claimed.expect.to,
22926
+ ...claimed.expect.cc,
22927
+ ...claimed.expect.bcc
22928
+ ].map(canonicalAddress),
22929
+ reason: [`digest ${digest.slice(0, 12)} · policy ${livePolicy} · ${claimed.approvedVia ?? "chat"}`, ...unrecorded].join(" · ")
22395
22930
  });
22396
- if (outcome === "unknown") throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
22397
- hint: `Do not send it again. Check with \`agent-resend send status ${options.approvalId} --account ${name}\`; Resend would also refuse a repeat under this approval for 24 hours.`,
22398
- details: {
22399
- approvalId: options.approvalId,
22400
- outcome: "unknown"
22401
- }
22402
- });
22403
- throw error;
22931
+ } catch (error) {
22932
+ unrecorded.push(`the audit log could not record it (${error instanceof Error ? error.message : String(error)})`);
22404
22933
  }
22405
- await context.core.approvals.complete(options.approvalId, { sentMessageId: resendId });
22406
- await records.record(named.account.id, {
22407
- approvalId: options.approvalId,
22408
- event: "sent",
22409
- resendId
22410
- });
22411
- await context.core.audit.append({
22412
- inboxId: named.account.id,
22413
- alias: name,
22414
- operation: "resend.send.execute",
22415
- outcome: "ok",
22416
- surface: context.surface,
22417
- approvalId: options.approvalId,
22418
- ids: { resendIds: [resendId] },
22419
- recipients: [
22420
- ...claimed.expect.to,
22421
- ...claimed.expect.cc,
22422
- ...claimed.expect.bcc
22423
- ].map(canonicalAddress),
22424
- reason: `digest ${digest.slice(0, 12)} · policy ${livePolicy} · ${claimed.approvedVia ?? "chat"}`
22425
- });
22426
22934
  return {
22427
22935
  account: name,
22428
22936
  approvalId: options.approvalId,
@@ -22432,7 +22940,8 @@ async function executeSend(context, name, options) {
22432
22940
  to: claimed.expect.to,
22433
22941
  cc: claimed.expect.cc,
22434
22942
  bcc: claimed.expect.bcc,
22435
- subject: claimed.expect.subject
22943
+ subject: claimed.expect.subject,
22944
+ ...unrecorded.length > 0 ? { note: unrecorded.join("; ") } : {}
22436
22945
  };
22437
22946
  }
22438
22947
  /**
@@ -22528,7 +23037,7 @@ async function beginSendApproval(context, approvalId) {
22528
23037
  const config = await context.config();
22529
23038
  const study = await studyRecipients(context, named, prepared.message, config.defaults.riskEscalation);
22530
23039
  const effective = stricterPolicy(named.account.sendPolicy ?? config.defaults.sendPolicy, record.requiredPolicy);
22531
- const challenge = await context.core.approvals.issueChallenge(approvalId);
23040
+ const challenge = await context.core.approvals.issueChallenge(approvalId, "send", context.platform);
22532
23041
  return {
22533
23042
  approvalId,
22534
23043
  account: named.name,
@@ -22543,7 +23052,8 @@ async function beginSendApproval(context, approvalId) {
22543
23052
  study,
22544
23053
  domainNote: prepared.warnings[0] ?? "",
22545
23054
  effective,
22546
- flags: record.riskFlags
23055
+ flags: record.riskFlags,
23056
+ platform: context.platform
22547
23057
  }),
22548
23058
  challenge
22549
23059
  };
@@ -22554,7 +23064,7 @@ async function finishSendApproval(context, approvalId, answer) {
22554
23064
  const approved = await context.core.approvals.approve(approvalId, "terminal", {
22555
23065
  draftMessageId: digest,
22556
23066
  digest
22557
- }, answer);
23067
+ }, answer, "send", context.platform);
22558
23068
  await context.core.audit.append({
22559
23069
  inboxId: named.account.id,
22560
23070
  alias: named.name,
@@ -22580,4 +23090,4 @@ async function revokeSendApproval(context, approvalId) {
22580
23090
  });
22581
23091
  }
22582
23092
  //#endregion
22583
- export { installExitStatus 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 _, RESEND_DOWNLOAD_ORIGIN as _t, revokeSendApproval as a, runCommand as at, listAccounts as b, downloadReceived as c, serverPruneChange as ct, listReceived as d, truncateDisplay as dt, openCore as et, listScheduled as f, updateGateAtTerminal as ft, showSentEmail as g, RESEND_API_ORIGIN as gt, showReceived as h, REFUSED as ht, prepareSend as i, renderPrune as it, MODES as j, ResendContext as k, getMetrics as l, terminalUpdateHooks as lt, listSuppressions as m, writeResult as mt, executeSend as n, refuseUnclaimedApproval as nt, sendStatus as o, runUpdateCheckChild as ot, listSentEmails as p, updateToolGate as pt, colorEnabled as q, finishSendApproval as r, renderInstall as rt, cancelScheduledChange as s, serverInstallChange as st, beginSendApproval as t, paint as tt, listDomains as u, toCommsError as ut, addAccountChange as v, ROUTES as vt, policyReport as w, modeOf as x, inspectKey as y, VERSION$1 as yt, UPDATE_CHECK_CHILD_COMMAND as z };
23093
+ 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 };