@agentcomms/resend 0.12.2 → 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 { homedir, platform, userInfo } from "node:os";
8
- import { constants as constants$1, realpathSync } from "node:fs";
9
7
  import "node:readline/promises";
10
8
  import { styleText } from "node:util";
9
+ import { homedir, platform, userInfo } from "node:os";
10
+ import { constants as constants$1, realpathSync } from "node:fs";
11
11
  import { execFile, spawn } from "node:child_process";
12
12
  //#endregion
13
13
  //#region src/version.ts
14
14
  /** The package version, read from package.json at build time. */
15
- const VERSION$1 = "0.12.2";
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-B-WYPZae.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
@@ -1211,6 +1211,253 @@ function connectMailboxCommand() {
1211
1211
  const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
1212
1212
  return mail ? `${mail.binary} inbox add` : void 0;
1213
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
+ }
1214
1461
  /**
1215
1462
  * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1216
1463
  * and the preview renderer (which makes them visible), so the two can never disagree.
@@ -1871,13 +2118,19 @@ function committedSecretsStore(config) {
1871
2118
  * A different backend asked for is refused, not taken: one backend holds everything here, and changing it has to move
1872
2119
  * what is already stored, which is what `agentcomms secrets migrate` does and nothing else does.
1873
2120
  */
1874
- function secretsStoreFor(config, requested) {
2121
+ function secretsStoreFor(config, requested, platform = process.platform) {
1875
2122
  const committed = committedSecretsStore(config);
1876
2123
  if (committed === null) return {
1877
2124
  store: requested ?? "keychain",
1878
2125
  choosing: true
1879
2126
  };
1880
- 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.` });
1881
2134
  return {
1882
2135
  store: committed,
1883
2136
  choosing: false
@@ -2420,40 +2673,6 @@ function canonicalLoosening(loosening) {
2420
2673
  function sameLoosening(a, b) {
2421
2674
  return canonicalLoosening(a) === canonicalLoosening(b);
2422
2675
  }
2423
- /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
2424
- const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2425
- function randomString(length) {
2426
- const bytes = randomBytes(length);
2427
- let out = "";
2428
- for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
2429
- return out;
2430
- }
2431
- /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
2432
- const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
2433
- const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
2434
- function newApprovalId() {
2435
- return `ap_${randomString(26)}`;
2436
- }
2437
- function newPlanToken() {
2438
- return `pl_${randomString(26)}`;
2439
- }
2440
- const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
2441
- /** A short challenge a human types back to approve: letters only, no look-alikes. */
2442
- function newChallenge(length = 4) {
2443
- let out = "";
2444
- for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
2445
- return out;
2446
- }
2447
- /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
2448
- function hashChallenge(challenge) {
2449
- return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
2450
- }
2451
- /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
2452
- function challengeMatches(answer, storedHash) {
2453
- const a = Buffer.from(hashChallenge(answer), "hex");
2454
- const b = Buffer.from(storedHash, "hex");
2455
- return a.length === b.length && timingSafeEqual(a, b);
2456
- }
2457
2676
  /**
2458
2677
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
2459
2678
  * names it showed them under.
@@ -2747,19 +2966,23 @@ var ApprovalStore = class {
2747
2966
  * Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
2748
2967
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
2749
2968
  */
2750
- #requireKind(record, kind) {
2969
+ #requireKind(record, kind, platform) {
2751
2970
  const actual = approvalKind(record);
2752
2971
  if (actual === kind) return;
2753
2972
  const id = record.approvalId;
2754
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);
2755
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.");
2756
- 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.`);
2757
2980
  }
2758
2981
  /** Issues a new challenge to show a human; only its hash is kept. */
2759
- async issueChallenge(approvalId, kind = "send") {
2982
+ async issueChallenge(approvalId, kind = "send", platform = process.platform) {
2760
2983
  const challenge = newChallenge();
2761
2984
  await this.#transition(approvalId, (current) => {
2762
- this.#requireKind(current, kind);
2985
+ this.#requireKind(current, kind, platform);
2763
2986
  if (current.state !== "pending") throw this.#stateError(current);
2764
2987
  return {
2765
2988
  ...current,
@@ -2776,10 +2999,10 @@ var ApprovalStore = class {
2776
2999
  * A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
2777
3000
  * `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
2778
3001
  */
2779
- async approve(approvalId, via, live, answer, kind = "send") {
3002
+ async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
2780
3003
  let failure = null;
2781
3004
  const result = await this.#transition(approvalId, (current) => {
2782
- this.#requireKind(current, kind);
3005
+ this.#requireKind(current, kind, platform);
2783
3006
  if (current.state !== "pending") throw this.#stateError(current);
2784
3007
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
2785
3008
  if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
@@ -2836,7 +3059,7 @@ var ApprovalStore = class {
2836
3059
  async claimForSend(approvalId, live, options = {}) {
2837
3060
  let failure = null;
2838
3061
  const result = await this.#transition(approvalId, (current) => {
2839
- this.#requireKind(current, "send");
3062
+ this.#requireKind(current, "send", options.platform ?? process.platform);
2840
3063
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2841
3064
  if (options.signal?.aborted) throw cancelledClaim(current);
2842
3065
  const voidWith = (code, reason) => {
@@ -2947,7 +3170,7 @@ var ApprovalStore = class {
2947
3170
  const digest = changeDigest(live.change);
2948
3171
  let failure = null;
2949
3172
  const result = await this.#transition(approvalId, (current) => {
2950
- this.#requireKind(current, "change");
3173
+ this.#requireKind(current, "change", options.platform ?? process.platform);
2951
3174
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2952
3175
  if (options.signal?.aborted) throw cancelledClaim(current);
2953
3176
  const voidWith = (reason) => {
@@ -2963,7 +3186,11 @@ var ApprovalStore = class {
2963
3186
  };
2964
3187
  if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
2965
3188
  if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
2966
- 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.`);
2967
3194
  if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
2968
3195
  }
2969
3196
  return {
@@ -3040,13 +3267,13 @@ var ApprovalStore = class {
3040
3267
  *
3041
3268
  * Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
3042
3269
  */
3043
- async answerDownload(approvalId, via, answer) {
3270
+ async answerDownload(approvalId, via, answer, platform = process.platform) {
3044
3271
  const recorded = answer.choice === "other" ? {
3045
3272
  choice: "other",
3046
3273
  folder: answer.folder
3047
3274
  } : { choice: answer.choice };
3048
3275
  return await this.#transition(approvalId, (current) => {
3049
- this.#requireKind(current, "download");
3276
+ this.#requireKind(current, "download", platform);
3050
3277
  if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
3051
3278
  if (current.state !== "pending") throw this.#stateError(current);
3052
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);
@@ -3085,7 +3312,7 @@ var ApprovalStore = class {
3085
3312
  const digest = downloadDigest(live);
3086
3313
  let failure = null;
3087
3314
  const result = await this.#transition(approvalId, (current) => {
3088
- this.#requireKind(current, "download");
3315
+ this.#requireKind(current, "download", options.platform ?? process.platform);
3089
3316
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
3090
3317
  if (options.signal?.aborted) throw cancelledClaim(current);
3091
3318
  const voidWith = (reason) => {
@@ -3244,330 +3471,117 @@ const MAP = {
3244
3471
  * An own property only.
3245
3472
  *
3246
3473
  * A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
3247
- * of an inbox called `constructor`, which version 1 allows, found it.
3248
- */
3249
- function own(map, key) {
3250
- return Object.hasOwn(map, key) ? map[key] : void 0;
3251
- }
3252
- function resolveName(config, kind, name, notFound) {
3253
- if (kind === "inbox") {
3254
- const inbox = own(config.inboxes, name);
3255
- if (inbox) return {
3256
- alias: name,
3257
- inbox
3258
- };
3259
- } else {
3260
- const account = own(config.accounts, name);
3261
- if (account) return {
3262
- alias: name,
3263
- account
3264
- };
3265
- }
3266
- const renamed = formerNameRefusal(config, kind, name);
3267
- if (renamed) throw renamed;
3268
- throw notFound?.() ?? defaultNotFound(config, kind, name);
3269
- }
3270
- /** What to say when there are no mailboxes at all: the command that connects one, from the mail channel's manifest. */
3271
- function noInboxesHint() {
3272
- const connect = connectMailboxCommand();
3273
- return connect ? `No inboxes yet: add one with \`${connect}\`.` : "No inboxes yet.";
3274
- }
3275
- function defaultNotFound(config, kind, name) {
3276
- if (kind === "inbox") {
3277
- const known = Object.keys(config.inboxes);
3278
- return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
3279
- }
3280
- return new CommsError("NOT_FOUND", `no account called "${name}"`);
3281
- }
3282
- function lookupName(config, kind, name) {
3283
- return kind === "inbox" ? own(config.inboxes, name) : own(config.accounts, name);
3284
- }
3285
- function findById(config, kind, id) {
3286
- if (kind === "inbox") {
3287
- for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
3288
- alias,
3289
- inbox
3290
- };
3291
- } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
3292
- alias,
3293
- account
3294
- };
3295
- return null;
3296
- }
3297
- /**
3298
- * The refusal for a former name, or null when `name` is not one.
3299
- *
3300
- * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
3301
- * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
3302
- * name that now belongs to nothing.
3303
- */
3304
- function formerNameRefusal(config, kind, name) {
3305
- if (config.version !== 2) return null;
3306
- const former = own(config.formerNames[MAP[kind]], name);
3307
- if (!former) return null;
3308
- const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
3309
- if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
3310
- formerName: name,
3311
- id: former.id
3312
- } });
3313
- return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
3314
- hint: `Use "${current.alias}".`,
3315
- details: {
3316
- formerName: name,
3317
- currentName: current.alias,
3318
- id: former.id
3319
- }
3320
- });
3321
- }
3322
- /**
3323
- * Whether `name` may be given to a new or renamed account of this kind and platform.
3324
- *
3325
- * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
3326
- * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
3327
- * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
3328
- * either kind.
3329
- *
3330
- * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
3331
- * again for version 2, because anything can happen between asking and writing.
3332
- */
3333
- function nameAvailable(config, kind, name, platform) {
3334
- const refuse = (error) => ({
3335
- ok: false,
3336
- error
3337
- });
3338
- if (config.version === 1) {
3339
- if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
3340
- if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
3341
- if (kind === "inbox" ? own(config.inboxes, name) : own(config.inboxes, name) ?? own(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3342
- return { ok: true };
3343
- }
3344
- const problem = nameShapeProblem(name, platform);
3345
- if (problem) return refuse(new CommsError("USAGE", problem));
3346
- if (own(config.inboxes, name) || own(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3347
- for (const map of ["inboxes", "accounts"]) {
3348
- const former = own(config.formerNames[map], name);
3349
- if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
3350
- hint: "A former name keeps pointing people at the account that had it. Choose another name.",
3351
- details: {
3352
- formerName: name,
3353
- id: former.id
3354
- }
3355
- }));
3356
- }
3357
- return { ok: true };
3358
- }
3359
- function okEnvelope(data) {
3360
- return {
3361
- ok: true,
3362
- schemaVersion: 1,
3363
- data
3364
- };
3365
- }
3366
- function errorEnvelope(error) {
3367
- const body = {
3368
- code: error.code,
3369
- message: error.message
3370
- };
3371
- if (error.hint !== void 0) body.hint = error.hint;
3372
- if (error.details !== void 0) body.details = error.details;
3373
- return {
3374
- ok: false,
3375
- schemaVersion: 1,
3376
- error: body
3377
- };
3378
- }
3379
- const defaultStreams = {
3380
- stdout: process.stdout,
3381
- stderr: process.stderr,
3382
- stdin: process.stdin
3383
- };
3384
- function colorEnabled(env, stream, flag) {
3385
- if (flag === false) return false;
3386
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
3387
- if (env.TERM === "dumb") return false;
3388
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
3389
- return Boolean(stream.isTTY);
3390
- }
3391
- /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
3392
- function canPrompt(env, streams, options) {
3393
- if (options.json || options.noInput) return false;
3394
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
3395
- return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
3396
- }
3397
- /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
3398
- const AGENT_MARKERS = [
3399
- "CLAUDECODE",
3400
- "CLAUDE_CODE_ENTRYPOINT",
3401
- "CODEX_SANDBOX",
3402
- "CODEX_HOME",
3403
- "CURSOR_AGENT",
3404
- "GEMINI_CLI",
3405
- "AGENT_COMMS_AGENT"
3406
- ];
3407
- function agentMarker(env) {
3408
- for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
3409
- return null;
3410
- }
3411
- function paint(color, format, text) {
3412
- return color ? styleText(format, text, { validateStream: false }) : text;
3413
- }
3414
- /**
3415
- * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
3416
- * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
3417
- * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
3418
- *
3419
- * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
3420
- * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
3421
- * as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
3422
- * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
3423
- * has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
3424
- * the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
3425
- * hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
3426
- * reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
3427
- * line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
3428
- * applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
3429
- * 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
3430
- * `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
3431
- * old way, and cmd.exe then reads that new line. So on Windows:
3432
- *
3433
- * - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
3434
- * does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
3435
- * splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
3436
- * that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
3437
- * is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
3438
- * - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
3439
- * `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
3440
- * backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
3441
- * quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
3442
- * expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
3443
- * expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
3444
- * escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
3445
- * tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
3446
- * that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
3447
- * backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
3448
- * backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
3449
- * which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
3450
- * - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
3451
- * as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
3452
- * still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
3453
- * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
3454
- * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
3455
- *
3456
- * Everywhere else there is always a line: single quotes make any word safe.
3457
- */
3458
- function shellCommand(words, platform = process.platform) {
3459
- if (platform !== "win32") return {
3460
- words,
3461
- line: words.map(posixShellWord).join(" "),
3462
- platform
3463
- };
3464
- const printed = words.map(windowsShellWord);
3465
- return {
3466
- words,
3467
- line: printed.includes(null) ? null : printed.join(" "),
3468
- platform
3469
- };
3470
- }
3471
- /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
3472
- function withWords(command, ...more) {
3473
- return shellCommand([...command.words, ...more], command.platform);
3474
- }
3475
- function posixShellWord(word) {
3476
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
3477
- }
3478
- /**
3479
- * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
3480
- * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
3481
- */
3482
- function windowsShellWord(word) {
3483
- if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
3484
- if (word === "" || word.endsWith("\\")) return null;
3485
- if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
3486
- if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
3487
- return `"${word}"`;
3488
- }
3489
- /**
3490
- * The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
3491
- * parse, and nothing to run.
3492
- *
3493
- * Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
3494
- * opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
3495
- * finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
3496
- * `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
3497
- * JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
3498
- * as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
3499
- * so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
3500
- * doubled, as JSON has it.
3501
- */
3502
- function commandAsJson(words) {
3503
- const word = (text) => {
3504
- let json = "";
3505
- for (let index = 0; index < text.length; index++) {
3506
- const code = text.charCodeAt(index);
3507
- const char = text[index];
3508
- if (char === "\\") json += "\\\\";
3509
- else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
3510
- else json += `\\u${code.toString(16).padStart(4, "0")}`;
3511
- }
3512
- return `"${json}"`;
3513
- };
3514
- return `[${words.map(word).join(",")}]`;
3515
- }
3516
- /** What is said beside a command shown as JSON. */
3517
- 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";
3518
- /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
3519
- function inlineCommand(command) {
3520
- return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
3521
- }
3522
- /** 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. */
3523
- function commandText(command) {
3524
- return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
3474
+ * of an inbox called `constructor`, which version 1 allows, found it.
3475
+ */
3476
+ function own(map, key) {
3477
+ return Object.hasOwn(map, key) ? map[key] : void 0;
3525
3478
  }
3526
- /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
3527
- function writeResult(data, options, human, streams = defaultStreams) {
3528
- if (options.json) {
3529
- streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
3530
- return;
3479
+ function resolveName(config, kind, name, notFound) {
3480
+ if (kind === "inbox") {
3481
+ const inbox = own(config.inboxes, name);
3482
+ if (inbox) return {
3483
+ alias: name,
3484
+ inbox
3485
+ };
3486
+ } else {
3487
+ const account = own(config.accounts, name);
3488
+ if (account) return {
3489
+ alias: name,
3490
+ account
3491
+ };
3531
3492
  }
3532
- const text = human(data);
3533
- if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
3493
+ const renamed = formerNameRefusal(config, kind, name);
3494
+ if (renamed) throw renamed;
3495
+ throw notFound?.() ?? defaultNotFound(config, kind, name);
3534
3496
  }
3535
- /** Writes an error and returns the exit code to use. */
3536
- function writeError(error, options, streams = defaultStreams) {
3537
- const commsError = toCommsError(error);
3538
- if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
3539
- else {
3540
- streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
3541
- if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
3542
- }
3543
- return commsError.exitCode;
3497
+ /** What to say when there are no mailboxes at all: the command that connects one, from the mail channel's manifest. */
3498
+ function noInboxesHint() {
3499
+ const connect = connectMailboxCommand();
3500
+ return connect ? `No inboxes yet: add one with \`${connect}\`.` : "No inboxes yet.";
3544
3501
  }
3545
- /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
3546
- async function runCommand(options, body, streams = defaultStreams) {
3547
- try {
3548
- await body();
3549
- return EXIT_CODES.OK;
3550
- } catch (error) {
3551
- return writeError(error, options, streams);
3502
+ function defaultNotFound(config, kind, name) {
3503
+ if (kind === "inbox") {
3504
+ const known = Object.keys(config.inboxes);
3505
+ return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
3552
3506
  }
3507
+ return new CommsError("NOT_FOUND", `no account called "${name}"`);
3508
+ }
3509
+ function lookupName(config, kind, name) {
3510
+ return kind === "inbox" ? own(config.inboxes, name) : own(config.accounts, name);
3511
+ }
3512
+ function findById(config, kind, id) {
3513
+ if (kind === "inbox") {
3514
+ for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
3515
+ alias,
3516
+ inbox
3517
+ };
3518
+ } else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
3519
+ alias,
3520
+ account
3521
+ };
3522
+ return null;
3553
3523
  }
3554
3524
  /**
3555
- * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
3556
- * whose code is not its own to make up.
3525
+ * The refusal for a former name, or null when `name` is not one.
3557
3526
  *
3558
- * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
3559
- * but it refuses in exactly the same order and words, because it is the same gate.
3527
+ * The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
3528
+ * account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
3529
+ * name that now belongs to nothing.
3560
3530
  */
3561
- function refuseUnlessPerson(env, streams, gate) {
3562
- const marker = agentMarker(env);
3563
- if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
3564
- hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
3565
- details: { marker }
3531
+ function formerNameRefusal(config, kind, name) {
3532
+ if (config.version !== 2) return null;
3533
+ const former = own(config.formerNames[MAP[kind]], name);
3534
+ if (!former) return null;
3535
+ const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
3536
+ if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
3537
+ formerName: name,
3538
+ id: former.id
3539
+ } });
3540
+ return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
3541
+ hint: `Use "${current.alias}".`,
3542
+ details: {
3543
+ formerName: name,
3544
+ currentName: current.alias,
3545
+ id: former.id
3546
+ }
3566
3547
  });
3567
- const prompting = {};
3568
- if (gate.json !== void 0) prompting.json = gate.json;
3569
- if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
3570
- if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
3548
+ }
3549
+ /**
3550
+ * Whether `name` may be given to a new or renamed account of this kind and platform.
3551
+ *
3552
+ * Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
3553
+ * older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
3554
+ * 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
3555
+ * either kind.
3556
+ *
3557
+ * A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
3558
+ * again for version 2, because anything can happen between asking and writing.
3559
+ */
3560
+ function nameAvailable(config, kind, name, platform) {
3561
+ const refuse = (error) => ({
3562
+ ok: false,
3563
+ error
3564
+ });
3565
+ if (config.version === 1) {
3566
+ if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
3567
+ if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
3568
+ if (kind === "inbox" ? own(config.inboxes, name) : own(config.inboxes, name) ?? own(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3569
+ return { ok: true };
3570
+ }
3571
+ const problem = nameShapeProblem(name, platform);
3572
+ if (problem) return refuse(new CommsError("USAGE", problem));
3573
+ if (own(config.inboxes, name) || own(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
3574
+ for (const map of ["inboxes", "accounts"]) {
3575
+ const former = own(config.formerNames[map], name);
3576
+ if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
3577
+ hint: "A former name keeps pointing people at the account that had it. Choose another name.",
3578
+ details: {
3579
+ formerName: name,
3580
+ id: former.id
3581
+ }
3582
+ }));
3583
+ }
3584
+ return { ok: true };
3571
3585
  }
3572
3586
  /**
3573
3587
  * Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
@@ -4995,7 +5009,7 @@ async function mcpInstall(context, product, options, planned) {
4995
5009
  };
4996
5010
  const existing = scan.servers;
4997
5011
  const { cliName, binary, own, configPath, writes } = target;
4998
- 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);
4999
5013
  if (kept.length > 0) {
5000
5014
  const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
5001
5015
  "claude",
@@ -5501,8 +5515,8 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
5501
5515
  return findings;
5502
5516
  }
5503
5517
  /** What registering a server says about the rival packages its manifest names: one line each. */
5504
- function rivalPackageWarnings(servers, packages) {
5505
- 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}`);
5506
5520
  }
5507
5521
  /**
5508
5522
  * Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
@@ -5588,7 +5602,7 @@ function serverFactsOf({ packageName, manifest }) {
5588
5602
  if (rivals === void 0) return facts;
5589
5603
  return {
5590
5604
  ...facts,
5591
- 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) : []]
5592
5606
  };
5593
5607
  }
5594
5608
  const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
@@ -5786,9 +5800,9 @@ function renderUpdate(result, color) {
5786
5800
  if (result.next) lines.push("", result.next);
5787
5801
  return lines.join("\n");
5788
5802
  }
5789
- /** The terminal command that approves a change, for the channel that asked. */
5790
- function approveCommandOf(options) {
5791
- 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);
5792
5806
  }
5793
5807
  /**
5794
5808
  * The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
@@ -5899,7 +5913,7 @@ async function prepareChange(core, request, options) {
5899
5913
  change: binding
5900
5914
  }),
5901
5915
  expiresAt: record.expiresAt,
5902
- next: nextStep(record.approvalId, policy, approveCommandOf(options))
5916
+ next: nextStep(record.approvalId, policy, options)
5903
5917
  };
5904
5918
  } catch (error) {
5905
5919
  await auditRefusal(core, "change.prepare", error, {
@@ -5930,7 +5944,10 @@ async function claimChange(core, approvalId, expect, options) {
5930
5944
  const record = await core.approvals.claimForChange(approvalId, {
5931
5945
  change: binding,
5932
5946
  policy
5933
- }, { 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
+ });
5934
5951
  const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
5935
5952
  await auditChange(core, {
5936
5953
  operation: "change.claim",
@@ -5978,7 +5995,7 @@ async function beginChangeApproval(core, approvalId, options) {
5978
5995
  let record;
5979
5996
  try {
5980
5997
  record = await changeRecord(core, approvalId);
5981
- const challenge = await core.approvals.issueChallenge(approvalId, "change");
5998
+ const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
5982
5999
  return {
5983
6000
  approvalId,
5984
6001
  preview: renderChangePreview(record),
@@ -6003,7 +6020,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
6003
6020
  const approved = await core.approvals.approve(approvalId, "terminal", {
6004
6021
  draftMessageId: digest,
6005
6022
  digest
6006
- }, answer, "change");
6023
+ }, answer, "change", options.platform);
6007
6024
  await auditChange(core, {
6008
6025
  operation: "change.approve",
6009
6026
  outcome: "ok",
@@ -6059,8 +6076,8 @@ async function revokeChange(core, approvalId, reason, options) {
6059
6076
  function changePolicyOf$1(record) {
6060
6077
  return record.requiredPolicy === "chat" ? "chat" : "confirm";
6061
6078
  }
6062
- function nextStep(approvalId, policy, approveCommand) {
6063
- 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.`;
6064
6081
  }
6065
6082
  /**
6066
6083
  * One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
@@ -6209,12 +6226,14 @@ async function gatedChange(core, change, options) {
6209
6226
  status: "approval-required",
6210
6227
  prepared: await prepareChange(core, request, {
6211
6228
  surface: options.surface,
6212
- approveCommand: options.approveCommand
6229
+ approveCommand: options.approveCommand,
6230
+ platform: options.platform
6213
6231
  })
6214
6232
  };
6215
6233
  const consent = await claimChange(core, options.approvalId, request, {
6216
6234
  surface: options.surface,
6217
- approveCommand: options.approveCommand
6235
+ approveCommand: options.approveCommand,
6236
+ platform: options.platform
6218
6237
  });
6219
6238
  return {
6220
6239
  status: "applied",
@@ -6276,7 +6295,8 @@ async function gatedChangeAtTerminal(core, change, options) {
6276
6295
  const first = await gatedChange(core, change, {
6277
6296
  surface: "cli",
6278
6297
  approvalId: options.approvalId,
6279
- approveCommand
6298
+ approveCommand,
6299
+ platform: options.output.platform
6280
6300
  });
6281
6301
  if (first.status === "applied") return first.result;
6282
6302
  const { prepared } = first;
@@ -6285,7 +6305,7 @@ async function gatedChangeAtTerminal(core, change, options) {
6285
6305
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
6286
6306
  const { command } = options;
6287
6307
  throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
6288
- hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand),
6308
+ hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
6289
6309
  details: {
6290
6310
  approvalId: prepared.approvalId,
6291
6311
  policy: prepared.policy,
@@ -6306,7 +6326,8 @@ async function gatedChangeAtTerminal(core, change, options) {
6306
6326
  const second = await gatedChange(core, change, {
6307
6327
  surface: "cli",
6308
6328
  approvalId: prepared.approvalId,
6309
- approveCommand
6329
+ approveCommand,
6330
+ platform: options.output.platform
6310
6331
  });
6311
6332
  if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
6312
6333
  return second.result;
@@ -6318,9 +6339,9 @@ async function gatedChangeAtTerminal(core, change, options) {
6318
6339
  * Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
6319
6340
  * its registration step and says why in its report — so the two say it in the same words.
6320
6341
  */
6321
- function approvalHint(prepared, rerun, approveCommand) {
6342
+ function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
6322
6343
  const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
6323
- return prepared.policy === "confirm" ? `Show the person the preview. They run \`${approveCommandOf({ approveCommand })} ${prepared.approvalId}\`; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
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}.`;
6324
6345
  }
6325
6346
  /**
6326
6347
  * Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
@@ -6340,7 +6361,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
6340
6361
  refuseUnlessPerson(env, streams, {
6341
6362
  refusedToAgent: "only a person can approve a change, not an agent",
6342
6363
  refusedWithoutTerminal: "approving a change needs an interactive terminal",
6343
- command: `agentcomms approve ${approvalId}`,
6364
+ command: commandText(shellCommand([
6365
+ "agentcomms",
6366
+ "approve",
6367
+ approvalId
6368
+ ], output.platform)),
6344
6369
  color: output.color,
6345
6370
  json: output.json
6346
6371
  });
@@ -7002,7 +7027,7 @@ async function npmInstallGlobal(env, spec) {
7002
7027
  }
7003
7028
  }
7004
7029
  /** The package version, read from package.json at build time. */
7005
- const VERSION = "0.12.2";
7030
+ const VERSION = "0.12.3";
7006
7031
  /**
7007
7032
  * Registering, pruning and listing the MCP servers of every channel, from the core.
7008
7033
  *
@@ -7260,7 +7285,7 @@ function serverPruneChange(core, env, request, own) {
7260
7285
  };
7261
7286
  const { packageName, version } = ownProduct(request.channel, own) ?? {
7262
7287
  packageName: channelServer(request.channel).packageName,
7263
- version: "0.12.2"
7288
+ version: "0.12.3"
7264
7289
  };
7265
7290
  const product = {
7266
7291
  packageName,
@@ -8937,7 +8962,7 @@ function terminalUpdateHooks(core, env, options) {
8937
8962
  };
8938
8963
  }
8939
8964
  //#endregion
8940
- //#region ../core/dist/tool-arguments-BMmJnuY4.mjs
8965
+ //#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
8941
8966
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
8942
8967
  const TARGET = "draft-2020-12";
8943
8968
  /**
@@ -20142,7 +20167,7 @@ function keyPermissionOf(account) {
20142
20167
  * wrong. Core's schema reads any mode word so that one odd account cannot make the whole file unreadable; acting on
20143
20168
  * one is refused here, as Slack refuses its own.
20144
20169
  */
20145
- function checkedAccount(name, account) {
20170
+ function checkedAccount(name, account, platform = process.platform) {
20146
20171
  const problem = (() => {
20147
20172
  if (account.platform !== "resend") return `it is a ${account.platform} account`;
20148
20173
  if (!MODES.includes(String(account.mode))) return `its mode is "${String(account.mode)}", and a Resend account is in read or send mode`;
@@ -20152,57 +20177,67 @@ function checkedAccount(name, account) {
20152
20177
  if (lock !== void 0 && (typeof lock !== "string" || !DOMAIN.test(lock))) return "its domainLock is not a domain";
20153
20178
  return null;
20154
20179
  })();
20155
- 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.` });
20156
20186
  return account;
20157
20187
  }
20158
20188
  function notFound(name) {
20159
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>`." });
20160
20190
  }
20161
20191
  /** Every Resend account in `config`, checked, by name. */
20162
- function resendAccounts(config) {
20192
+ function resendAccounts(config, platform = process.platform) {
20163
20193
  return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name, account]) => ({
20164
20194
  name,
20165
- account: checkedAccount(name, account)
20195
+ account: checkedAccount(name, account, platform)
20166
20196
  })).sort((a, b) => a.name < b.name ? -1 : 1);
20167
20197
  }
20168
20198
  /**
20169
20199
  * The account by name, or a refusal that says what to do — through core's `resolveName`, so a name that was replaced
20170
20200
  * is refused with the one it has now rather than reported as unknown.
20171
20201
  */
20172
- function requireAccount(config, name) {
20202
+ function requireAccount(config, name, platform = process.platform) {
20173
20203
  const { alias, account } = resolveName(config, "account", name, notFound(name));
20174
20204
  if (account.platform !== "resend") throw notFound(name)();
20175
20205
  return {
20176
20206
  name: alias,
20177
- account: checkedAccount(alias, account)
20207
+ account: checkedAccount(alias, account, platform)
20178
20208
  };
20179
20209
  }
20180
20210
  /** The account under exactly this name, or null: no former names, no refusal. */
20181
- function lookupAccount(config, name) {
20211
+ function lookupAccount(config, name, platform = process.platform) {
20182
20212
  const account = lookupName(config, "account", name);
20183
20213
  if (!account || account.platform !== "resend") return null;
20184
20214
  return {
20185
20215
  name,
20186
- account: checkedAccount(name, account)
20216
+ account: checkedAccount(name, account, platform)
20187
20217
  };
20188
20218
  }
20189
20219
  /** The account by its immutable id, under whatever name it has now. */
20190
- function accountById(config, id) {
20220
+ function accountById(config, id, platform = process.platform) {
20191
20221
  const found = findById(config, "account", id);
20192
20222
  if (!found || found.account.platform !== "resend") return null;
20193
20223
  return {
20194
20224
  name: found.alias,
20195
- account: checkedAccount(found.alias, found.account)
20225
+ account: checkedAccount(found.alias, found.account, platform)
20196
20226
  };
20197
20227
  }
20198
20228
  /**
20199
20229
  * Refuses a name a new account cannot take — core's rule, for either config version: the grammar and the platform,
20200
20230
  * free in both maps, and never a former name — with this package's own hint for one already connected.
20201
20231
  */
20202
- function checkNewName(config, name) {
20232
+ function checkNewName(config, name, platform = process.platform) {
20203
20233
  const check = nameAvailable(config, "account", name, PLATFORM);
20204
20234
  if (check.ok) return;
20205
- 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))}.` });
20206
20241
  throw check.error;
20207
20242
  }
20208
20243
  /** `config` with `account` under `name`. */
@@ -20238,20 +20273,22 @@ function changePolicyIn(config, account) {
20238
20273
  */
20239
20274
  var AccountStore = class {
20240
20275
  #load;
20241
- constructor(load) {
20276
+ #platform;
20277
+ constructor(load, platform = process.platform) {
20242
20278
  this.#load = load;
20279
+ this.#platform = platform;
20243
20280
  }
20244
20281
  async list() {
20245
- return resendAccounts(await this.#load());
20282
+ return resendAccounts(await this.#load(), this.#platform);
20246
20283
  }
20247
20284
  async find(name) {
20248
- return lookupAccount(await this.#load(), name);
20285
+ return lookupAccount(await this.#load(), name, this.#platform);
20249
20286
  }
20250
20287
  async findById(id) {
20251
- return accountById(await this.#load(), id);
20288
+ return accountById(await this.#load(), id, this.#platform);
20252
20289
  }
20253
20290
  async require(name) {
20254
- return requireAccount(await this.#load(), name);
20291
+ return requireAccount(await this.#load(), name, this.#platform);
20255
20292
  }
20256
20293
  };
20257
20294
  var Throttle = class {
@@ -20386,6 +20423,7 @@ var ResendContext = class {
20386
20423
  core;
20387
20424
  env;
20388
20425
  now;
20426
+ platform;
20389
20427
  surface;
20390
20428
  accounts;
20391
20429
  #fetch;
@@ -20395,8 +20433,9 @@ var ResendContext = class {
20395
20433
  this.env = options.env ?? process.env;
20396
20434
  this.core = options.core ?? openCore({ env: this.env });
20397
20435
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
20436
+ this.platform = options.platform ?? process.platform;
20398
20437
  this.surface = options.surface ?? "cli";
20399
- this.accounts = new AccountStore(() => this.core.config.load());
20438
+ this.accounts = new AccountStore(() => this.core.config.load(), this.platform);
20400
20439
  this.#fetch = options.fetch;
20401
20440
  this.#throttle = new Throttle(this.core.paths.stateDir, options.throttle);
20402
20441
  }
@@ -20445,7 +20484,12 @@ var ResendContext = class {
20445
20484
  /** A transport for a connected account: its key from the secret store, the machine's throttle, and any open permit. */
20446
20485
  async transport(named, permit) {
20447
20486
  const key = await (await this.secrets()).get(named.account.secretRef);
20448
- 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))}.` });
20449
20493
  return {
20450
20494
  fetch: this.#fetch,
20451
20495
  key,
@@ -20593,15 +20637,17 @@ function codeFor(status, name) {
20593
20637
  message: "Resend refused the request"
20594
20638
  };
20595
20639
  }
20596
- /**
20597
- * Whether a failed write can have taken effect.
20598
- *
20599
- * A 4xx is Resend refusing to act — except 409, which means another request with this idempotency key exists or is
20600
- * in flight, so something may already have gone. A 5xx, a timeout or a dropped connection says nothing either way.
20601
- */
20602
- function outcomeOf(status) {
20603
- if (status === void 0) return "unknown";
20604
- 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";
20605
20651
  }
20606
20652
  /** One API request. Resolves to the parsed JSON body of a 2xx, or throws a `CommsError` whose text holds no key. */
20607
20653
  async function resendRequest(transport, method, path, options = {}) {
@@ -20674,7 +20720,7 @@ async function resendRequest(transport, method, path, options = {}) {
20674
20720
  resendError: name,
20675
20721
  stage: "http",
20676
20722
  ...stopFor !== null ? { retryAfterSeconds: stopFor } : {},
20677
- ...writing ? { outcome: outcomeOf(response.status) } : {}
20723
+ ...writing ? { outcome: writeOutcomeOf(response.status) } : {}
20678
20724
  }
20679
20725
  });
20680
20726
  }
@@ -21020,11 +21066,11 @@ function viewOf(named, config) {
21020
21066
  }
21021
21067
  async function listAccounts(context) {
21022
21068
  const config = await context.config();
21023
- return { accounts: resendAccounts(config).map((named) => viewOf(named, config)) };
21069
+ return { accounts: resendAccounts(config, context.platform).map((named) => viewOf(named, config)) };
21024
21070
  }
21025
21071
  async function showAccount(context, name) {
21026
21072
  const config = await context.config();
21027
- return viewOf(requireAccount(config, name), config);
21073
+ return viewOf(requireAccount(config, name, context.platform), config);
21028
21074
  }
21029
21075
  /** A mode word checked by the operation, so both surfaces refuse it in the same words. */
21030
21076
  function modeOf(raw) {
@@ -21043,8 +21089,8 @@ function changePolicyOf(raw) {
21043
21089
  throw new CommsError("USAGE", `"${String(raw)}" is not a change policy: use chat or confirm`);
21044
21090
  }
21045
21091
  /** Refuses a key that is already connected, under any name: one key, one account. */
21046
- function refuseConnectedKey(config, fingerprint) {
21047
- 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);
21048
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`." });
21049
21095
  }
21050
21096
  /**
@@ -21079,8 +21125,8 @@ function addAccountChange(context, request, inspection) {
21079
21125
  ...domainLock ? { domainLock } : {}
21080
21126
  };
21081
21127
  const connect = (config) => {
21082
- checkNewName(config, request.name);
21083
- refuseConnectedKey(config, fingerprint);
21128
+ checkNewName(config, request.name, context.platform);
21129
+ refuseConnectedKey(config, fingerprint, context.platform);
21084
21130
  return withAccount(config, request.name, account);
21085
21131
  };
21086
21132
  return {
@@ -21096,7 +21142,7 @@ function addAccountChange(context, request, inspection) {
21096
21142
  };
21097
21143
  },
21098
21144
  apply: async (consent) => {
21099
- const { store, choosing } = secretsStoreFor(await context.config(), void 0);
21145
+ const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.platform);
21100
21146
  const secrets = await context.core.secrets(store);
21101
21147
  await secrets.set(account.secretRef, request.key.trim());
21102
21148
  let written;
@@ -21137,7 +21183,7 @@ function addAccountChange(context, request, inspection) {
21137
21183
  function removeAccountChange(context, name) {
21138
21184
  return {
21139
21185
  plan: (config) => {
21140
- const found = requireAccount(config, name);
21186
+ const found = requireAccount(config, name, context.platform);
21141
21187
  return {
21142
21188
  account: found.name,
21143
21189
  before: config,
@@ -21147,15 +21193,25 @@ function removeAccountChange(context, name) {
21147
21193
  };
21148
21194
  },
21149
21195
  apply: async (_consent, request) => {
21150
- const approved = requireAccount(request.before, name).account;
21196
+ const approved = requireAccount(request.before, name, context.platform).account;
21151
21197
  const removed = await withCredentialsLock(context.core.paths.configDir, async () => {
21152
- const found = requireAccount(await context.config(), name);
21153
- 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.` });
21154
21205
  const secrets = await context.secrets();
21155
21206
  await secrets.delete(found.account.secretRef).catch(() => false);
21156
21207
  await context.core.config.update((current) => {
21157
- 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.` });
21158
- 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);
21159
21215
  if (!held) return current;
21160
21216
  return withoutAccount(current, held.name);
21161
21217
  });
@@ -21207,7 +21263,7 @@ function reportOf(named, config) {
21207
21263
  }
21208
21264
  async function policyReport(context, name) {
21209
21265
  const config = await context.config();
21210
- return reportOf(requireAccount(config, name), config);
21266
+ return reportOf(requireAccount(config, name, context.platform), config);
21211
21267
  }
21212
21268
  /**
21213
21269
  * Why a policy report refuses the approval it is handed, in the words of the surface it came from, as core's
@@ -21249,7 +21305,7 @@ function policyChange(context, name, wanted) {
21249
21305
  ].filter(Boolean).join(", ");
21250
21306
  return {
21251
21307
  plan: (config) => {
21252
- const found = checked(requireAccount(config, name));
21308
+ const found = checked(requireAccount(config, name, context.platform));
21253
21309
  return {
21254
21310
  account: found.name,
21255
21311
  before: config,
@@ -21258,10 +21314,10 @@ function policyChange(context, name, wanted) {
21258
21314
  };
21259
21315
  },
21260
21316
  apply: async (consent, request) => {
21261
- const approved = requireAccount(request.before, name).account;
21317
+ const approved = requireAccount(request.before, name, context.platform).account;
21262
21318
  let written;
21263
21319
  const config = await context.core.config.update((current) => {
21264
- const held = accountById(current, approved.id);
21320
+ const held = accountById(current, approved.id, context.platform);
21265
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." });
21266
21322
  written = {
21267
21323
  name: held.name,
@@ -21308,7 +21364,17 @@ async function checkAccount(context, named, offline) {
21308
21364
  name: "key stored",
21309
21365
  ok: false,
21310
21366
  detail: "the key is missing from the secret store",
21311
- 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))}`
21312
21378
  });
21313
21379
  } catch (error) {
21314
21380
  checks.push({
@@ -22355,8 +22421,15 @@ async function ownRecord(context, named, approvalId) {
22355
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." });
22356
22422
  return record;
22357
22423
  }
22358
- function requireSendMode(named) {
22359
- 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.` });
22360
22433
  }
22361
22434
  /**
22362
22435
  * Whether the From domain may send, checked before an approval exists.
@@ -22423,8 +22496,12 @@ function expectationOf(message) {
22423
22496
  subject: message.subject
22424
22497
  };
22425
22498
  }
22426
- function describePolicy(effective, approvalId, flags) {
22427
- 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.`;
22428
22505
  return "Policy: chat — send only after the user approves this exact preview.";
22429
22506
  }
22430
22507
  function previewOf(options) {
@@ -22462,7 +22539,7 @@ function previewOf(options) {
22462
22539
  thread: message.inReplyTo ? `reply to ${message.inReplyTo}${message.references.length > 1 ? ` (${message.references.length} references)` : ""}` : void 0,
22463
22540
  links: options.built.links,
22464
22541
  warnings,
22465
- policy: describePolicy(options.effective, options.approvalId, options.flags)
22542
+ policy: describePolicy(options.effective, options.approvalId, options.flags, options.platform)
22466
22543
  });
22467
22544
  }
22468
22545
  async function attachPolicy(context) {
@@ -22480,10 +22557,17 @@ async function attachPolicy(context) {
22480
22557
  */
22481
22558
  async function prepareSend(context, name, input) {
22482
22559
  const named = await context.accounts.require(name);
22483
- requireSendMode(named);
22560
+ requireSendMode(named, context.platform);
22484
22561
  const config = await context.config();
22485
22562
  const livePolicy = named.account.sendPolicy ?? config.defaults.sendPolicy;
22486
- 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))}.` });
22487
22571
  const built = await buildMessage(input, {
22488
22572
  now: context.now(),
22489
22573
  attach: await attachPolicy(context)
@@ -22535,7 +22619,8 @@ async function prepareSend(context, name, input) {
22535
22619
  study,
22536
22620
  domainNote,
22537
22621
  effective,
22538
- flags: study.flags
22622
+ flags: study.flags,
22623
+ platform: context.platform
22539
22624
  }),
22540
22625
  policy: livePolicy,
22541
22626
  effectivePolicy: effective,
@@ -22543,7 +22628,11 @@ async function prepareSend(context, name, input) {
22543
22628
  reach,
22544
22629
  expect: record.expect,
22545
22630
  expiresAt: record.expiresAt,
22546
- 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."
22547
22636
  };
22548
22637
  }
22549
22638
  /** The stored message and its attachments' bytes, checked again: a file changed since the preview voids the send. */
@@ -22606,13 +22695,64 @@ function describeState(state) {
22606
22695
  switch (state) {
22607
22696
  case "used": return "this approval has already been used — the email was sent once, and is not sent again";
22608
22697
  case "sending": return "this approval is being sent by another process right now";
22609
- 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";
22610
22699
  case "unknown": return "a process stopped mid-send under this approval; whether the email went is not known";
22611
22700
  case "expired": return "this approval has expired";
22612
22701
  case "revoked": return "this approval was cancelled or voided";
22613
22702
  default: return `this approval is ${state}`;
22614
22703
  }
22615
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
+ }
22616
22756
  /**
22617
22757
  * Step three, and the only place mail leaves.
22618
22758
  *
@@ -22621,10 +22761,17 @@ function describeState(state) {
22621
22761
  */
22622
22762
  async function executeSend(context, name, options) {
22623
22763
  const named = await context.accounts.require(name);
22624
- requireSendMode(named);
22764
+ requireSendMode(named, context.platform);
22625
22765
  const known = await ownRecord(context, named, options.approvalId);
22626
22766
  if (known.state !== "pending" && known.state !== "approved") throw new CommsError("APPROVAL_VOID", `nothing was sent: ${describeState(known.state)}`, {
22627
- 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.",
22628
22775
  details: {
22629
22776
  approvalId: options.approvalId,
22630
22777
  state: known.state
@@ -22647,89 +22794,143 @@ async function executeSend(context, name, options) {
22647
22794
  inboxSub: named.account.userId,
22648
22795
  policy: livePolicy,
22649
22796
  expect: options.expect
22650
- }, { 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
+ });
22651
22805
  const records = new SendRecords(context.core.paths.stateDir, context.now);
22652
22806
  const message = prepared.message;
22653
22807
  const recipients = uniqueRecipients(message);
22808
+ const bookkeeping = {
22809
+ name,
22810
+ accountId: named.account.id,
22811
+ approvalId: options.approvalId
22812
+ };
22654
22813
  try {
22655
22814
  await records.reserve(named.account.id, options.approvalId, config.defaults.sendCaps);
22656
22815
  } catch (error) {
22657
- await context.core.approvals.complete(options.approvalId, { error: "rate capped" });
22658
- 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);
22659
22837
  }
22660
- await records.record(named.account.id, {
22661
- approvalId: options.approvalId,
22662
- event: "attempt",
22663
- recipients,
22664
- subject: message.subject,
22665
- scheduledAt: message.scheduledAt
22666
- });
22667
- await context.core.audit.append({
22668
- inboxId: named.account.id,
22669
- alias: name,
22670
- operation: "resend.send.execute",
22671
- outcome: "started",
22672
- surface: context.surface,
22673
- approvalId: options.approvalId,
22674
- recipients: recipients.map(canonicalAddress)
22675
- }, { durable: true });
22676
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
+ };
22677
22848
  try {
22678
- 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", {
22679
22850
  body: payloadOf(message, options.approvalId, contents),
22680
22851
  idempotencyKey: options.approvalId
22681
22852
  }));
22682
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" } });
22683
22854
  resendId = response.id;
22684
22855
  } catch (error) {
22856
+ if (!requestIssued) throw await recordNoSend(context, records, bookkeeping, error);
22685
22857
  const outcome = error instanceof CommsError && error.details?.outcome === "not-sent" ? "not-sent" : error instanceof CommsError && error.code === "SEND_REFUSED" ? "not-sent" : "unknown";
22686
22858
  const said = error instanceof Error ? error.message : String(error);
22687
- await context.core.approvals.complete(options.approvalId, { error: outcome === "unknown" ? `outcome unknown: ${said}` : said });
22688
- 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 {
22689
22907
  await records.record(named.account.id, {
22690
22908
  approvalId: options.approvalId,
22691
- event: outcome === "unknown" ? "unknown" : "failed",
22692
- error: said.slice(0, 300)
22909
+ event: "sent",
22910
+ resendId
22693
22911
  });
22912
+ } catch (error) {
22913
+ unrecorded.push(`the send record could not record it (${error instanceof Error ? error.message : String(error)})`);
22914
+ }
22915
+ try {
22694
22916
  await context.core.audit.append({
22695
22917
  inboxId: named.account.id,
22696
22918
  alias: name,
22697
22919
  operation: "resend.send.execute",
22698
- outcome: "failed",
22920
+ outcome: "ok",
22699
22921
  surface: context.surface,
22700
22922
  approvalId: options.approvalId,
22701
- 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(" · ")
22702
22930
  });
22703
- if (outcome === "unknown") throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
22704
- 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.`,
22705
- details: {
22706
- approvalId: options.approvalId,
22707
- outcome: "unknown"
22708
- }
22709
- });
22710
- throw error;
22931
+ } catch (error) {
22932
+ unrecorded.push(`the audit log could not record it (${error instanceof Error ? error.message : String(error)})`);
22711
22933
  }
22712
- await context.core.approvals.complete(options.approvalId, { sentMessageId: resendId });
22713
- await records.record(named.account.id, {
22714
- approvalId: options.approvalId,
22715
- event: "sent",
22716
- resendId
22717
- });
22718
- await context.core.audit.append({
22719
- inboxId: named.account.id,
22720
- alias: name,
22721
- operation: "resend.send.execute",
22722
- outcome: "ok",
22723
- surface: context.surface,
22724
- approvalId: options.approvalId,
22725
- ids: { resendIds: [resendId] },
22726
- recipients: [
22727
- ...claimed.expect.to,
22728
- ...claimed.expect.cc,
22729
- ...claimed.expect.bcc
22730
- ].map(canonicalAddress),
22731
- reason: `digest ${digest.slice(0, 12)} · policy ${livePolicy} · ${claimed.approvedVia ?? "chat"}`
22732
- });
22733
22934
  return {
22734
22935
  account: name,
22735
22936
  approvalId: options.approvalId,
@@ -22739,7 +22940,8 @@ async function executeSend(context, name, options) {
22739
22940
  to: claimed.expect.to,
22740
22941
  cc: claimed.expect.cc,
22741
22942
  bcc: claimed.expect.bcc,
22742
- subject: claimed.expect.subject
22943
+ subject: claimed.expect.subject,
22944
+ ...unrecorded.length > 0 ? { note: unrecorded.join("; ") } : {}
22743
22945
  };
22744
22946
  }
22745
22947
  /**
@@ -22835,7 +23037,7 @@ async function beginSendApproval(context, approvalId) {
22835
23037
  const config = await context.config();
22836
23038
  const study = await studyRecipients(context, named, prepared.message, config.defaults.riskEscalation);
22837
23039
  const effective = stricterPolicy(named.account.sendPolicy ?? config.defaults.sendPolicy, record.requiredPolicy);
22838
- const challenge = await context.core.approvals.issueChallenge(approvalId);
23040
+ const challenge = await context.core.approvals.issueChallenge(approvalId, "send", context.platform);
22839
23041
  return {
22840
23042
  approvalId,
22841
23043
  account: named.name,
@@ -22850,7 +23052,8 @@ async function beginSendApproval(context, approvalId) {
22850
23052
  study,
22851
23053
  domainNote: prepared.warnings[0] ?? "",
22852
23054
  effective,
22853
- flags: record.riskFlags
23055
+ flags: record.riskFlags,
23056
+ platform: context.platform
22854
23057
  }),
22855
23058
  challenge
22856
23059
  };
@@ -22861,7 +23064,7 @@ async function finishSendApproval(context, approvalId, answer) {
22861
23064
  const approved = await context.core.approvals.approve(approvalId, "terminal", {
22862
23065
  draftMessageId: digest,
22863
23066
  digest
22864
- }, answer);
23067
+ }, answer, "send", context.platform);
22865
23068
  await context.core.audit.append({
22866
23069
  inboxId: named.account.id,
22867
23070
  alias: named.name,
@@ -22887,4 +23090,4 @@ async function revokeSendApproval(context, approvalId) {
22887
23090
  });
22888
23091
  }
22889
23092
  //#endregion
22890
- 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 };