@agentcomms/resend 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ 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.17.0";
15
+ const VERSION$1 = "0.18.1";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -201,7 +201,7 @@ const RESEND_CALLER = Object.freeze({
201
201
  packageName: PACKAGE_NAME
202
202
  });
203
203
  //#endregion
204
- //#region ../core/dist/update-check-XMsGuXRl.mjs
204
+ //#region ../core/dist/update-check-DvZxdFU_.mjs
205
205
  /**
206
206
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
207
207
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -1386,6 +1386,24 @@ const CHANNEL_SNAPSHOT = [
1386
1386
  }
1387
1387
  }
1388
1388
  ];
1389
+ /** Every declared service server: what core knows about it without installing it. */
1390
+ const SERVICE_SNAPSHOT = [{
1391
+ packageName: "@agentcomms/events-daemon",
1392
+ declaration: {
1393
+ kind: "service",
1394
+ binary: "agent-events",
1395
+ server: {
1396
+ defaultName: "events",
1397
+ entry: "src/mcp/server.ts",
1398
+ factory: "createEventsMcpServer"
1399
+ },
1400
+ operations: "src/operations",
1401
+ skills: {
1402
+ prefix: "agentcomms-",
1403
+ contract: "skills/_shared/contract-events.md"
1404
+ }
1405
+ }
1406
+ }];
1389
1407
  const DISCLOSURE_VERSION_KINDS = [
1390
1408
  "rule",
1391
1409
  "target",
@@ -1575,8 +1593,8 @@ function bindingDigestOf(record) {
1575
1593
  * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
1576
1594
  * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
1577
1595
  * as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
1578
- * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
1579
- * has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
1596
+ * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A common line printed on
1597
+ * Windows has to be safe in both — and safe is not enough:
1580
1598
  * the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
1581
1599
  * hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
1582
1600
  * reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
@@ -1603,15 +1621,17 @@ function bindingDigestOf(record) {
1603
1621
  * backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
1604
1622
  * backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
1605
1623
  * which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
1606
- * - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
1624
+ * - With any other word in it, the common renderer has no line, and the printers show the command's words
1607
1625
  * as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
1608
1626
  * still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
1609
1627
  * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
1610
1628
  * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
1611
1629
  *
1612
- * - The program itself has to be left as it is. Quoted, it is a string to PowerShell rather than a command — PowerShell
1613
- * runs a quoted program only after `&`, which cmd.exe reads as the end of a command — so a program whose path needs
1614
- * quotes, as `C:\Program Files\nodejs\node.exe` does, leaves the command with no line either (CUE-403).
1630
+ * - The program itself has to be left as it is in the common form. Quoted, it is a string to PowerShell rather than a
1631
+ * command. When its path needs quoting, CUE-410 may emit a labelled PowerShell line: `&`, then every word in single
1632
+ * quotes, with an inner single quote doubled. Empty words, trailing backslashes, double quotes and control or
1633
+ * formatting characters retain the inert-JSON fallback because their native-program boundary cannot be proved
1634
+ * across the supported PowerShell versions.
1615
1635
  *
1616
1636
  * Everywhere else there is always a line: single quotes make any word safe.
1617
1637
  *
@@ -1627,9 +1647,23 @@ function quoteCommand(words, platform) {
1627
1647
  };
1628
1648
  const printed = words.map(windowsShellWord);
1629
1649
  const programBare = words.length === 0 || printed[0] === words[0];
1650
+ if (!printed.includes(null) && programBare) return {
1651
+ words,
1652
+ line: printed.join(" "),
1653
+ platform
1654
+ };
1655
+ if (!programBare && printed.slice(1).every((word) => word !== null)) {
1656
+ const powershell = words.map(powerShellWord);
1657
+ if (!powershell.includes(null)) return {
1658
+ words,
1659
+ line: `& ${powershell.join(" ")}`,
1660
+ platform,
1661
+ shell: "powershell"
1662
+ };
1663
+ }
1630
1664
  return {
1631
1665
  words,
1632
- line: printed.includes(null) || !programBare ? null : printed.join(" "),
1666
+ line: null,
1633
1667
  platform
1634
1668
  };
1635
1669
  }
@@ -1642,6 +1676,14 @@ function quotedLineWithWordsToFill(command, ...toFill) {
1642
1676
  if (command.line === null) return null;
1643
1677
  const sentinel = command.words.indexOf("--");
1644
1678
  if (sentinel < 0) return [command.line, ...toFill].join(" ");
1679
+ if (command.shell === "powershell") {
1680
+ const printed = command.words.map((each) => powerShellWord(each));
1681
+ return `& ${[
1682
+ ...printed.slice(0, sentinel),
1683
+ ...toFill,
1684
+ ...printed.slice(sentinel)
1685
+ ].join(" ")}`;
1686
+ }
1645
1687
  const word = command.platform === "win32" ? windowsShellWord : posixShellWord;
1646
1688
  const printed = command.words.map((each) => word(each));
1647
1689
  return [
@@ -1669,6 +1711,11 @@ function windowsShellWord(word) {
1669
1711
  if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
1670
1712
  return `"${word}"`;
1671
1713
  }
1714
+ /** One native-command word in a PowerShell single-quoted literal, or null when a one-line call is not provable. */
1715
+ function powerShellWord(word) {
1716
+ if (word === "" || word.endsWith("\\") || /["\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
1717
+ return `'${word.replaceAll("'", "''")}'`;
1718
+ }
1672
1719
  /**
1673
1720
  * The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
1674
1721
  * parse, and nothing to run.
@@ -1699,17 +1746,19 @@ function commandAsJson(words) {
1699
1746
  /** What is said beside a command shown as JSON. */
1700
1747
  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";
1701
1748
  /** What is said beside a command shown as JSON because its program's path needs quotes. */
1702
- const TO_TYPE_PROGRAM = "the command's words, written as JSON: the program's path needs quotes, and PowerShell runs a quoted program only after `&`, which cmd.exe does not accept, so type the command yourself, with each word quoted for the shell you use and, in PowerShell, `&` before the program";
1749
+ const TO_TYPE_PROGRAM = "the command's words, written as JSON: the program's path needs quoting and at least one word cannot be represented safely in a one-line PowerShell call, so type the command yourself, with each word quoted for the shell you use and, in PowerShell, `&` before the program";
1703
1750
  function toType(command) {
1704
1751
  return programNeedsQuotes(command) ? TO_TYPE_PROGRAM : TO_TYPE;
1705
1752
  }
1706
1753
  /** Quoted words in backticks, for a sentence — as words in JSON, saying it has to be typed, when there is no line. */
1707
1754
  function inlineQuoted(command) {
1708
- return command.line === null ? `\`${commandAsJson(command.words)}\` (${toType(command)})` : `\`${command.line}\``;
1755
+ if (command.line === null) return `\`${commandAsJson(command.words)}\` (${toType(command)})`;
1756
+ return `\`${command.line}\`${command.shell === "powershell" ? " (PowerShell)" : ""}`;
1709
1757
  }
1710
1758
  /** Quoted words as text of their own — a list's line, a field's value — or as words in JSON, saying to type them. */
1711
1759
  function quotedText(command) {
1712
- return command.line === null ? `${commandAsJson(command.words)} (${toType(command)})` : command.line;
1760
+ if (command.line === null) return `${commandAsJson(command.words)} (${toType(command)})`;
1761
+ return command.shell === "powershell" ? `PowerShell:\n${command.line}` : command.line;
1713
1762
  }
1714
1763
  /**
1715
1764
  * The same command with words the agent is to fill in — a placeholder such as `<folder>` — before its first `--`: for
@@ -2173,7 +2222,7 @@ function handoffSentence(handoff, say, options = {}) {
2173
2222
  function handoffSentenceToFill(handoff, toFill, say, options = {}) {
2174
2223
  if (!isCommand(handoff)) return noCommand(handoff, options);
2175
2224
  const line = lineWithWordsToFill(handoff, ...toFill);
2176
- if (line !== null) return say(`\`${line}\``);
2225
+ if (line !== null) return say(`\`${line}\`${handoff.shell === "powershell" ? " (PowerShell)" : ""}`);
2177
2226
  return say(inlineQuoted(quotedWithWordsToFill(handoff, toFill)));
2178
2227
  }
2179
2228
  /**
@@ -2393,7 +2442,7 @@ function nearestPackage(start) {
2393
2442
  * Whether `path` is `root` or inside it, by whole path segments: `/x/core-evil` is not inside `/x/core`, which a
2394
2443
  * comparison of strings would say it was. Both are taken as given; a caller compares real paths with real paths.
2395
2444
  */
2396
- function isWithin(root, path) {
2445
+ function isWithin$1(root, path) {
2397
2446
  const between = relative(resolve(root), resolve(path));
2398
2447
  return between === "" || !isAbsolute(between) && between !== ".." && !/^\.\.[\\/]/.test(between);
2399
2448
  }
@@ -2463,15 +2512,18 @@ var ExternalCommand = class {
2463
2512
  #reason;
2464
2513
  /** The words, as the program is to receive them. */
2465
2514
  words;
2466
- /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
2515
+ /** The line to paste; null when no supported common or labelled shell rendering is safe. */
2467
2516
  line;
2468
2517
  platform;
2518
+ shell;
2469
2519
  constructor(gate, words, platform, reason) {
2470
2520
  if (gate !== GATE$1) throw new TypeError("an external command is made by externalCommand, and only there");
2471
2521
  this.#reason = reason;
2472
2522
  this.words = Object.freeze([...words]);
2473
- this.line = quoteCommand(this.words, platform).line;
2523
+ const quoted = quoteCommand(this.words, platform);
2524
+ this.line = quoted.line;
2474
2525
  this.platform = platform;
2526
+ if (quoted.shell !== void 0) this.shell = quoted.shell;
2475
2527
  Object.freeze(this);
2476
2528
  }
2477
2529
  /** Why this program, which is not part of this suite, is one a person may be told to run. */
@@ -3185,6 +3237,343 @@ function commandLinePackage(command, args) {
3185
3237
  const line = [command, ...withoutPathOptions(args).words].join(" ");
3186
3238
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
3187
3239
  }
3240
+ const RECEIPT_VERSION = 1;
3241
+ const RECEIPT_FILE = "managed-shims.json";
3242
+ const MUTATION_LOCK_FILE = "managed-shims.lock";
3243
+ const POSIX_SHIM_MODE = 493;
3244
+ const WINDOWS_SHIM_NOTICE = "Managed CLI shims are not offered on Windows yet because cmd.exe re-parses arguments forwarded through .cmd files, including shell metacharacters. Use the full-path command or the labelled PowerShell & line the result provides.";
3245
+ function platformOf(context) {
3246
+ return context.platform ?? process.platform;
3247
+ }
3248
+ /** Why the selected platform has no managed shim in this release. */
3249
+ function managedShimUnsupportedNotice(context = {}) {
3250
+ return platformOf(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
3251
+ }
3252
+ /** The per-user directory managed installs put their command shims in. */
3253
+ function managedShimDirectory(context) {
3254
+ if (!managedShimsSupported(context)) throw new CommsError("CONFIG", managedShimUnsupportedNotice(context));
3255
+ return join(homeDirectory(context.env), ".local", "bin");
3256
+ }
3257
+ /** Managed CLI shims are POSIX-only until Windows has an argument-forwarding form that does not re-parse input. */
3258
+ function managedShimsSupported(context = {}) {
3259
+ const platform = platformOf(context);
3260
+ return platform === "darwin" || platform === "linux";
3261
+ }
3262
+ /** A managed shim's name: one plain command word, never a path, so joining it to the directory stays inside it. */
3263
+ function isSafeBinary(binary) {
3264
+ return /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(binary);
3265
+ }
3266
+ function checkedBinary(binary) {
3267
+ if (!isSafeBinary(binary)) throw new CommsError("CONFIG", `${JSON.stringify(binary)} is not a safe managed CLI binary name`);
3268
+ return binary;
3269
+ }
3270
+ /** The exact path for one declared binary's managed shim. */
3271
+ function managedShimPath(context, binary) {
3272
+ return join(managedShimDirectory(context), checkedBinary(binary));
3273
+ }
3274
+ function receiptPath(context) {
3275
+ return join(context.dataDir, RECEIPT_FILE);
3276
+ }
3277
+ function emptyReceipt() {
3278
+ return {
3279
+ version: RECEIPT_VERSION,
3280
+ shims: {}
3281
+ };
3282
+ }
3283
+ async function readReceipt(context) {
3284
+ let parsed;
3285
+ try {
3286
+ parsed = JSON.parse(await readFile(receiptPath(context), "utf8"));
3287
+ } catch (error) {
3288
+ return error.code === "ENOENT" ? emptyReceipt() : null;
3289
+ }
3290
+ if (typeof parsed !== "object" || parsed === null || parsed.version !== RECEIPT_VERSION || typeof parsed.shims !== "object" || parsed.shims === null) return null;
3291
+ const shims = {};
3292
+ for (const [binary, value] of Object.entries(parsed.shims)) {
3293
+ if (!isSafeBinary(binary) || typeof value !== "object" || value === null || ![
3294
+ "path",
3295
+ "digest",
3296
+ "node",
3297
+ "entry"
3298
+ ].every((key) => typeof value[key] === "string")) return null;
3299
+ shims[binary] = value;
3300
+ }
3301
+ return {
3302
+ version: RECEIPT_VERSION,
3303
+ shims
3304
+ };
3305
+ }
3306
+ async function writeReceipt(context, receipt) {
3307
+ await writeFileAtomic(receiptPath(context), `${JSON.stringify(receipt, null, 2)}\n`);
3308
+ }
3309
+ function sha256(bytes) {
3310
+ return createHash("sha256").update(bytes).digest("hex");
3311
+ }
3312
+ function posixWord(word) {
3313
+ return `'${word.replaceAll("'", `'"'"'`)}'`;
3314
+ }
3315
+ function shimSource(spec) {
3316
+ return `#!/bin/sh\n# managed CLI shim v1\nexec ${spec.command.map(posixWord).join(" ")} "$@"\n`;
3317
+ }
3318
+ async function pinShimDirectory(context, create) {
3319
+ const publicDirectory = managedShimDirectory(context);
3320
+ if (create) await mkdir(publicDirectory, {
3321
+ recursive: true,
3322
+ mode: 493
3323
+ });
3324
+ let resolved;
3325
+ try {
3326
+ resolved = await realpath(publicDirectory);
3327
+ } catch (error) {
3328
+ if (!create && error.code === "ENOENT") return null;
3329
+ throw error;
3330
+ }
3331
+ return {
3332
+ public: publicDirectory,
3333
+ resolved
3334
+ };
3335
+ }
3336
+ async function assertPinnedDirectory(directory) {
3337
+ let now;
3338
+ try {
3339
+ now = await realpath(directory.public);
3340
+ } catch {
3341
+ throw new CommsError("CONFIG", `the managed shim directory's resolved directory changed from ${directory.resolved} during the operation`, { hint: "Nothing was changed. Inspect the directory link, then run the install, update or prune again." });
3342
+ }
3343
+ if (now !== directory.resolved) throw new CommsError("CONFIG", `the managed shim directory's resolved directory changed from ${directory.resolved} to ${now} during the operation`, { hint: "Nothing was changed. Inspect the directory link, then run the install, update or prune again." });
3344
+ }
3345
+ async function regularFileBytes(path) {
3346
+ try {
3347
+ if (!(await lstat(path)).isFile()) return null;
3348
+ return await readFile(path, "utf8");
3349
+ } catch (error) {
3350
+ if (error.code === "ENOENT") return null;
3351
+ throw error;
3352
+ }
3353
+ }
3354
+ async function pathExists(path) {
3355
+ try {
3356
+ await lstat(path);
3357
+ return true;
3358
+ } catch (error) {
3359
+ if (error.code === "ENOENT") return false;
3360
+ throw error;
3361
+ }
3362
+ }
3363
+ /** Serialize this installation's receipt and shim mutations across its processes. */
3364
+ async function withMutationLock(context, change) {
3365
+ await mkdir(context.dataDir, {
3366
+ recursive: true,
3367
+ mode: 448
3368
+ });
3369
+ return withFileLock(join(context.dataDir, MUTATION_LOCK_FILE), change, {
3370
+ timeoutMs: 5e3,
3371
+ staleMs: 3e4,
3372
+ renewMs: 1e4,
3373
+ ...context.lockOptions
3374
+ });
3375
+ }
3376
+ /** Publish complete shim bytes only if the target is still absent. */
3377
+ async function createShimExclusive(directory, path, source) {
3378
+ const nonce = randomBytes(6).toString("hex");
3379
+ const temp = join(directory.resolved, `.${basename(path)}.${nonce}.tmp`);
3380
+ await assertPinnedDirectory(directory);
3381
+ const handle = await open(temp, "wx", POSIX_SHIM_MODE);
3382
+ try {
3383
+ await handle.writeFile(source);
3384
+ await handle.sync();
3385
+ } finally {
3386
+ await handle.close();
3387
+ }
3388
+ await assertPinnedDirectory(directory);
3389
+ await chmod(temp, POSIX_SHIM_MODE);
3390
+ try {
3391
+ await assertPinnedDirectory(directory);
3392
+ await link(temp, path);
3393
+ } finally {
3394
+ await assertPinnedDirectory(directory);
3395
+ await rm(temp, { force: true });
3396
+ }
3397
+ }
3398
+ /** Restore an object moved aside without ever replacing a raced object at its public path. */
3399
+ async function restoreMoved(directory, path, moved) {
3400
+ try {
3401
+ await assertPinnedDirectory(directory);
3402
+ await link(moved, path);
3403
+ } catch (error) {
3404
+ if (error.code === "EEXIST") return false;
3405
+ throw error;
3406
+ }
3407
+ await assertPinnedDirectory(directory);
3408
+ await rm(moved);
3409
+ return true;
3410
+ }
3411
+ /**
3412
+ * Move the exact current object aside atomically, then prove that object still has the owned digest. A raced foreign
3413
+ * file is put back; if another object has already occupied its path, both are preserved and the change is refused.
3414
+ */
3415
+ async function moveOwned(directory, path, digest, suffix, before) {
3416
+ await before?.(path);
3417
+ await assertPinnedDirectory(directory);
3418
+ const moved = join(directory.resolved, `.${basename(path)}.${randomBytes(6).toString("hex")}.${suffix}`);
3419
+ try {
3420
+ await rename(path, moved);
3421
+ } catch (error) {
3422
+ if (error.code === "ENOENT") return null;
3423
+ throw error;
3424
+ }
3425
+ const bytes = await regularFileBytes(moved);
3426
+ if (bytes !== null && sha256(bytes) === digest) return moved;
3427
+ if (!await restoreMoved(directory, path, moved)) throw new CommsError("CONFIG", `${path} changed during a managed shim operation; its bytes were preserved at ${moved}`, { hint: "Resolve the two files by hand, then run the install, update or prune again." });
3428
+ return null;
3429
+ }
3430
+ /** Remove only the exact object whose bytes prove it is still owned. */
3431
+ async function removeOwned(directory, path, digest, before) {
3432
+ const moved = await moveOwned(directory, path, digest, "remove", before);
3433
+ if (moved === null) return false;
3434
+ await assertPinnedDirectory(directory);
3435
+ await rm(moved);
3436
+ return true;
3437
+ }
3438
+ function normalPath(path, platform) {
3439
+ const trimmed = path.replace(/[\\/]+$/, "");
3440
+ return platform === "win32" ? trimmed.toLowerCase() : trimmed;
3441
+ }
3442
+ function directoryOnPath(context, directory) {
3443
+ const platform = platformOf(context);
3444
+ const value = context.env.PATH ?? context.env.Path ?? "";
3445
+ const separator = platform === "win32" ? ";" : ":";
3446
+ return value.split(separator).filter(Boolean).some((part) => normalPath(part, platform) === normalPath(directory, platform));
3447
+ }
3448
+ function pathNotice(directory) {
3449
+ return `The shim directory ${directory} is not on PATH. For this shell, run: export PATH=${posixWord(directory)}:"$PATH". No shell profile was edited.`;
3450
+ }
3451
+ /** Create or repoint one shim, but only while its target is free or still matches this installation's receipt. */
3452
+ async function writeManagedShim(context, spec) {
3453
+ checkedBinary(spec.binary);
3454
+ const directory = await pinShimDirectory(context, true);
3455
+ if (directory === null) throw new CommsError("UNEXPECTED", "the managed shim directory was not created");
3456
+ const publicPath = join(directory.public, spec.binary);
3457
+ const path = join(directory.resolved, spec.binary);
3458
+ const result = (status) => {
3459
+ const onPath = directoryOnPath(context, directory.public);
3460
+ return {
3461
+ path: publicPath,
3462
+ directory: directory.public,
3463
+ status,
3464
+ onPath,
3465
+ ...onPath ? {} : { pathNotice: pathNotice(directory.public) }
3466
+ };
3467
+ };
3468
+ return withMutationLock(context, async () => {
3469
+ const receipt = await readReceipt(context);
3470
+ const recorded = receipt?.shims[spec.binary];
3471
+ const current = await regularFileBytes(path);
3472
+ const owned = receipt !== null && recorded !== void 0 && recorded.path === path && current !== null && sha256(current) === recorded.digest;
3473
+ if (await pathExists(path) && !owned) return result("foreign");
3474
+ if (receipt === null || recorded !== void 0 && recorded.path !== path) return result("foreign");
3475
+ const node = spec.command[0];
3476
+ if (node === void 0) throw new CommsError("CONFIG", "the managed shim command has no program");
3477
+ const source = shimSource(spec);
3478
+ const digest = sha256(source);
3479
+ let previous = null;
3480
+ if (owned) {
3481
+ previous = await moveOwned(directory, path, recorded.digest, "old", context.hooks?.beforeReplace);
3482
+ if (previous === null) return result("foreign");
3483
+ }
3484
+ try {
3485
+ await createShimExclusive(directory, path, source);
3486
+ } catch (error) {
3487
+ if (previous !== null) {
3488
+ if (!await restoreMoved(directory, path, previous)) throw new CommsError("CONFIG", `${path} was occupied while its owned shim was being updated; the old shim remains at ${previous}`, {
3489
+ hint: "Resolve the two files by hand, then run the managed install again.",
3490
+ cause: error
3491
+ });
3492
+ }
3493
+ if (error.code === "EEXIST") return result("foreign");
3494
+ throw error;
3495
+ }
3496
+ receipt.shims[spec.binary] = {
3497
+ path,
3498
+ digest,
3499
+ node,
3500
+ entry: spec.entry
3501
+ };
3502
+ try {
3503
+ await assertPinnedDirectory(directory);
3504
+ await writeReceipt(context, receipt);
3505
+ } catch (error) {
3506
+ const removed = await removeOwned(directory, path, digest);
3507
+ if (previous !== null && removed && !await restoreMoved(directory, path, previous)) throw new CommsError("CONFIG", `the shim receipt failed and the old shim could not be restored from ${previous}`, {
3508
+ hint: "Restore that file to the reported shim path, then run the managed install again.",
3509
+ cause: error
3510
+ });
3511
+ throw error;
3512
+ }
3513
+ if (previous !== null) {
3514
+ await assertPinnedDirectory(directory);
3515
+ await rm(previous);
3516
+ }
3517
+ return result(owned ? "updated" : "created");
3518
+ });
3519
+ }
3520
+ function isWithin(root, path) {
3521
+ const child = relative(root, path);
3522
+ return child === "" || !child.startsWith("..") && !isAbsolute(child);
3523
+ }
3524
+ /** Remove unchanged owned shims whose recorded entry is inside one of the runtimes prune removed. */
3525
+ async function removeManagedShimsForRuntimes(context, runtimes, options = {}) {
3526
+ if (!managedShimsSupported(context)) return {
3527
+ removed: [],
3528
+ kept: []
3529
+ };
3530
+ const directory = await pinShimDirectory(context, false);
3531
+ if (directory === null) return {
3532
+ removed: [],
3533
+ kept: []
3534
+ };
3535
+ return withMutationLock(context, async () => {
3536
+ const receipt = await readReceipt(context);
3537
+ if (receipt === null) return {
3538
+ removed: [],
3539
+ kept: []
3540
+ };
3541
+ const removed = [];
3542
+ const kept = [];
3543
+ let changed = false;
3544
+ for (const [binary, recorded] of Object.entries(receipt.shims)) {
3545
+ if (!runtimes.some((runtime) => isWithin(runtime, recorded.entry))) continue;
3546
+ const publicPath = join(directory.public, binary);
3547
+ const path = join(directory.resolved, binary);
3548
+ if (recorded.path !== path) {
3549
+ kept.push(recorded.path);
3550
+ continue;
3551
+ }
3552
+ if (options.dryRun) {
3553
+ const bytes = await regularFileBytes(path);
3554
+ if (bytes !== null && sha256(bytes) === recorded.digest) removed.push(publicPath);
3555
+ else if (await pathExists(path)) kept.push(publicPath);
3556
+ continue;
3557
+ }
3558
+ if (await removeOwned(directory, path, recorded.digest, context.hooks?.beforeRemove)) {
3559
+ delete receipt.shims[binary];
3560
+ changed = true;
3561
+ removed.push(publicPath);
3562
+ } else if (!await pathExists(path)) {
3563
+ delete receipt.shims[binary];
3564
+ changed = true;
3565
+ } else kept.push(publicPath);
3566
+ }
3567
+ if (changed) {
3568
+ await assertPinnedDirectory(directory);
3569
+ await writeReceipt(context, receipt);
3570
+ }
3571
+ return {
3572
+ removed,
3573
+ kept
3574
+ };
3575
+ });
3576
+ }
3188
3577
  /**
3189
3578
  * The environment a child process is started with: `env` as it is, and on Windows also
3190
3579
  * `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
@@ -4269,8 +4658,8 @@ async function mergeIntoJsonConfig(path, client, name, entry) {
4269
4658
  }
4270
4659
  /** Starts the server exactly as a client would, and completes the handshake. */
4271
4660
  async function verifyEntry(entry, product) {
4272
- const { Client } = await import("./dist-COPefvtq-COGS2a-K.mjs");
4273
- const { StdioClientTransport } = await import("./stdio-CHZ2WRcx-B2_wGdA5.mjs");
4661
+ const { Client } = await import("./dist-C7DG2LdL-l0mj9_dH.mjs");
4662
+ const { StdioClientTransport } = await import("./stdio-CyjIgrch-59zcjSEt.mjs");
4274
4663
  const client = new Client({
4275
4664
  name: `${product.binary}-install-check`,
4276
4665
  version: product.version
@@ -4325,6 +4714,14 @@ function commandLinesFrom(ps) {
4325
4714
  });
4326
4715
  }
4327
4716
  const RUNTIME_NAME = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
4717
+ /** Whether a runtime's own manifest, the one the installer writes, names this package as what it installs. */
4718
+ async function pinsPackage(runtime, packageName) {
4719
+ try {
4720
+ return typeof JSON.parse(await readFile(join(runtime, "package.json"), "utf8")).dependencies?.[packageName] === "string";
4721
+ } catch {
4722
+ return false;
4723
+ }
4724
+ }
4328
4725
  /**
4329
4726
  * This product's managed runtimes on disk, oldest name first: every directory directly inside `<data>/runtime`,
4330
4727
  * named like a version, that holds this product's package — never a symbolic link, never another product's runtime,
@@ -4353,6 +4750,7 @@ async function listManagedRuntimes(dataDir, packageName) {
4353
4750
  } catch {
4354
4751
  continue;
4355
4752
  }
4753
+ if (!name.endsWith(suffix) && !await pinsPackage(path, packageName)) continue;
4356
4754
  found.push({
4357
4755
  path,
4358
4756
  version: name.endsWith(suffix) ? name.slice(0, -suffix.length) : name
@@ -4395,7 +4793,11 @@ async function pruneManagedRuntimes(context, product, options = {}) {
4395
4793
  runtimeDir: join(context.core.paths.dataDir, "runtime"),
4396
4794
  dryRun: options.dryRun === true,
4397
4795
  removed: [],
4398
- kept: []
4796
+ kept: [],
4797
+ shims: {
4798
+ removed: [],
4799
+ kept: []
4800
+ }
4399
4801
  };
4400
4802
  const candidates = [];
4401
4803
  for (const { path, version } of await listManagedRuntimes(context.core.paths.dataDir, product.packageName)) {
@@ -4462,6 +4864,11 @@ async function pruneManagedRuntimes(context, product, options = {}) {
4462
4864
  const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
4463
4865
  if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
4464
4866
  }
4867
+ if (result.removed.length > 0) result.shims = await removeManagedShimsForRuntimes({
4868
+ env: context.env,
4869
+ dataDir: context.core.paths.dataDir,
4870
+ platform: context.platform
4871
+ }, result.removed.map((item) => item.path), { dryRun: result.dryRun });
4465
4872
  return result;
4466
4873
  }
4467
4874
  /** Every runtime the installer handed out, or null when the record is there and cannot be read. */
@@ -4594,6 +5001,8 @@ function rivalWordWarnings(servers, word, can, product) {
4594
5001
  }
4595
5002
  /** Every channel in core's snapshot, the core first. */
4596
5003
  const CHANNELS = Object.freeze(CHANNEL_SNAPSHOT.map((entry) => entry.manifest.channel));
5004
+ /** Every declared MCP server, channels first and then services, with no separate hand-kept list. */
5005
+ const SERVERS = Object.freeze([...CHANNELS, ...SERVICE_SNAPSHOT.map((entry) => entry.declaration.server.defaultName)]);
4597
5006
  /**
4598
5007
  * The value a pin's flag carries in an entry's arguments: `--account acme/resend`, or `--account=acme/resend` — the
4599
5008
  * first given, in either form.
@@ -4654,8 +5063,28 @@ function serverFactsOf({ packageName, manifest }) {
4654
5063
  };
4655
5064
  }
4656
5065
  const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
5066
+ /** Service server facts, derived from each service package's declaration. */
5067
+ const SERVICE_SERVERS = Object.freeze(Object.fromEntries(SERVICE_SNAPSHOT.map(({ packageName, declaration }) => [declaration.server.defaultName, {
5068
+ packageName,
5069
+ binary: declaration.binary,
5070
+ defaultServerName: declaration.server.defaultName,
5071
+ npxPackage: packageName,
5072
+ npxArgs: ["mcp"],
5073
+ serverArgs: () => [],
5074
+ narrowingOf: () => ({})
5075
+ }])));
5076
+ /** All managed servers, derived from channel manifests and service declarations. */
5077
+ const SERVERS_BY_ID = Object.freeze({
5078
+ ...CHANNEL_SERVERS,
5079
+ ...SERVICE_SERVERS
5080
+ });
4657
5081
  /** How each server is named to a person: in a preview, and in what a tool returns. */
4658
5082
  const CHANNEL_LABELS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, entry.manifest.label])));
5083
+ /** Labels for every managed server; a service declares its public server name. */
5084
+ const SERVER_LABELS = Object.freeze({
5085
+ ...CHANNEL_LABELS,
5086
+ ...Object.fromEntries(SERVICE_SNAPSHOT.map(({ declaration }) => [declaration.server.defaultName, declaration.server.defaultName]))
5087
+ });
4659
5088
  /** A channel's manifest, from core's snapshot. */
4660
5089
  function channelManifest(channel) {
4661
5090
  return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest;
@@ -4663,10 +5092,18 @@ function channelManifest(channel) {
4663
5092
  function isChannel(value) {
4664
5093
  return typeof value === "string" && CHANNELS.includes(value);
4665
5094
  }
5095
+ /** Whether a word identifies any channel or service server in this release. */
5096
+ function isServer(value) {
5097
+ return typeof value === "string" && SERVERS.includes(value);
5098
+ }
4666
5099
  /** Refuses a word that is not a channel of this release, naming the ones that are. */
4667
5100
  function notAChannel(value) {
4668
5101
  return new CommsError("USAGE", `"${String(value)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
4669
5102
  }
5103
+ /** Refuses a word that is not a declared server of this release. */
5104
+ function notAServer(value) {
5105
+ return new CommsError("USAGE", `"${String(value)}" is not a server`, { hint: `One of: ${SERVERS.join(", ")}.` });
5106
+ }
4670
5107
  /** A channel's server facts, by its word; refused for a word that is not a channel. */
4671
5108
  function channelServer(channel) {
4672
5109
  if (!isChannel(channel)) throw notAChannel(channel);
@@ -4677,6 +5114,16 @@ function channelLabel(channel) {
4677
5114
  if (!isChannel(channel)) throw notAChannel(channel);
4678
5115
  return CHANNEL_LABELS[channel];
4679
5116
  }
5117
+ /** A channel or service's server facts, by its declared server name. */
5118
+ function serverFacts(server) {
5119
+ if (!isServer(server)) throw notAServer(server);
5120
+ return SERVERS_BY_ID[server];
5121
+ }
5122
+ /** How a channel or service server is named to a person. */
5123
+ function serverLabel(server) {
5124
+ if (!isServer(server)) throw notAServer(server);
5125
+ return SERVER_LABELS[server];
5126
+ }
4680
5127
  /** A channel's manifest, by its word; refused for a word that is not a channel. */
4681
5128
  function requireChannelManifest(channel) {
4682
5129
  const manifest = channelManifest(channel);
@@ -9643,7 +10090,7 @@ function folding(platform) {
9643
10090
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
9644
10091
  }
9645
10092
  /** The package version, read from package.json at build time. */
9646
- const VERSION = "0.17.0";
10093
+ const VERSION = "0.18.1";
9647
10094
  /**
9648
10095
  * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
9649
10096
  * to decide whether the release it last heard of is newer than the one running.
@@ -9779,15 +10226,18 @@ var PrintedCommand = class {
9779
10226
  #entry;
9780
10227
  /** The words, as the program is to receive them: this Node, its flags, the entry, then the CLI's own words. */
9781
10228
  words;
9782
- /** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
10229
+ /** The line to paste; null when no supported common or labelled shell rendering is safe. */
9783
10230
  line;
9784
10231
  platform;
10232
+ shell;
9785
10233
  constructor(gate, words, platform, entry) {
9786
10234
  if (gate !== GATE) throw new TypeError("a printed command is made by locateCliCommand, and only there");
9787
10235
  this.#entry = entry;
9788
10236
  this.words = Object.freeze([...words]);
9789
- this.line = quoteCommand(this.words, platform).line;
10237
+ const quoted = quoteCommand(this.words, platform);
10238
+ this.line = quoted.line;
9790
10239
  this.platform = platform;
10240
+ if (quoted.shell !== void 0) this.shell = quoted.shell;
9791
10241
  Object.freeze(this);
9792
10242
  }
9793
10243
  /**
@@ -9814,7 +10264,7 @@ const STRIP_TYPES = "--experimental-strip-types";
9814
10264
  const TRANSFORM_TYPES = "--experimental-transform-types";
9815
10265
  /** The suite's packages, by name, and its channel packages, which reach core through their own dependency on it. */
9816
10266
  const SUITE_PACKAGES = new Set(CHANNEL_SNAPSHOT.map(({ packageName }) => packageName));
9817
- const CHANNEL_PACKAGES$1 = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
10267
+ const CHANNEL_PACKAGES = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
9818
10268
  /**
9819
10269
  * Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
9820
10270
  *
@@ -9897,7 +10347,7 @@ function locateCliCommand(request, runtime = {
9897
10347
  sameCheckout: true
9898
10348
  });
9899
10349
  }
9900
- if (target.manifest.channel === "core" && CHANNEL_PACKAGES$1.has(caller.packageName)) {
10350
+ if (target.manifest.channel === "core" && CHANNEL_PACKAGES.has(caller.packageName)) {
9901
10351
  const installed = installedPackage(dirname(callerFile), target.packageName);
9902
10352
  if (installed === null) return fail("core", `${caller.packageName} has no ${target.packageName} installed where it can find one`);
9903
10353
  const manifest = readPackageManifest(installed);
@@ -9925,7 +10375,9 @@ function locateCliCommand(request, runtime = {
9925
10375
  }
9926
10376
  if (!SUITE_PACKAGES.has(caller.packageName)) return fail("direction", `${caller.packageName} is not a package of this suite`);
9927
10377
  if (request.registrations === void 0) return fail("no-registrations", "nothing registered was given to look through for another product's command");
9928
- const facts = registrationFactsOf(target);
10378
+ const channelTarget = CHANNEL_SNAPSHOT.find((entry) => entry.packageName === target.packageName);
10379
+ if (channelTarget === void 0) return fail("direction", `${target.packageName} is not a channel package this locator can find through a registration`);
10380
+ const facts = registrationFactsOf(channelTarget);
9929
10381
  const notUsed = [];
9930
10382
  const usable = [];
9931
10383
  let engineRange;
@@ -10023,8 +10475,7 @@ function buildCommand(target, found, words, runtime, callerSource, platform) {
10023
10475
  let entry;
10024
10476
  if (found.source) entry = join(found.root, "src", "cli.ts");
10025
10477
  else {
10026
- const declared = found.manifest.agentcomms;
10027
- const binary = typeof declared === "object" && declared !== null ? declared.binary : void 0;
10478
+ const binary = declaredBinary(found.manifest);
10028
10479
  const bin = found.manifest.bin;
10029
10480
  const path = binary === target.manifest.binary && typeof bin === "object" && bin !== null && Object.hasOwn(bin, target.manifest.binary) ? bin[target.manifest.binary] : void 0;
10030
10481
  if (typeof path !== "string" || path.length === 0) return {
@@ -10053,6 +10504,16 @@ function buildCommand(target, found, words, runtime, callerSource, platform) {
10053
10504
  };
10054
10505
  }
10055
10506
  /**
10507
+ * The command a package declares for itself: a channel's in its `agentcomms` field, a service's in `agentcommsPackage`
10508
+ * (D14). A library declares none, and no package declares both: the registry refuses one that does.
10509
+ */
10510
+ function declaredBinary(manifest) {
10511
+ const channel = manifest.agentcomms;
10512
+ if (typeof channel === "object" && channel !== null) return channel.binary;
10513
+ const service = manifest.agentcommsPackage;
10514
+ if (typeof service === "object" && service !== null && service.kind === "service") return service.binary;
10515
+ }
10516
+ /**
10056
10517
  * The target's package a registration's files lead to, or null: a script it starts, real or as written when it is
10057
10518
  * gone, inside the package (or inside the product's own server wrapper, which depends on it); or, for the product's
10058
10519
  * own command, the package that command links into or that npm installed beside it.
@@ -10184,7 +10645,7 @@ function notLocated(target, version, reason, detail, nodeVersion, extra) {
10184
10645
  * anywhere else for the product, PATH included, and a later locate checks the path again.
10185
10646
  */
10186
10647
  function checkedEntry(root, entry) {
10187
- if (!isWithin(root, entry)) return {
10648
+ if (!isWithin$1(root, entry)) return {
10188
10649
  ok: false,
10189
10650
  why: `${entry} is outside its package, ${root}`
10190
10651
  };
@@ -10197,7 +10658,7 @@ function checkedEntry(root, entry) {
10197
10658
  why: `${entry} is not there`
10198
10659
  };
10199
10660
  }
10200
- if (!isWithin(root, real)) return {
10661
+ if (!isWithin$1(root, real)) return {
10201
10662
  ok: false,
10202
10663
  why: `${entry} leads to ${real}, outside its package, ${root}`
10203
10664
  };
@@ -10611,6 +11072,10 @@ function renderInstall(result, color) {
10611
11072
  lines.push(paint(color, "bold", `Add this to ${result.configPath ?? `the MCP configuration of ${result.client}`}:`), result.snippet.trimEnd());
10612
11073
  }
10613
11074
  if (result.backupPath) lines.push(`The entry it replaced was saved to ${result.backupPath}.`);
11075
+ if (result.shim && result.shim.status !== "foreign") lines.push(`CLI shim ${result.shim.status}: ${result.shim.path}.`);
11076
+ if (result.shimFailure) lines.push(paint(color, "yellow", `CLI shim failed: ${result.shimFailure}.`));
11077
+ if (result.shimNotice) lines.push(paint(color, "yellow", result.shimNotice));
11078
+ if (result.shim?.pathNotice) lines.push(result.shim.pathNotice);
10614
11079
  if (result.verification === "passed") lines.push(paint(color, "green", `Checked: ${result.verifyDetail}`));
10615
11080
  else if (result.verification === "failed") {
10616
11081
  lines.push(paint(color, "red", `Failed to start: ${result.verifyDetail ?? "no reason given"}`));
@@ -10625,6 +11090,8 @@ function renderPrune(result, color) {
10625
11090
  if (result.refused) lines.push(paint(color, "yellow", `Nothing removed: ${result.refused}.`));
10626
11091
  const verb = result.dryRun ? "Would remove" : "Removed";
10627
11092
  for (const item of result.removed) lines.push(paint(color, "green", `${verb} ${item.version}: ${item.path}`));
11093
+ for (const path of result.shims.removed) lines.push(paint(color, "green", `${verb} CLI shim: ${path}`));
11094
+ for (const path of result.shims.kept) lines.push(`Kept foreign CLI shim: ${path}`);
10628
11095
  for (const item of result.kept) lines.push(`Kept ${item.version} (${item.reason}): ${item.path}`);
10629
11096
  if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
10630
11097
  return lines.join("\n");
@@ -10636,7 +11103,7 @@ function describeItem(item) {
10636
11103
  const where = item.scope === "project" ? `for a project, in ${item.path}` : `in ${item.path}`;
10637
11104
  const pins = item.narrowing.length > 0 ? `, ${item.narrowing.join(" ")}` : "";
10638
11105
  const version = item.version === null ? "pins no release" : `${item.version}${item.version === item.latest ? "" : ` → ${item.latest}`}`;
10639
- return `${channelLabel(item.channel)} with ${item.client} as "${item.name}" ${where} (${item.launcher}, ${version}${pins})`;
11106
+ return `${serverLabel(item.channel)} with ${item.client} as "${item.name}" ${where} (${item.launcher}, ${version}${pins})`;
10640
11107
  }
10641
11108
  function renderUpdate(result, color) {
10642
11109
  const lines = [];
@@ -10648,6 +11115,8 @@ function renderUpdate(result, color) {
10648
11115
  if (step.verification === "passed") lines.push(` Checked: ${step.detail}`);
10649
11116
  else if (step.verification === "failed") lines.push(paint(color, "red", ` Failed to start: ${step.detail}`));
10650
11117
  else lines.push(paint(color, "yellow", ` Not checked: ${step.detail ?? "skipped"}`));
11118
+ if (step.shim && step.shim.status !== "foreign") lines.push(` CLI shim ${step.shim.status}: ${step.shim.path}.`);
11119
+ if (step.shim?.pathNotice) lines.push(` ${step.shim.pathNotice}`);
10651
11120
  for (const warning of step.warnings ?? []) lines.push(` ${warning}`);
10652
11121
  } else lines.push(paint(color, "red", `${step.outcome === "skipped" ? "Skipped" : "Could not register"} ${what}: ${step.detail}`));
10653
11122
  } else lines.push(step.outcome === "updated" ? paint(color, "green", `Updated the global ${step.package} from ${step.from} to ${step.to}.`) : paint(color, "red", `Could not update the global ${step.package}: ${step.detail ?? "no reason given"}`));
@@ -12052,10 +12521,10 @@ async function npmInstallGlobal(env, spec) {
12052
12521
  }
12053
12522
  }
12054
12523
  /**
12055
- * Registering, pruning and listing the MCP servers of every channel, from the core.
12524
+ * Registering, pruning and listing every declared channel or service MCP server, from the core.
12056
12525
  *
12057
- * `agentcomms mcp install` registers the core server; `comms_server_install` registers any channel's; and
12058
- * `agent-gmail mcp install` and `agent-slack mcp install` register their own. All four are the one change here, over
12526
+ * `agentcomms mcp install` registers core or a declared service; `comms_server_install` registers any declared
12527
+ * server; and channel CLIs register their own. They are the one change here, over
12059
12528
  * the shared installer (`mcp-install.ts`), and all go through the change flow: registering a server is a loosening in
12060
12529
  * the design's terms (§3.1) — it hands a client a new set of tools — and so is asked for once and applied on the
12061
12530
  * second call with the approval. Printing an entry registers nothing and asks nobody.
@@ -12066,7 +12535,7 @@ async function npmInstallGlobal(env, spec) {
12066
12535
  * the other — or worse, a channel's own command would register with no approval at all, which is what it did.
12067
12536
  *
12068
12537
  * The version installed is this core's own, or the calling channel's when it passes its product. The packages are
12069
- * released together, at one version, so the core that is asked to install Gmail installs the Gmail of its own release.
12538
+ * released together, at one version, so the core asked to install Gmail or events installs its own release.
12070
12539
  */
12071
12540
  const CLIENTS = Object.freeze([
12072
12541
  "claude-code",
@@ -12107,10 +12576,10 @@ const exists = (path) => access(path, constants.R_OK).then(() => true, () => fal
12107
12576
  * core depends on none of them. So the channel is looked for beside this core — `packages/gmail` next to
12108
12577
  * `packages/core` in a checkout — and from source when this core runs from source, as the channel CLIs do.
12109
12578
  */
12110
- async function localModuleUrl(channel) {
12579
+ async function localModuleUrl(server) {
12111
12580
  const coreRoot = await corePackageRoot();
12112
- const unscopedName = channelServer(channel).packageName.split("/").at(-1) ?? channel;
12113
- const root = channel === "core" ? coreRoot : join(dirname(coreRoot), unscopedName);
12581
+ const unscopedName = serverFacts(server).packageName.split("/").at(-1) ?? server;
12582
+ const root = server === "core" ? coreRoot : join(dirname(coreRoot), unscopedName);
12114
12583
  const source = join(root, "src", "cli.ts");
12115
12584
  const built = join(root, "dist", "cli.mjs");
12116
12585
  const fromSource = fileURLToPath(import.meta.url).endsWith(".ts");
@@ -12118,7 +12587,7 @@ async function localModuleUrl(channel) {
12118
12587
  if (!await exists(cli)) continue;
12119
12588
  return pathToFileURL(cli === source ? join(root, "src", "mcp", "module.ts") : join(root, "dist", "module.mjs")).href;
12120
12589
  }
12121
- throw new CommsError("USAGE", `\`--launcher local\` registers a checkout's own code, and there is no ${channelServer(channel).packageName} beside this one`, { hint: `Looked in ${root}. Use the managed launcher, or run the install from that checkout.` });
12590
+ throw new CommsError("USAGE", `\`--launcher local\` registers a checkout's own code, and there is no ${serverFacts(server).packageName} beside this one`, { hint: `Looked in ${root}. Use the managed launcher, or run the install from that checkout.` });
12122
12591
  }
12123
12592
  /**
12124
12593
  * The product the shared installer registers, for one channel: at this core's version unless another is named.
@@ -12128,11 +12597,11 @@ async function localModuleUrl(channel) {
12128
12597
  * newer release here is all it takes to register one this core is older than — which is what `agentcomms update` does.
12129
12598
  * `comms_server_install` and every `mcp install` name none, and register their own release as they always have.
12130
12599
  */
12131
- async function channelProduct(channel, launcher, version = VERSION) {
12600
+ async function serverProduct(server, launcher, version = VERSION) {
12132
12601
  return {
12133
- ...channelServer(channel),
12602
+ ...serverFacts(server),
12134
12603
  version,
12135
- moduleUrl: launcher === "local" ? await localModuleUrl(channel) : ""
12604
+ moduleUrl: launcher === "local" ? await localModuleUrl(server) : ""
12136
12605
  };
12137
12606
  }
12138
12607
  /**
@@ -12145,8 +12614,8 @@ async function channelProduct(channel, launcher, version = VERSION) {
12145
12614
  * registered, from the channel; a product for another package would register something else under those words, and
12146
12615
  * the person would have approved a sentence that was not true.
12147
12616
  */
12148
- function ownProduct(channel, own) {
12149
- if (own !== void 0 && own.packageName !== channelServer(channel).packageName) throw new CommsError("UNEXPECTED", `${own.packageName} is not the ${channelLabel(channel)} server's package, so it cannot register that server`);
12617
+ function ownProduct(server, own) {
12618
+ if (own !== void 0 && own.packageName !== serverFacts(server).packageName) throw new CommsError("UNEXPECTED", `${own.packageName} is not the ${serverLabel(server)} server's package, so it cannot register that server`);
12150
12619
  return own;
12151
12620
  }
12152
12621
  /**
@@ -12156,11 +12625,11 @@ function ownProduct(channel, own) {
12156
12625
  * register a server reaching every workspace for a caller who believed they had pinned it.
12157
12626
  */
12158
12627
  function checkRequest(request) {
12159
- if (!isChannel(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
12628
+ if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
12160
12629
  if (!CLIENTS.includes(request.client)) throw new CommsError("USAGE", `"${String(request.client)}" is not a client this can register with`, { hint: `One of: ${CLIENTS.join(", ")}.` });
12161
12630
  if (request.launcher !== void 0 && !LAUNCHERS.includes(request.launcher)) throw new CommsError("USAGE", `"${String(request.launcher)}" is not a launcher`, { hint: `One of: ${LAUNCHERS.join(", ")}.` });
12162
12631
  if (request.name !== void 0) checkServerName(request.name);
12163
- const label = channelLabel(request.channel);
12632
+ const label = serverLabel(request.channel);
12164
12633
  const refuse = (option) => {
12165
12634
  const owner = narrowingOwner(option);
12166
12635
  throw new CommsError("USAGE", owner === void 0 ? `\`${option}\` is not an option of the ${label} server` : `\`${option}\` is an option of the ${owner.label} server; the ${label} server has no such option`);
@@ -12196,7 +12665,7 @@ function checkPin(config, request) {
12196
12665
  return;
12197
12666
  }
12198
12667
  const { account } = resolveName(config, "account", pin);
12199
- if (account.platform !== request.channel) throw new CommsError("USAGE", `"${pin}" is not a ${channelLabel(request.channel)} ${accountNoun(request.channel)}`);
12668
+ if (account.platform !== request.channel) throw new CommsError("USAGE", `"${pin}" is not a ${serverLabel(request.channel)} ${accountNoun(request.channel)}`);
12200
12669
  }
12201
12670
  function installOptions(request) {
12202
12671
  const own = pinOption(request.channel);
@@ -12215,7 +12684,7 @@ function installOptions(request) {
12215
12684
  };
12216
12685
  }
12217
12686
  /**
12218
- * Registering one channel's server with one client, as a change.
12687
+ * Registering one declared server with one client, as a change.
12219
12688
  *
12220
12689
  * The effects are what a person is agreeing to, one sentence each, and everything that decides what the server may
12221
12690
  * reach is in them — which server, which client, under which name, pinned to what, replacing what, started how. The
@@ -12230,21 +12699,22 @@ function installOptions(request) {
12230
12699
  * resolves another — `CLAUDE_CONFIG_DIR` set to another account's, a PATH that finds another node, a core running
12231
12700
  * from another build of the checkout — is another registration, refused as one.
12232
12701
  */
12233
- function serverInstallChange(core, env, request, own) {
12702
+ function serverInstallChange(core, env, request, own, deps = {}) {
12234
12703
  checkRequest(request);
12235
12704
  ownProduct(request.channel, own);
12236
12705
  const context = {
12237
12706
  env,
12238
- core
12707
+ core,
12708
+ ...deps.platform === void 0 ? {} : { platform: deps.platform }
12239
12709
  };
12240
- const facts = channelServer(request.channel);
12241
- const label = channelLabel(request.channel);
12710
+ const facts = serverFacts(request.channel);
12711
+ const label = serverLabel(request.channel);
12242
12712
  const noun = accountNoun(request.channel);
12243
12713
  const pinName = pinOption(request.channel);
12244
12714
  const switchable = hasNarrowing(request.channel, "readOnly");
12245
12715
  const name = request.name ?? facts.defaultServerName;
12246
12716
  const launcher = request.launcher ?? "managed";
12247
- const productNow = async () => own ?? await channelProduct(request.channel, request.launcher);
12717
+ const productNow = async () => own ?? await serverProduct(request.channel, request.launcher);
12248
12718
  let planned;
12249
12719
  return {
12250
12720
  plan: async (config) => {
@@ -12275,6 +12745,14 @@ function serverInstallChange(core, env, request, own) {
12275
12745
  else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
12276
12746
  }
12277
12747
  if (request.print !== true && launcher === "managed" && await reusableRuntime(core.paths.dataDir, facts.packageName, version) === null) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
12748
+ if (request.print !== true && launcher === "managed" && managedShimsSupported(context)) {
12749
+ const path = managedShimPath({
12750
+ env,
12751
+ dataDir: core.paths.dataDir,
12752
+ platform: context.platform
12753
+ }, facts.binary);
12754
+ effects.push(`writes or repoints the owned CLI shim at ${path}; a foreign file there is left unchanged`);
12755
+ }
12278
12756
  return {
12279
12757
  before: config,
12280
12758
  after: config,
@@ -12284,9 +12762,10 @@ function serverInstallChange(core, env, request, own) {
12284
12762
  },
12285
12763
  apply: async () => {
12286
12764
  if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
12287
- const result = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
12765
+ const installed = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
12288
12766
  throw await remedyLocated(error, core);
12289
12767
  });
12768
+ const result = request.print === true ? installed : await withManagedShim(context, planned.product, installed);
12290
12769
  return {
12291
12770
  ...result,
12292
12771
  restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
@@ -12295,6 +12774,68 @@ function serverInstallChange(core, env, request, own) {
12295
12774
  };
12296
12775
  }
12297
12776
  /**
12777
+ * A managed install's CLI shim (CUE-406), once the registration's outcome is known: the command the locator finds in
12778
+ * the runtime the entry starts, written where nothing is or over this installation's own unchanged shim, and never
12779
+ * over anything else. A shim that cannot be written leaves the registration as it went, and says how to repair both.
12780
+ * The installer may not import the locator, so the operation writes it — for `mcp install` and `update` alike.
12781
+ */
12782
+ async function withManagedShim(context, product, installed) {
12783
+ if (installed.launcher !== "managed") return installed;
12784
+ const cliEntry = installed.entry.args[0];
12785
+ if (cliEntry === void 0) throw new CommsError("UNEXPECTED", "the managed entry has no CLI path");
12786
+ const shimContext = {
12787
+ env: context.env,
12788
+ dataDir: context.core.paths.dataDir,
12789
+ platform: context.platform
12790
+ };
12791
+ if (!managedShimsSupported(shimContext)) return {
12792
+ ...installed,
12793
+ shimNotice: managedShimUnsupportedNotice(shimContext)
12794
+ };
12795
+ const warnings = [...installed.warnings];
12796
+ let shim;
12797
+ let shimFailure;
12798
+ try {
12799
+ const located = locateCliCommand({
12800
+ caller: {
12801
+ url: pathToFileURL(cliEntry).href,
12802
+ packageName: product.packageName
12803
+ },
12804
+ target: {
12805
+ packageName: product.packageName,
12806
+ manifest: {
12807
+ label: product.defaultServerName,
12808
+ binary: product.binary
12809
+ }
12810
+ },
12811
+ words: [],
12812
+ uses: [],
12813
+ paths: {
12814
+ ...context.core.paths,
12815
+ downloadsDir: context.core.paths.dataDir
12816
+ },
12817
+ platform: context.platform
12818
+ });
12819
+ if (!located.ok) throw new CommsError("CONFIG", `the managed CLI command could not be located: ${located.message}`);
12820
+ shim = await (context.writeShim ?? writeManagedShim)(shimContext, {
12821
+ binary: product.binary,
12822
+ command: located.command.words,
12823
+ entry: located.command.entry
12824
+ });
12825
+ } catch (error) {
12826
+ shimFailure = error instanceof Error ? error.message : String(error);
12827
+ const path = join(homeDirectory(context.env), ".local", "bin", product.binary);
12828
+ warnings.push(`The MCP registration ${installed.applied ? "succeeded" : "was not changed"}, but the CLI shim at ${path} could not be written (${shimFailure}). Fix that shim directory, then run this managed install again with \`--force\`; the owned registration is replaced without widening it and the shim is repaired.`);
12829
+ }
12830
+ if (shim?.status === "foreign") warnings.push(`${shim.path} already exists but was not written by this installation, so it was left unchanged; move it aside and run this managed install again with \`--force\` to create the CLI shim at that path.`);
12831
+ return {
12832
+ ...installed,
12833
+ warnings,
12834
+ ...shim === void 0 ? {} : { shim },
12835
+ ...shimFailure === void 0 ? {} : { shimFailure }
12836
+ };
12837
+ }
12838
+ /**
12298
12839
  * A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
12299
12840
  * the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
12300
12841
  * installer says only the words; it may not import the locator.
@@ -12302,17 +12843,24 @@ function serverInstallChange(core, env, request, own) {
12302
12843
  async function remedyLocated(error, core) {
12303
12844
  const remedy = installRemedyOf(error);
12304
12845
  if (remedy === void 0 || !(error instanceof CommsError)) return error;
12305
- const channel = CHANNELS.find((each) => channelServer(each).binary === remedy.binary);
12306
- if (channel === void 0) return error;
12846
+ const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
12847
+ if (server === void 0) return error;
12307
12848
  const maker = await requireHandoffs(core).registered();
12849
+ const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
12850
+ "mcp",
12851
+ "install",
12852
+ "--server",
12853
+ server,
12854
+ ...remedy.words.slice(2)
12855
+ ]);
12308
12856
  return new CommsError(error.code, error.message, {
12309
- hint: handoffSentence(maker.of(channel, remedy.words), remedy.say),
12857
+ hint: handoffSentence(handoff, remedy.say),
12310
12858
  ...error.details === void 0 ? {} : { details: error.details },
12311
12859
  ...error.cause === void 0 ? {} : { cause: error.cause }
12312
12860
  });
12313
12861
  }
12314
12862
  /**
12315
- * Removing a channel's unused managed runtimes, as a change.
12863
+ * Removing a declared server's unused managed runtimes, as a change.
12316
12864
  *
12317
12865
  * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
12318
12866
  * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
@@ -12322,20 +12870,20 @@ async function remedyLocated(error, core) {
12322
12870
  * release of the command that was run; checked to be that channel's, as for an install.
12323
12871
  */
12324
12872
  function serverPruneChange(core, env, request, own) {
12325
- if (!isChannel(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
12873
+ if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
12326
12874
  const context = {
12327
12875
  env,
12328
12876
  core
12329
12877
  };
12330
12878
  const { packageName, version } = ownProduct(request.channel, own) ?? {
12331
- packageName: channelServer(request.channel).packageName,
12332
- version: "0.17.0"
12879
+ packageName: serverFacts(request.channel).packageName,
12880
+ version: "0.18.1"
12333
12881
  };
12334
12882
  const product = {
12335
12883
  packageName,
12336
12884
  version
12337
12885
  };
12338
- const label = channelLabel(request.channel);
12886
+ const label = serverLabel(request.channel);
12339
12887
  const base = {
12340
12888
  includePrinted: request.includePrinted === true,
12341
12889
  ...request.processes ? { processes: request.processes } : {}
@@ -12356,7 +12904,7 @@ function serverPruneChange(core, env, request, own) {
12356
12904
  return {
12357
12905
  before: config,
12358
12906
  after: config,
12359
- effects: preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`),
12907
+ effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
12360
12908
  summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
12361
12909
  };
12362
12910
  },
@@ -12635,21 +13183,22 @@ function updateStopMessage(pending, where, commands) {
12635
13183
  * the yes is a different change, and the claim is refused. Nothing behind, nothing is prepared.
12636
13184
  */
12637
13185
  /**
12638
- * The packages this suite publishes, whose latest releases an update reads: every channel's, from the manifests, and
12639
- * every package a channel's server is run through by `npx` when that is another one — Gmail's `gmail-mcp`.
13186
+ * The packages this suite publishes, whose latest releases an update reads: every declared server owner's, from the
13187
+ * channel and service declarations, and every package a server is run through by `npx` when that is another one —
13188
+ * Gmail's `gmail-mcp`.
12640
13189
  */
12641
- const CHANNEL_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(channel).packageName));
12642
- const SERVER_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(channel).npxPackage).filter((name) => !CHANNEL_PACKAGES.includes(name)));
12643
- const PUBLISHED_NAMES = Object.freeze([.../* @__PURE__ */ new Set([...CHANNEL_PACKAGES, ...SERVER_PACKAGES])].sort());
12644
- function narrowingArgs(channel, narrowing) {
12645
- return channelServer(channel).serverArgs({
13190
+ const OWNER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server).packageName));
13191
+ const SERVER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server).npxPackage).filter((name) => !OWNER_PACKAGES.includes(name)));
13192
+ const PUBLISHED_NAMES = Object.freeze([.../* @__PURE__ */ new Set([...OWNER_PACKAGES, ...SERVER_PACKAGES])].sort());
13193
+ function narrowingArgs(server, narrowing) {
13194
+ return serverFacts(server).serverArgs({
12646
13195
  client: "json",
12647
13196
  ...narrowing
12648
13197
  });
12649
13198
  }
12650
- /** Every registration of every channel's server, as the core's `channels` finds them — nothing an `env` holds. */
13199
+ /** Every registration of every declared server, as the core's `channels` finds them — nothing an `env` holds. */
12651
13200
  function registrations(servers) {
12652
- return CHANNELS.flatMap((channel) => servers.filter((server) => isProductServer(server, channelServer(channel))).map((server) => ({
13201
+ return SERVERS.flatMap((channel) => servers.filter((server) => isProductServer(server, serverFacts(channel))).map((server) => ({
12653
13202
  channel,
12654
13203
  server
12655
13204
  })));
@@ -12687,7 +13236,7 @@ async function latestReleases(names, latestVersion) {
12687
13236
  * have no line to paste: the reasons below give it with `handoffSentence`, which shows it as words then.
12688
13237
  */
12689
13238
  function installHandoff(item, handoffs) {
12690
- const facts = channelServer(item.channel);
13239
+ const facts = serverFacts(item.channel);
12691
13240
  const words = [
12692
13241
  "mcp",
12693
13242
  "install",
@@ -12696,7 +13245,13 @@ function installHandoff(item, handoffs) {
12696
13245
  ];
12697
13246
  if (item.name !== facts.defaultServerName) words.push("--name", item.name);
12698
13247
  words.push(...item.narrowing, "--force");
12699
- return handoffs.of(item.channel, words);
13248
+ return isChannel(item.channel) ? handoffs.of(item.channel, words) : handoffs.core([
13249
+ "mcp",
13250
+ "install",
13251
+ "--server",
13252
+ item.channel,
13253
+ ...words.slice(2)
13254
+ ]);
12700
13255
  }
12701
13256
  /**
12702
13257
  * Why a registration that is behind cannot be registered again from here, or undefined when it can.
@@ -12736,8 +13291,8 @@ function newest(versions) {
12736
13291
  */
12737
13292
  async function runtimesNeeded(dataDir, latest, managed) {
12738
13293
  const needed = [];
12739
- for (const channel of CHANNELS) {
12740
- const packageName = channelServer(channel).packageName;
13294
+ for (const channel of SERVERS) {
13295
+ const packageName = serverFacts(channel).packageName;
12741
13296
  const version = latest[packageName];
12742
13297
  if (version === void 0) continue;
12743
13298
  if (!managed.some((item) => item.channel === channel && item.launcher === "managed")) continue;
@@ -12764,7 +13319,7 @@ async function inspect(core, env, deps) {
12764
13319
  const handoffs = requireHandoffs(core, deps.platform).withRegistrations(scan.servers);
12765
13320
  const unreadable = [...scan.unreadable];
12766
13321
  const found = registrations(scan.servers).map(({ channel, server }) => {
12767
- const facts = channelServer(channel);
13322
+ const facts = serverFacts(channel);
12768
13323
  return {
12769
13324
  channel,
12770
13325
  server,
@@ -12784,9 +13339,9 @@ async function inspect(core, env, deps) {
12784
13339
  reason: error instanceof Error ? error.message : String(error)
12785
13340
  });
12786
13341
  }
12787
- const wanted = /* @__PURE__ */ new Set([channelServer("core").packageName]);
13342
+ const wanted = /* @__PURE__ */ new Set([serverFacts("core").packageName]);
12788
13343
  for (const entry of found) {
12789
- wanted.add(channelServer(entry.channel).packageName);
13344
+ wanted.add(serverFacts(entry.channel).packageName);
12790
13345
  wanted.add(entry.package);
12791
13346
  }
12792
13347
  for (const name of Object.keys(installed)) wanted.add(name);
@@ -12809,7 +13364,7 @@ async function inspect(core, env, deps) {
12809
13364
  name: entry.server.name,
12810
13365
  scope: entry.server.scope ?? "user",
12811
13366
  path: entry.server.path,
12812
- launcher: launcherOf(entry.server, channelServer(entry.channel)),
13367
+ launcher: launcherOf(entry.server, serverFacts(entry.channel)),
12813
13368
  version: entry.version,
12814
13369
  latest: latest[entry.package] ?? "",
12815
13370
  narrowing: narrowingArgs(entry.channel, entry.narrowing)
@@ -12840,8 +13395,8 @@ async function inspect(core, env, deps) {
12840
13395
  }
12841
13396
  const needed = await runtimesNeeded(core.paths.dataDir, latest, candidates.map((candidate) => candidate.item));
12842
13397
  behind.push(...needed);
12843
- for (const channel of CHANNELS) {
12844
- const packageName = channelServer(channel).packageName;
13398
+ for (const channel of SERVERS) {
13399
+ const packageName = serverFacts(channel).packageName;
12845
13400
  const version = latest[packageName];
12846
13401
  if (version === void 0 || await reusableRuntime(core.paths.dataDir, packageName, version) === null) continue;
12847
13402
  upToDate.push({
@@ -12899,7 +13454,7 @@ async function updateCheck(core, env, deps = {}, claim) {
12899
13454
  await recordFound(core, env, report, deps, null, claim);
12900
13455
  return report;
12901
13456
  }
12902
- const CORE_PACKAGE$1 = channelServer("core").packageName;
13457
+ const CORE_PACKAGE$1 = serverFacts("core").packageName;
12903
13458
  /**
12904
13459
  * One registration: its channel, its client, its scope, the file it is in and its name. Two copies of it in one file —
12905
13460
  * `servers` and an old `mcpServers` — are one registration, and an update registers them again as one.
@@ -12978,7 +13533,7 @@ function updateCheckFindings(report, latest, after = null) {
12978
13533
  });
12979
13534
  const globals = items.filter((item) => item.kind === "global");
12980
13535
  const global = CHANNELS.filter((channel) => {
12981
- const name = channelServer(channel).packageName;
13536
+ const name = serverFacts(channel).packageName;
12982
13537
  return globals.some((item) => item.package === name && atLatest(installed.has(name) ? item.latest : item.version));
12983
13538
  });
12984
13539
  const stillBehind = after === null ? report.behind.length > 0 : !(after.ok && after.manual.length === 0 && after.status !== "manual");
@@ -13009,13 +13564,11 @@ function reachOf(channel, narrowing) {
13009
13564
  */
13010
13565
  function registrationEffects(step) {
13011
13566
  const { item } = step;
13012
- const reach = reachOf(item.channel, channelServer(item.channel).narrowingOf(item.narrowing));
13013
- const entry = `registers the ${channelLabel(item.channel)} MCP server with ${item.client} as "${item.name}" again (${item.scope} scope, ${item.launcher} launcher), at ${item.latest} in place of ${item.version}${reach ? ` — ${reach}, as now` : ""}`;
13014
- return item.launcher === "npx" ? [
13015
- entry,
13016
- step.destination,
13017
- `${item.client} will fetch ${item.package}@${item.latest} from npm each time it starts "${item.name}"`
13018
- ] : [entry, step.destination];
13567
+ const reach = reachOf(item.channel, serverFacts(item.channel).narrowingOf(item.narrowing));
13568
+ const effects = [`registers the ${serverLabel(item.channel)} MCP server with ${item.client} as "${item.name}" again (${item.scope} scope, ${item.launcher} launcher), at ${item.latest} in place of ${item.version}${reach ? ` — ${reach}, as now` : ""}`, step.destination];
13569
+ if (step.shim) effects.push(`writes or repoints the owned CLI shim at ${step.shim}; a foreign file there is left unchanged`);
13570
+ if (item.launcher === "npx") effects.push(`${item.client} will fetch ${item.package}@${item.latest} from npm each time it starts "${item.name}"`);
13571
+ return effects;
13019
13572
  }
13020
13573
  /** Every step as the sentences a person reads, in the order the steps are taken. */
13021
13574
  function effectsOf(planned) {
@@ -13051,7 +13604,7 @@ async function planRegistrations(context, candidates, request, handoffs) {
13051
13604
  if (seen.has(key)) continue;
13052
13605
  seen.add(key);
13053
13606
  const launcher = item.launcher;
13054
- const product = await channelProduct(item.channel, launcher, item.latest);
13607
+ const product = await serverProduct(item.channel, launcher, item.latest);
13055
13608
  const options = {
13056
13609
  client: item.client,
13057
13610
  name: item.name,
@@ -13077,7 +13630,12 @@ async function planRegistrations(context, candidates, request, handoffs) {
13077
13630
  product,
13078
13631
  options,
13079
13632
  install: plannedInstall(preflight),
13080
- destination: entryDestination(item.client, preflight.target, preflight.command, context.env)
13633
+ destination: entryDestination(item.client, preflight.target, preflight.command, context.env),
13634
+ ...launcher === "managed" && managedShimsSupported(context) ? { shim: managedShimPath({
13635
+ env: context.env,
13636
+ dataDir: context.core.paths.dataDir,
13637
+ platform: context.platform
13638
+ }, product.binary) } : {}
13081
13639
  });
13082
13640
  }
13083
13641
  return {
@@ -13199,18 +13757,23 @@ function whatBecameOf(stored) {
13199
13757
  revoked: "it had been revoked"
13200
13758
  }[record.state] ?? `it is ${JSON.stringify(record.state)}, a state this version does not know`;
13201
13759
  }
13202
- /** The product a runtime of `packageName` is installed as: the channel whose package it is. */
13760
+ /** The product a runtime of `packageName` is installed as: the declared server whose package it is. */
13203
13761
  function runtimeProduct(packageName, version) {
13204
- const channel = CHANNELS.find((each) => channelServer(each).packageName === packageName);
13205
- if (channel === void 0) throw new CommsError("UNEXPECTED", `${packageName} is not a channel's package`);
13206
- return channelProduct(channel, "managed", version);
13762
+ const channel = SERVERS.find((each) => serverFacts(each).packageName === packageName);
13763
+ if (channel === void 0) throw new CommsError("UNEXPECTED", `${packageName} is not a declared server package`);
13764
+ return serverProduct(channel, "managed", version);
13207
13765
  }
13208
13766
  /**
13209
- * Every channel's `mcp prune`, as a person runs it at a terminal: each located among this machine's registrations, and
13210
- * for one that is not, why — never a bare name (CUE-403).
13767
+ * Every declared server's `mcp prune`, as a person runs it at a terminal: each channel command is located among this
13768
+ * machine's registrations, and each service uses core — never a bare name (CUE-403).
13211
13769
  */
13212
13770
  function pruneCommands(handoffs) {
13213
- return handoffChoices(CHANNELS.map((channel) => handoffs.of(channel, ["mcp", "prune"])), "none of their commands is locatable here:", { conjunction: "and" });
13771
+ return handoffChoices(SERVERS.map((channel) => isChannel(channel) ? handoffs.of(channel, ["mcp", "prune"]) : handoffs.core([
13772
+ "mcp",
13773
+ "prune",
13774
+ "--server",
13775
+ channel
13776
+ ])), "none of their commands is locatable here:", { conjunction: "and" });
13214
13777
  }
13215
13778
  function message(error) {
13216
13779
  return error instanceof CommsError ? error.message : error instanceof Error ? error.message : String(error);
@@ -13267,26 +13830,28 @@ async function applyUpdate(context, planned, deps) {
13267
13830
  continue;
13268
13831
  }
13269
13832
  try {
13270
- const pins = channelServer(item.channel).narrowingOf(item.narrowing);
13271
- const result = await mcpInstall(context, product, {
13833
+ const pins = serverFacts(item.channel).narrowingOf(item.narrowing);
13834
+ const installed = await mcpInstall(context, product, {
13272
13835
  ...options,
13273
13836
  ...pins
13274
13837
  }, install);
13275
- if (!result.applied) {
13838
+ if (!installed.applied) {
13276
13839
  steps.push({
13277
13840
  ...base,
13278
13841
  outcome: "failed",
13279
- detail: result.notApplied ?? "nothing was registered"
13842
+ detail: installed.notApplied ?? "nothing was registered"
13280
13843
  });
13281
13844
  continue;
13282
13845
  }
13846
+ const result = await withManagedShim(context, product, installed);
13283
13847
  steps.push({
13284
13848
  ...base,
13285
13849
  outcome: "registered",
13286
13850
  verification: result.verification,
13287
13851
  ...result.verifyDetail !== void 0 ? { detail: result.verifyDetail } : {},
13288
13852
  ...result.backupPath !== void 0 ? { backupPath: result.backupPath } : {},
13289
- ...result.warnings.length > 0 ? { warnings: result.warnings } : {}
13853
+ ...result.warnings.length > 0 ? { warnings: result.warnings } : {},
13854
+ ...result.shim !== void 0 ? { shim: result.shim } : {}
13290
13855
  });
13291
13856
  } catch (error) {
13292
13857
  steps.push({
@@ -14046,7 +14611,7 @@ function terminalUpdateHooks(core, env, options) {
14046
14611
  };
14047
14612
  }
14048
14613
  //#endregion
14049
- //#region ../core/dist/tool-arguments-rfCRYwaZ.mjs
14614
+ //#region ../core/dist/tool-arguments-DLa_xsSp.mjs
14050
14615
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
14051
14616
  const TARGET = "draft-2020-12";
14052
14617
  /**
@@ -14351,7 +14916,7 @@ function spoken(words, joiner) {
14351
14916
  return `${words.slice(0, -1).join(", ")} ${joiner} ${words.at(-1)}`;
14352
14917
  }
14353
14918
  //#endregion
14354
- //#region ../../node_modules/.pnpm/postal-mime@3.0.0/node_modules/postal-mime/src/decode-strings.js
14919
+ //#region ../../node_modules/.pnpm/postal-mime@4.0.5/node_modules/postal-mime/dist/esm/decode-strings.js
14355
14920
  const textEncoder = new TextEncoder();
14356
14921
  const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
14357
14922
  const base64Lookup = /* @__PURE__ */ new Uint8Array(256);
@@ -14406,26 +14971,143 @@ const codePagePattern = /^(?:cp|windows|ms|ibm)(\d+)$/;
14406
14971
  function normalizeCharset(charset) {
14407
14972
  return charset.replace(/^(?:x-ms-|x-|cs)/, "").replace(/[\s._-]+/g, "");
14408
14973
  }
14974
+ const ENCODING_LABELS = new Set([
14975
+ "unicode-1-1-utf-8 unicode11utf8 unicode20utf8 utf-8 utf8 x-unicode20utf8",
14976
+ "866 cp866 csibm866 ibm866",
14977
+ "csisolatin2 iso-8859-2 iso-ir-101 iso8859-2 iso88592 iso_8859-2 iso_8859-2:1987 l2 latin2",
14978
+ "csisolatin3 iso-8859-3 iso-ir-109 iso8859-3 iso88593 iso_8859-3 iso_8859-3:1988 l3 latin3",
14979
+ "csisolatin4 iso-8859-4 iso-ir-110 iso8859-4 iso88594 iso_8859-4 iso_8859-4:1988 l4 latin4",
14980
+ "csisolatincyrillic cyrillic iso-8859-5 iso-ir-144 iso8859-5 iso88595 iso_8859-5 iso_8859-5:1988",
14981
+ "arabic asmo-708 csiso88596e csiso88596i csisolatinarabic ecma-114 iso-8859-6 iso-8859-6-e",
14982
+ "iso-8859-6-i iso-ir-127 iso8859-6 iso88596 iso_8859-6 iso_8859-6:1987",
14983
+ "csisolatingreek ecma-118 elot_928 greek greek8 iso-8859-7 iso-ir-126 iso8859-7 iso88597 iso_8859-7",
14984
+ "iso_8859-7:1987 sun_eu_greek",
14985
+ "csiso88598e csisolatinhebrew hebrew iso-8859-8 iso-8859-8-e iso-ir-138 iso8859-8 iso88598 iso_8859-8",
14986
+ "iso_8859-8:1988 visual",
14987
+ "csiso88598i iso-8859-8-i logical",
14988
+ "csisolatin6 iso-8859-10 iso-ir-157 iso8859-10 iso885910 l6 latin6",
14989
+ "iso-8859-13 iso8859-13 iso885913",
14990
+ "iso-8859-14 iso8859-14 iso885914",
14991
+ "csisolatin9 iso-8859-15 iso8859-15 iso885915 iso_8859-15 l9",
14992
+ "iso-8859-16",
14993
+ "cskoi8r koi koi8 koi8-r koi8_r",
14994
+ "koi8-ru koi8-u",
14995
+ "csmacintosh mac macintosh x-mac-roman",
14996
+ "dos-874 iso-8859-11 iso8859-11 iso885911 tis-620 windows-874",
14997
+ "cp1250 windows-1250 x-cp1250",
14998
+ "cp1251 windows-1251 x-cp1251",
14999
+ "ansi_x3.4-1968 ascii cp1252 cp819 csisolatin1 ibm819 iso-8859-1 iso-ir-100 iso8859-1 iso88591",
15000
+ "iso_8859-1 iso_8859-1:1987 l1 latin1 us-ascii windows-1252 x-cp1252",
15001
+ "cp1253 windows-1253 x-cp1253",
15002
+ "cp1254 csisolatin5 iso-8859-9 iso-ir-148 iso8859-9 iso88599 iso_8859-9 iso_8859-9:1989 l5 latin5",
15003
+ "windows-1254 x-cp1254",
15004
+ "cp1255 windows-1255 x-cp1255",
15005
+ "cp1256 windows-1256 x-cp1256",
15006
+ "cp1257 windows-1257 x-cp1257",
15007
+ "cp1258 windows-1258 x-cp1258",
15008
+ "x-mac-cyrillic x-mac-ukrainian",
15009
+ "chinese csgb2312 csiso58gb231280 gb2312 gb_2312 gb_2312-80 gbk iso-ir-58 x-gbk",
15010
+ "gb18030",
15011
+ "big5 big5-hkscs cn-big5 csbig5 x-x-big5",
15012
+ "cseucpkdfmtjapanese euc-jp x-euc-jp",
15013
+ "csiso2022jp iso-2022-jp",
15014
+ "csshiftjis ms932 ms_kanji shift-jis shift_jis sjis windows-31j x-sjis",
15015
+ "cseuckr csksc56011987 euc-kr iso-ir-149 korean ks_c_5601-1987 ks_c_5601-1989 ksc5601 ksc_5601",
15016
+ "windows-949",
15017
+ "csiso2022kr hz-gb-2312 iso-2022-cn iso-2022-cn-ext iso-2022-kr replacement",
15018
+ "unicodefffe utf-16be",
15019
+ "csunicode iso-10646-ucs-2 ucs-2 unicode unicodefeff utf-16 utf-16le",
15020
+ "x-user-defined"
15021
+ ].join(" ").split(" "));
15022
+ const decoders = /* @__PURE__ */ new Map();
14409
15023
  function tryDecoder(charset) {
14410
- try {
14411
- return new TextDecoder(charset);
14412
- } catch (err) {
14413
- return null;
15024
+ if (!ENCODING_LABELS.has(charset)) return null;
15025
+ let decoder = decoders.get(charset);
15026
+ if (decoder === void 0) {
15027
+ decoder = null;
15028
+ try {
15029
+ decoder = new TextDecoder(charset);
15030
+ } catch {}
15031
+ decoders.set(charset, decoder);
14414
15032
  }
15033
+ return decoder;
14415
15034
  }
14416
- function getDecoder(charset) {
15035
+ const utf8Decoder = new TextDecoder();
15036
+ /**
15037
+ * Finds the decoder for a charset label, or null when the runtime has no encoding for it
15038
+ */
15039
+ function findDecoder(charset) {
14417
15040
  charset = (charset || "utf8").trim().toLowerCase();
14418
15041
  const decoder = tryDecoder(charset);
14419
15042
  if (decoder) return decoder;
14420
15043
  const normalized = normalizeCharset(charset);
14421
15044
  const codePage = normalized.match(codePagePattern);
14422
- return tryDecoder(codePage && codePageAliases.get(codePage[1]) || charsetAliases.get(normalized) || normalized) || new TextDecoder("windows-1252");
15045
+ return tryDecoder(codePage && codePageAliases.get(codePage[1]) || charsetAliases.get(normalized) || normalized);
15046
+ }
15047
+ function getDecoder(charset) {
15048
+ return findDecoder(charset) || tryDecoder("windows-1252") || utf8Decoder;
15049
+ }
15050
+ /**
15051
+ * Whether the bytes are well formed UTF-8. A scan rather than a fatal TextDecoder, since
15052
+ * the exception one throws costs more than the decode itself and a message decides how
15053
+ * many lines throw it.
15054
+ */
15055
+ function isUtf8(bytes) {
15056
+ for (let i = 0; i < bytes.length;) {
15057
+ const lead = bytes[i];
15058
+ if (lead < 128) {
15059
+ i++;
15060
+ continue;
15061
+ }
15062
+ let count;
15063
+ if (lead >= 194 && lead <= 223) count = 1;
15064
+ else if (lead >= 224 && lead <= 239) count = 2;
15065
+ else if (lead >= 240 && lead <= 244) count = 3;
15066
+ else return false;
15067
+ if (i + count >= bytes.length) return false;
15068
+ const second = bytes[i + 1];
15069
+ if (second < (lead === 224 ? 160 : lead === 240 ? 144 : 128) || second > (lead === 237 ? 159 : lead === 244 ? 143 : 191)) return false;
15070
+ for (let k = 2; k <= count; k++) if ((bytes[i + k] & 192) !== 128) return false;
15071
+ i += count + 1;
15072
+ }
15073
+ return true;
15074
+ }
15075
+ /**
15076
+ * Decodes bytes whose encoding is not known: as UTF-8 when they are well formed UTF-8, as
15077
+ * windows-1252 otherwise.
15078
+ *
15079
+ * A message that declares no charset, or one the runtime has never heard of, usually
15080
+ * predates RFC 6532 or comes from a generator that never heard of it either, and its 8-bit
15081
+ * bytes are a legacy single byte encoding far more often than broken UTF-8. windows-1252
15082
+ * is the fallback the WHATWG Encoding Standard prescribes and what a mail client's default
15083
+ * charset amounts to, and it reads such text rather than filling it with replacement
15084
+ * characters.
15085
+ *
15086
+ * @param bytes Bytes to decode
15087
+ * @param utf8 The UTF-8 decoder to use, header lines bring one that keeps a BOM
15088
+ * @return Decoded text
15089
+ */
15090
+ function decodeUtf8OrWindows1252(bytes, utf8 = utf8Decoder) {
15091
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
15092
+ return isUtf8(view) ? utf8.decode(view) : getDecoder("windows-1252").decode(view);
15093
+ }
15094
+ /**
15095
+ * Decodes bytes in a named charset. A label the runtime does not know is no charset at
15096
+ * all, see decodeUtf8OrWindows1252.
15097
+ *
15098
+ * @param bytes Bytes to decode
15099
+ * @param charset Charset label
15100
+ * @return Decoded text
15101
+ */
15102
+ function decodeCharset(bytes, charset) {
15103
+ const decoder = findDecoder(charset);
15104
+ return decoder ? decoder.decode(bytes) : decodeUtf8OrWindows1252(bytes);
14423
15105
  }
14424
15106
  /**
14425
15107
  * Numeric value of an ASCII hex digit
14426
15108
  *
14427
- * @param {Number} c Byte to read
14428
- * @return {Number} Value 0-15, or -1 if the byte is not a hex digit
15109
+ * @param c Byte to read
15110
+ * @return Value 0-15, or -1 if the byte is not a hex digit
14429
15111
  */
14430
15112
  function hexNibble(c) {
14431
15113
  if (c >= 48 && c <= 57) return c - 48;
@@ -14434,14 +15116,35 @@ function hexNibble(c) {
14434
15116
  return -1;
14435
15117
  }
14436
15118
  /**
15119
+ * Folds the line breaks of a header value into spaces.
15120
+ *
15121
+ * A header value holds no line breaks. The raw value is folded when the headers are
15122
+ * processed, see processHeaders in mime-node.ts, an RFC 2047 encoded word is folded as it
15123
+ * is decoded, and an RFC 2231 filename is folded where the attachment is built. Otherwise a
15124
+ * decoded name, subject or filename written into a header of an outgoing message by the
15125
+ * consumer ends the header early and starts another one with the rest of the value.
15126
+ *
15127
+ * The RFC 2231 percent decoder itself does not fold: it also produces the boundary, and a
15128
+ * boundary holding a line break can never match a line. That has to stay so, or a message
15129
+ * that has no parts for every other parser would have them here.
15130
+ *
15131
+ * @param str Header value
15132
+ * @return The value with every run of CR and LF replaced by a space
15133
+ */
15134
+ function foldLineBreaks(str) {
15135
+ return str.replace(/[\r\n]+/g, " ");
15136
+ }
15137
+ /**
14437
15138
  * Decode a complete mime word encoded string
14438
15139
  *
14439
- * @param {String} str Mime word encoded string
14440
- * @return {String} Decoded unicode string
15140
+ * @param charset Character set of the encoded word
15141
+ * @param encoding `Q` or `B`
15142
+ * @param str Mime word encoded string
15143
+ * @return Decoded unicode string
14441
15144
  */
14442
15145
  function decodeWord(charset, encoding, str) {
14443
15146
  let splitPos = charset.indexOf("*");
14444
- if (splitPos >= 0) charset = charset.substr(0, splitPos);
15147
+ if (splitPos >= 0) charset = charset.slice(0, splitPos);
14445
15148
  encoding = encoding.toUpperCase();
14446
15149
  let byteStr;
14447
15150
  if (encoding === "Q") {
@@ -14466,7 +15169,7 @@ function decodeWord(charset, encoding, str) {
14466
15169
  for (let i = 0, len = encodedBytes.length; i < len; i++) dataView.setUint8(i, encodedBytes[i]);
14467
15170
  } else if (encoding === "B") byteStr = decodeBase64$1(str.replace(/[^a-zA-Z0-9\+\/=]+/g, ""));
14468
15171
  else byteStr = textEncoder.encode(str);
14469
- return getDecoder(charset).decode(byteStr);
15172
+ return foldLineBreaks(decodeCharset(byteStr, charset));
14470
15173
  }
14471
15174
  const ENCODED_WORD_PATTERN = "=\\?([^?\\s]+)\\?([QqBb])\\?([^?]*)\\?=";
14472
15175
  const ENCODED_WORD_REGEX = new RegExp(ENCODED_WORD_PATTERN, "g");
@@ -14476,8 +15179,8 @@ const ENCODED_WORDS_ONLY_REGEX = new RegExp(`^(?:${ENCODED_WORD_PATTERN}\\s*)+$`
14476
15179
  * Checks whether a string is nothing but RFC 2047 encoded words. Kept next to the grammar
14477
15180
  * it depends on, so the pattern has a single definition.
14478
15181
  *
14479
- * @param {String} str String to check
14480
- * @return {Boolean} true if the string holds encoded words and nothing else
15182
+ * @param str String to check
15183
+ * @return true if the string holds encoded words and nothing else
14481
15184
  */
14482
15185
  function isEncodedWordsOnly(str) {
14483
15186
  return ENCODED_WORDS_ONLY_REGEX.test(str);
@@ -14489,8 +15192,8 @@ function isEncodedWordsOnly(str) {
14489
15192
  * means the input can not contain the marker, which previously let a sender delete text
14490
15193
  * from a subject or a display name by writing the marker into the header themselves.
14491
15194
  *
14492
- * @param {String} str String to split
14493
- * @return {Array} Array of `{text}` and `{charset, encoding, encodedText}` tokens
15195
+ * @param str String to split
15196
+ * @return Array of `{text}` and `{charset, encoding, encodedText}` tokens
14494
15197
  */
14495
15198
  function splitEncodedWords(str) {
14496
15199
  const tokens = [];
@@ -14524,9 +15227,9 @@ function canJoinWords(left, right) {
14524
15227
  /**
14525
15228
  * Decodes a token list into a string, optionally merging adjacent encoded words.
14526
15229
  *
14527
- * @param {Array} tokens Tokens from splitEncodedWords
14528
- * @param {Boolean} joinWords Whether adjacent encoded words may be decoded as one unit
14529
- * @return {String} Decoded string
15230
+ * @param tokens Tokens from splitEncodedWords
15231
+ * @param joinWords Whether adjacent encoded words may be decoded as one unit
15232
+ * @return Decoded string
14530
15233
  */
14531
15234
  function renderTokens(tokens, joinWords) {
14532
15235
  let result = "";
@@ -14557,6 +15260,12 @@ function renderTokens(tokens, joinWords) {
14557
15260
  if (pending) result += decodeWord(pending.charset, pending.encoding, pending.encodedText);
14558
15261
  return result;
14559
15262
  }
15263
+ /**
15264
+ * Decodes RFC 2047 encoded words in a string
15265
+ *
15266
+ * @param str String that may contain encoded words
15267
+ * @return Unicode string with every encoded word decoded
15268
+ */
14560
15269
  function decodeWords(str) {
14561
15270
  const tokens = splitEncodedWords((str || "").toString());
14562
15271
  const result = renderTokens(tokens, true);
@@ -14564,55 +15273,279 @@ function decodeWords(str) {
14564
15273
  }
14565
15274
  new TextDecoder("utf-8", { ignoreBOM: true });
14566
15275
  //#endregion
14567
- //#region ../../node_modules/.pnpm/postal-mime@3.0.0/node_modules/postal-mime/src/address-parser.js
14568
- const WORD_CHAR_REGEX = /\w/;
14569
- const NON_SPACE_TOKEN_REGEX = /[^\s]+/g;
15276
+ //#region ../../node_modules/.pnpm/postal-mime@4.0.5/node_modules/postal-mime/dist/esm/address-parser.js
14570
15277
  /**
14571
- * Finds the first address looking token in a run of text.
15278
+ * Any whitespace, the test for an angle-addr that needs recovering
15279
+ */
15280
+ const HAS_WHITESPACE = /\s/;
15281
+ /**
15282
+ * An addr-spec that carries its whitespace legally, inside a quoted local part. The
15283
+ * optional tail is the malformed shape: a real mailbox with wreckage trailing it.
15284
+ */
15285
+ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
15286
+ /**
15287
+ * One run holding a single '@' and no whitespace, the shape an addr-spec has to have
15288
+ */
15289
+ const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
15290
+ /**
15291
+ * The looser reading applied once the strict one finds nothing, which tolerates the
15292
+ * further '@' that a domain should not have but malformed headers carry anyway
15293
+ */
15294
+ const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
15295
+ /**
15296
+ * An addr-spec sitting inside free text, together with the whitespace around it. Sticky
15297
+ * on purpose: it is only run at the offset looseAddressStart picks, see there.
15298
+ */
15299
+ const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
15300
+ /**
15301
+ * Restores the quoting of a local part that was read out of a quoted string.
15302
+ *
15303
+ * RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
15304
+ * on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
15305
+ * domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
15306
+ * same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
15307
+ * recipient separator once the consumer puts the address back into a header.
15308
+ *
15309
+ * @param address Address with an unquoted local part
15310
+ * @return Address with the local part as a quoted-string
15311
+ */
15312
+ function quoteLocalPart(address) {
15313
+ const lastAt = address.lastIndexOf("@");
15314
+ if (lastAt < 0) return address;
15315
+ const user = address.substring(0, lastAt);
15316
+ if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) return address;
15317
+ return "\"" + user.replace(/["\\]/g, "\\$&") + "\"@" + address.substring(lastAt + 1);
15318
+ }
15319
+ /**
15320
+ * The characters JS `\s` matches, which the scan below has to agree with to land on the
15321
+ * same match LOOSE_TEXT_ADDR would.
15322
+ */
15323
+ function isSpaceCode(code) {
15324
+ return code === 32 || code >= 9 && code <= 13 || code === 160 || code === 5760 || code >= 8192 && code <= 8202 || code === 8232 || code === 8233 || code === 8239 || code === 8287 || code === 12288 || code === 65279;
15325
+ }
15326
+ /**
15327
+ * The characters JS `\w` matches without the unicode flag, the set the `\b` in
15328
+ * LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
15329
+ * which compares false throughout, so out of range reads as a non-word character.
15330
+ */
15331
+ function isWordCode(code) {
15332
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95;
15333
+ }
15334
+ /**
15335
+ * Whether `\b` holds at an offset
15336
+ */
15337
+ function isBoundary(text, at) {
15338
+ return isWordCode(text.charCodeAt(at - 1)) !== isWordCode(text.charCodeAt(at));
15339
+ }
15340
+ /**
15341
+ * Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
15342
+ * Bounded rather than indexOf, which would scan on to the end of the header once for every
15343
+ * whitespace delimited run and make the walk below quadratic.
15344
+ */
15345
+ function indexOfAt(text, from, to) {
15346
+ for (let i = from; i < to; i++) if (text.charCodeAt(i) === 64) return i;
15347
+ return -1;
15348
+ }
15349
+ /**
15350
+ * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
15351
+ *
15352
+ * Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
15353
+ * offset and rescans the run to the next '@' each time, so a single header well inside
15354
+ * the default size limit could hold a core busy for minutes. The search is also
15355
+ * unnecessary. '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin
15356
+ * at the head of a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b'
15357
+ * gives characters back until it lands on a boundary, so the only end it can take in that
15358
+ * run is the last boundary in it. Both are found in one pass, and the pattern is then run
15359
+ * at that single offset.
15360
+ *
15361
+ * @param text Free text to look in
15362
+ * @return Offset to match at, or -1
15363
+ */
15364
+ function looseAddressStart(text) {
15365
+ const len = text.length;
15366
+ let pos = 0;
15367
+ while (pos < len) {
15368
+ while (pos < len && isSpaceCode(text.charCodeAt(pos))) pos++;
15369
+ if (pos >= len) break;
15370
+ const runStart = pos;
15371
+ let runEnd = pos;
15372
+ while (runEnd < len && !isSpaceCode(text.charCodeAt(runEnd))) runEnd++;
15373
+ let at = indexOfAt(text, runStart, runEnd);
15374
+ if (at >= 0) {
15375
+ let lastBoundary = -1;
15376
+ for (let k = runEnd; k > runStart; k--) if (isBoundary(text, k)) {
15377
+ lastBoundary = k;
15378
+ break;
15379
+ }
15380
+ let atomStart = runStart;
15381
+ while (lastBoundary >= 0 && at >= 0) {
15382
+ if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
15383
+ for (let start = atomStart; start < at; start++) if (isBoundary(text, start)) {
15384
+ if (start > runStart) return start;
15385
+ let padded = runStart;
15386
+ while (padded > 0 && isSpaceCode(text.charCodeAt(padded - 1))) padded--;
15387
+ return padded;
15388
+ }
15389
+ }
15390
+ atomStart = at + 1;
15391
+ at = indexOfAt(text, atomStart, runEnd);
15392
+ }
15393
+ }
15394
+ pos = runEnd;
15395
+ }
15396
+ return -1;
15397
+ }
15398
+ /**
15399
+ * Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
15400
+ *
15401
+ * A malformed header can put more than a mailbox between the angle brackets, most often
15402
+ * because the generator wrote the recipient twice: '<user@example.com user@example.com>'
15403
+ * or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
15404
+ * never be a mailbox, and passing it on as the address loses the recipient that is
15405
+ * sitting right there in the header.
14572
15406
  *
14573
- * This replaces a `\s*\b[^@\s]+@[^\s]+\b\s*` scan over the whole string, which backtracks
14574
- * quadratically: the leading `\s*` makes every position inside a whitespace run a viable
14575
- * start, and `[^@\s]+` then gives back one character at a time looking for an '@'. A
14576
- * single header well inside the default size limit could hold a core busy for minutes.
15407
+ * The run that still reads as an addr-spec is kept and whatever is left over becomes
15408
+ * display text. Candidates are read strictly first and then under the looser grammar,
15409
+ * the same two tiers the unquoted text fallback applies, so that '<a@b@c.com junk>' and a
15410
+ * bare 'a@b@c.com junk' agree on the recipient.
14577
15411
  *
14578
- * Scanning whitespace delimited tokens instead is linear and keeps the word boundary
14579
- * semantics of the regex: the local part has to open on a word character and the domain
14580
- * has to end on one.
15412
+ * A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
15413
+ * '<"user name"@example.com>' is well formed and means exactly what it says.
14581
15414
  *
14582
- * @param {String} text Text to search
14583
- * @return {Object|null} `{index, length, value}` of the address, or null if there is none
15415
+ * @param address Collected address
15416
+ * @param text Collected display text
15417
+ * @return The address and the display text, with any leftovers moved to the text
14584
15418
  */
14585
- function findAddressInText(text) {
14586
- NON_SPACE_TOKEN_REGEX.lastIndex = 0;
14587
- let match;
14588
- while (match = NON_SPACE_TOKEN_REGEX.exec(text)) {
14589
- const token = match[0];
14590
- const at = token.indexOf("@");
14591
- let start = 0;
14592
- while (start < at && !WORD_CHAR_REGEX.test(token.charAt(start))) start++;
14593
- if (start >= at) continue;
14594
- let end = token.length;
14595
- while (end > at + 1 && !WORD_CHAR_REGEX.test(token.charAt(end - 1))) end--;
14596
- if (end <= at + 1) continue;
14597
- return {
14598
- index: match.index + start,
14599
- length: end - start,
14600
- value: token.substring(start, end)
15419
+ function recoverAddrSpec(address, text) {
15420
+ if (!HAS_WHITESPACE.test(address)) return {
15421
+ address,
15422
+ text
15423
+ };
15424
+ let rest;
15425
+ const quoted = address.match(QUOTED_LOCAL_ADDR);
15426
+ if (quoted) {
15427
+ if (!quoted[2]) return {
15428
+ address,
15429
+ text
14601
15430
  };
15431
+ address = quoted[1];
15432
+ rest = [quoted[2]];
15433
+ } else {
15434
+ if (address.indexOf("\"") >= 0) return {
15435
+ address,
15436
+ text
15437
+ };
15438
+ const parts = address.split(/\s+/);
15439
+ let addrIndex = parts.findIndex((part) => ADDR_SPEC.test(part));
15440
+ if (addrIndex < 0) addrIndex = parts.findIndex((part) => LOOSE_ADDR_SPEC.test(part));
15441
+ if (addrIndex < 0) return {
15442
+ address,
15443
+ text
15444
+ };
15445
+ address = parts.splice(addrIndex, 1)[0];
15446
+ rest = parts;
14602
15447
  }
14603
- return null;
15448
+ return {
15449
+ address,
15450
+ text: [text, ...rest].filter((part) => part).join(" ")
15451
+ };
15452
+ }
15453
+ /**
15454
+ * Takes the RFC 5322 comments out of the contents of an angle-addr.
15455
+ *
15456
+ * The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
15457
+ * a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
15458
+ * put 'user@example.com(x)evil.com' into the address, a value that is no mailbox and that a
15459
+ * consumer stripping the comment reads as 'user@example.comevil.com'. A comment is folding
15460
+ * whitespace, so it is read as a space that splits two atoms and as nothing next to an '@',
15461
+ * the same rule the token walk applies outside the brackets. Quoted strings and domain
15462
+ * literals are copied through untouched, a '(' in there is not a comment.
15463
+ *
15464
+ * @param address Contents of the angle brackets
15465
+ * @return The address with the comments removed, and the text of the comments
15466
+ */
15467
+ function stripAddressComments(address) {
15468
+ const comments = [];
15469
+ let result = "";
15470
+ let comment = "";
15471
+ let depth = 0;
15472
+ let closer = "";
15473
+ let lastChar = "";
15474
+ for (let i = 0, len = address.length; i < len; i++) {
15475
+ const chr = address.charAt(i);
15476
+ if (depth) {
15477
+ if (chr === "\\" && i < len - 1) comment += address.charAt(++i);
15478
+ else if (chr === "(") {
15479
+ depth++;
15480
+ comment += chr;
15481
+ } else if (chr === ")" && !--depth) {
15482
+ comments.push(comment.trim());
15483
+ comment = "";
15484
+ if (lastChar !== "@" && address.charAt(i + 1) !== "@") {
15485
+ result += " ";
15486
+ lastChar = " ";
15487
+ }
15488
+ } else comment += chr;
15489
+ continue;
15490
+ }
15491
+ if (closer) {
15492
+ if (chr === "\\" && closer === "\"" && i < len - 1) {
15493
+ result += chr + address.charAt(++i);
15494
+ lastChar = address.charAt(i);
15495
+ continue;
15496
+ }
15497
+ if (chr === closer) closer = "";
15498
+ } else if (chr === "\"") closer = "\"";
15499
+ else if (chr === "[") closer = "]";
15500
+ else if (chr === "(") {
15501
+ depth = 1;
15502
+ continue;
15503
+ }
15504
+ result += chr;
15505
+ lastChar = chr;
15506
+ }
15507
+ if (depth) comments.push(comment.trim());
15508
+ return {
15509
+ address: result.trim(),
15510
+ comments: comments.filter((text) => text)
15511
+ };
15512
+ }
15513
+ /**
15514
+ * Checks whether text holds an angle bracket address, ie. `<` and `>` around a run
15515
+ * without other angle brackets that has an '@' with at least one character on each side.
15516
+ *
15517
+ * Same answer as `/<[^<>]+@[^<>]+>/.test(text)`, which backtracks quadratically on a `<`
15518
+ * followed by a long run of '@' and no closing `>`.
15519
+ *
15520
+ * @param text Text to check
15521
+ * @return true if an angle bracket address is present
15522
+ */
15523
+ function hasAngleAddress(text) {
15524
+ let start = -1;
15525
+ let at = -1;
15526
+ for (let i = 0; i < text.length; i++) {
15527
+ const chr = text.charAt(i);
15528
+ if (chr === "<") {
15529
+ start = i + 1;
15530
+ at = -1;
15531
+ } else if (chr === ">") {
15532
+ if (at >= 0 && at < i - 1) return true;
15533
+ start = -1;
15534
+ at = -1;
15535
+ } else if (chr === "@" && start >= 0 && at < 0 && i > start) at = i;
15536
+ }
15537
+ return false;
14604
15538
  }
14605
15539
  /**
14606
15540
  * Converts tokens for a single address into an address object
14607
15541
  *
14608
- * @param {Array} tokens Tokens object
14609
- * @param {Number} depth Current recursion depth for nested group protection
14610
- * @return {Object} Address object
15542
+ * @param tokens Tokens object
15543
+ * @param depth Current recursion depth for nested group protection
15544
+ * @return Address objects
14611
15545
  */
14612
15546
  function _handleAddress(tokens, depth) {
14613
15547
  let isGroup = false;
14614
15548
  let state = "text";
14615
- let address;
14616
15549
  let addresses = [];
14617
15550
  let data = {
14618
15551
  address: [],
@@ -14624,6 +15557,12 @@ function _handleAddress(tokens, depth) {
14624
15557
  let i;
14625
15558
  let len;
14626
15559
  let insideQuotes = false;
15560
+ const lastChars = {
15561
+ address: "",
15562
+ comment: "",
15563
+ group: "",
15564
+ text: ""
15565
+ };
14627
15566
  for (i = 0, len = tokens.length; i < len; i++) {
14628
15567
  let token = tokens[i];
14629
15568
  let prevToken = i ? tokens[i - 1] : null;
@@ -14650,14 +15589,17 @@ function _handleAddress(tokens, depth) {
14650
15589
  insideQuotes = false;
14651
15590
  }
14652
15591
  else if (token.value) {
15592
+ const prevPrevToken = i > 1 ? tokens[i - 2] : null;
15593
+ const opensAfterEmptyQuotedString = prevToken?.type === "operator" && prevToken.value === "\"" && !!prevToken.noBreak && prevPrevToken?.type === "operator" && prevPrevToken.value === "\"";
14653
15594
  if (state === "address") token.value = token.value.replace(/^[^<]*<\s*/, "");
14654
- if (prevToken && prevToken.noBreak && data[state].length) {
15595
+ if (prevToken && prevToken.noBreak && data[state].length && (prevToken.value !== ")" || lastChars[state] === "@" || token.value.charAt(0) === "@")) {
14655
15596
  data[state][data[state].length - 1] += token.value;
14656
15597
  if (state === "text" && insideQuotes) data.textWasQuoted[data.textWasQuoted.length - 1] = true;
14657
15598
  } else {
14658
15599
  data[state].push(token.value);
14659
- if (state === "text") data.textWasQuoted.push(insideQuotes);
15600
+ if (state === "text") data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
14660
15601
  }
15602
+ lastChars[state] = token.value.charAt(token.value.length - 1);
14661
15603
  }
14662
15604
  }
14663
15605
  if (!data.text.length && data.comment.length) {
@@ -14665,32 +15607,49 @@ function _handleAddress(tokens, depth) {
14665
15607
  data.comment = [];
14666
15608
  }
14667
15609
  if (isGroup) {
14668
- data.text = data.text.join(" ");
15610
+ const text = data.text.join(" ");
14669
15611
  let groupMembers = [];
14670
- if (data.group.length) addressParser(data.group.join(","), { _depth: depth + 1 }).forEach((member) => {
14671
- if (member.group) groupMembers = groupMembers.concat(member.group);
15612
+ if (data.group.length) parseAddressList$1(data.group.join(","), depth + 1).forEach((member) => {
15613
+ if (member.group) for (const nestedMember of member.group) groupMembers.push(nestedMember);
14672
15614
  else groupMembers.push(member);
14673
15615
  });
14674
15616
  addresses.push({
14675
- name: decodeWords(data.text || address && address.name),
15617
+ name: decodeWords(text),
14676
15618
  group: groupMembers
14677
15619
  });
14678
15620
  } else {
15621
+ const addressComments = [];
15622
+ const addressParts = [];
15623
+ for (const part of data.address) {
15624
+ if (part.indexOf("(") < 0) {
15625
+ addressParts.push(part);
15626
+ continue;
15627
+ }
15628
+ const stripped = stripAddressComments(part);
15629
+ for (const comment of stripped.comments) addressComments.push(comment);
15630
+ if (stripped.address) addressParts.push(stripped.address);
15631
+ }
15632
+ data.address = addressParts;
14679
15633
  if (!data.address.length && data.text.length) {
14680
- for (i = data.text.length - 1; i >= 0; i--) if (!data.textWasQuoted[i] && data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
15634
+ for (i = data.text.length - 1; i >= 0; i--) if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
14681
15635
  data.address = data.text.splice(i, 1);
14682
15636
  data.textWasQuoted.splice(i, 1);
14683
15637
  break;
14684
15638
  }
14685
15639
  if (!data.address.length) {
14686
15640
  for (i = data.text.length - 1; i >= 0; i--) if (!data.textWasQuoted[i]) {
14687
- const found = findAddressInText(data.text[i]);
14688
- if (found) {
14689
- data.address = [found.value];
14690
- data.text[i] = (data.text[i].substring(0, found.index).replace(/\s+$/, "") + " " + data.text[i].substring(found.index + found.length).replace(/^\s+/, "")).trim();
14691
- break;
15641
+ const part = data.text[i];
15642
+ const start = looseAddressStart(part);
15643
+ if (start >= 0) {
15644
+ LOOSE_TEXT_ADDR.lastIndex = start;
15645
+ const match = LOOSE_TEXT_ADDR.exec(part);
15646
+ if (match) {
15647
+ data.address = [match[0].trim()];
15648
+ data.text[i] = (part.slice(0, start) + " " + part.slice(start + match[0].length)).trim();
15649
+ break;
15650
+ }
14692
15651
  }
14693
- data.text[i] = data.text[i].trim();
15652
+ data.text[i] = part.trim();
14694
15653
  }
14695
15654
  }
14696
15655
  }
@@ -14699,25 +15658,33 @@ function _handleAddress(tokens, depth) {
14699
15658
  data.comment = [];
14700
15659
  }
14701
15660
  if (data.address.length > 1) data.text = data.text.concat(data.address.splice(1));
14702
- data.text = data.text.join(" ");
14703
- data.address = data.address.join(" ");
14704
- if (!data.address && isEncodedWordsOnly(data.text.trim())) {
14705
- const decodedText = decodeWords(data.text);
14706
- if (/<[^<>]+@[^<>]+>/.test(decodedText)) {
14707
- const parsedSubAddresses = addressParser(decodedText);
14708
- if (parsedSubAddresses && parsedSubAddresses.length) return parsedSubAddresses;
15661
+ const addressFromQuotedText = !data.address.length && data.textWasQuoted.some((wasQuoted) => wasQuoted);
15662
+ let text = data.text.join(" ");
15663
+ let addressValue = data.address.join(" ");
15664
+ if (!addressValue && isEncodedWordsOnly(text.trim())) {
15665
+ const decodedText = decodeWords(text);
15666
+ if (!addressFromQuotedText && hasAngleAddress(decodedText)) {
15667
+ const parsedSubAddresses = parseAddressList$1(decodedText, depth + 1);
15668
+ if (parsedSubAddresses.length) return parsedSubAddresses;
14709
15669
  }
14710
15670
  return [{
14711
15671
  address: "",
14712
15672
  name: decodedText
14713
15673
  }];
14714
15674
  }
14715
- address = {
14716
- address: data.address || data.text || "",
14717
- name: decodeWords(data.text || data.address || "")
15675
+ if (addressFromQuotedText && text.indexOf("@") >= 0) {
15676
+ addressValue = quoteLocalPart(text);
15677
+ text = "";
15678
+ }
15679
+ ({address: addressValue, text} = recoverAddrSpec(addressValue, text));
15680
+ if (!text && addressComments.length) text = addressComments.join(" ");
15681
+ const rawName = text || addressValue || "";
15682
+ const address = {
15683
+ address: addressValue || text || "",
15684
+ name: decodeWords(rawName)
14718
15685
  };
14719
- if (address.address === address.name) {
14720
- if ((address.address || "").match(/@/)) address.name = "";
15686
+ if (address.address === rawName || address.address === address.name) {
15687
+ if (address.address.match(/@/)) address.name = "";
14721
15688
  else address.address = "";
14722
15689
  }
14723
15690
  addresses.push(address);
@@ -14725,22 +15692,21 @@ function _handleAddress(tokens, depth) {
14725
15692
  return addresses;
14726
15693
  }
14727
15694
  /**
14728
- * Creates a Tokenizer object for tokenizing address field strings
14729
- *
14730
- * @constructor
14731
- * @param {String} str Address field string
15695
+ * Tokenizer for address field strings
14732
15696
  */
14733
15697
  var Tokenizer$2 = class {
15698
+ /**
15699
+ * @param str Address field string
15700
+ */
14734
15701
  constructor(str) {
14735
15702
  this.str = (str || "").toString();
14736
15703
  this.operatorCurrent = "";
14737
15704
  this.operatorExpecting = "";
14738
15705
  this.node = null;
14739
15706
  this.escaped = false;
15707
+ this.inDomainLiteral = false;
15708
+ this.afterAt = false;
14740
15709
  this.list = [];
14741
- /**
14742
- * Operator tokens and which tokens are expected to end the sequence
14743
- */
14744
15710
  this.operators = {
14745
15711
  "\"": "\"",
14746
15712
  "(": ")",
@@ -14753,7 +15719,7 @@ var Tokenizer$2 = class {
14753
15719
  /**
14754
15720
  * Tokenizes the original input string
14755
15721
  *
14756
- * @return {Array} An array of operator|text tokens
15722
+ * @return An array of operator|text tokens
14757
15723
  */
14758
15724
  tokenize() {
14759
15725
  let list = [];
@@ -14771,9 +15737,14 @@ var Tokenizer$2 = class {
14771
15737
  /**
14772
15738
  * Checks if a character is an operator or text and acts accordingly
14773
15739
  *
14774
- * @param {String} chr Character from the address field
15740
+ * @param chr Character from the address field
15741
+ * @param nextChr The character that follows, or null at the end of the field
14775
15742
  */
14776
15743
  checkChar(chr, nextChr) {
15744
+ if (!this.escaped && !this.operatorExpecting) {
15745
+ if (!this.inDomainLiteral && chr === "[" && this.afterAt) this.inDomainLiteral = true;
15746
+ else if (this.inDomainLiteral && (chr === "]" || chr === "," || chr === ";")) this.inDomainLiteral = false;
15747
+ }
14777
15748
  if (this.escaped) {} else if (chr === this.operatorExpecting) {
14778
15749
  this.node = {
14779
15750
  type: "operator",
@@ -14789,20 +15760,22 @@ var Tokenizer$2 = class {
14789
15760
  ].includes(nextChr)) this.node.noBreak = true;
14790
15761
  this.list.push(this.node);
14791
15762
  this.node = null;
15763
+ if (chr !== ")") this.afterAt = false;
14792
15764
  this.operatorExpecting = "";
14793
15765
  this.escaped = false;
14794
15766
  return;
14795
- } else if (!this.operatorExpecting && chr in this.operators) {
15767
+ } else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ":") && chr in this.operators) {
14796
15768
  this.node = {
14797
15769
  type: "operator",
14798
15770
  value: chr
14799
15771
  };
14800
15772
  this.list.push(this.node);
14801
15773
  this.node = null;
15774
+ if (chr !== "(") this.afterAt = false;
14802
15775
  this.operatorExpecting = this.operators[chr];
14803
15776
  this.escaped = false;
14804
15777
  return;
14805
- } else if (this.operatorExpecting === "\"" && chr === "\\") {
15778
+ } else if ((this.operatorExpecting === "\"" || this.operatorExpecting === ")") && chr === "\\") {
14806
15779
  this.escaped = true;
14807
15780
  return;
14808
15781
  }
@@ -14814,7 +15787,10 @@ var Tokenizer$2 = class {
14814
15787
  this.list.push(this.node);
14815
15788
  }
14816
15789
  if (chr === "\n") chr = " ";
14817
- if (chr.charCodeAt(0) >= 33 || [" ", " "].includes(chr)) this.node.value += chr;
15790
+ if (chr.charCodeAt(0) >= 33 || [" ", " "].includes(chr)) {
15791
+ this.node.value += chr;
15792
+ if (!this.operatorExpecting && chr !== " " && chr !== " ") this.afterAt = chr === "@";
15793
+ }
14818
15794
  this.escaped = false;
14819
15795
  }
14820
15796
  };
@@ -14825,29 +15801,20 @@ var Tokenizer$2 = class {
14825
15801
  */
14826
15802
  const MAX_NESTED_GROUP_DEPTH = 50;
14827
15803
  /**
14828
- * Parses structured e-mail addresses from an address field
14829
- *
14830
- * Example:
14831
- *
14832
- * 'Name <address@domain>'
14833
- *
14834
- * will be converted to
14835
- *
14836
- * [{name: 'Name', address: 'address@domain'}]
15804
+ * Parses an address list, recursing into groups. The depth is threaded through the
15805
+ * calls rather than taken from an option, so a caller supplied options object can not
15806
+ * seed it and lift the recursion limit.
14837
15807
  *
14838
- * @param {String} str Address field
14839
- * @param {Object} options Optional options object
14840
- * @param {Number} options._depth Internal recursion depth counter (do not set manually)
14841
- * @return {Array} An array of address objects
15808
+ * @param str Address field
15809
+ * @param depth Current recursion depth for nested group protection
15810
+ * @return An array of address objects
14842
15811
  */
14843
- function addressParser(str, options) {
14844
- options = options || {};
14845
- let depth = options._depth || 0;
15812
+ function parseAddressList$1(str, depth) {
14846
15813
  if (depth > MAX_NESTED_GROUP_DEPTH) return [];
14847
15814
  let tokens = new Tokenizer$2(str).tokenize();
14848
15815
  let addresses = [];
14849
15816
  let address = [];
14850
- let parsedAddresses = [];
15817
+ const parsedAddresses = [];
14851
15818
  tokens.forEach((token) => {
14852
15819
  if (token.type === "operator" && (token.value === "," || token.value === ";")) {
14853
15820
  if (address.length) addresses.push(address);
@@ -14855,11 +15822,34 @@ function addressParser(str, options) {
14855
15822
  } else address.push(token);
14856
15823
  });
14857
15824
  if (address.length) addresses.push(address);
14858
- addresses.forEach((address) => {
14859
- address = _handleAddress(address, depth);
14860
- if (address.length) parsedAddresses = parsedAddresses.concat(address);
14861
- });
14862
- if (options.flatten) {
15825
+ for (const tokens of addresses) for (const address of _handleAddress(tokens, depth)) parsedAddresses.push(address);
15826
+ const mergedAddresses = [];
15827
+ for (let i = parsedAddresses.length - 1; i >= 0; i--) {
15828
+ const current = parsedAddresses[i];
15829
+ const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
15830
+ if (next && current.address === "" && current.name && !current.group && next.address && next.name) next.name = current.name + ", " + next.name;
15831
+ else mergedAddresses.push(current);
15832
+ }
15833
+ return mergedAddresses.reverse();
15834
+ }
15835
+ /**
15836
+ * Parses structured e-mail addresses from an address field
15837
+ *
15838
+ * Example:
15839
+ *
15840
+ * 'Name <address@domain>'
15841
+ *
15842
+ * will be converted to
15843
+ *
15844
+ * [{name: 'Name', address: 'address@domain'}]
15845
+ *
15846
+ * @param str Address field
15847
+ * @param options Optional options object
15848
+ * @return An array of address objects
15849
+ */
15850
+ function addressParser(str, options) {
15851
+ const parsedAddresses = parseAddressList$1(str, 0);
15852
+ if (options && options.flatten) {
14863
15853
  let addresses = [];
14864
15854
  let walkAddressList = (list) => {
14865
15855
  list.forEach((address) => {
@@ -14872,6 +15862,7 @@ function addressParser(str, options) {
14872
15862
  }
14873
15863
  return parsedAddresses;
14874
15864
  }
15865
+ new Uint8Array([10]);
14875
15866
  //#endregion
14876
15867
  //#region ../../node_modules/.pnpm/domelementtype@3.0.0/node_modules/domelementtype/dist/index.js
14877
15868
  /** Types of elements found in htmlparser2's DOM */
@@ -25992,7 +26983,7 @@ function bad(message, hint) {
25992
26983
  /** A bare address, checked and canonicalised. Display names are only accepted on From. */
25993
26984
  function address(value, field) {
25994
26985
  const trimmed = value.trim();
25995
- if (!ADDRESS.test(trimmed)) throw bad(`${field}: "${[...trimmed].slice(0, 80).join("")}" is not an email address`, "Give bare addresses.");
26986
+ if (!ADDRESS.test(trimmed)) throw bad(`${field} contains a value that is not a bare email address`, "Give bare addresses.");
25996
26987
  return canonicalAddress(trimmed);
25997
26988
  }
25998
26989
  function list(values, field) {
@@ -27778,11 +28769,37 @@ function outcomeUnavailable(why) {
27778
28769
  //#endregion
27779
28770
  //#region src/operations/send.ts
27780
28771
  /**
28772
+ * A caller may hand back the canonical expectation it kept, or the display-safe `expect` that prepare returned. The
28773
+ * latter is accepted only when it is byte-for-byte this approval's field rendering under the boundary it carries, the
28774
+ * way Gmail accepts its own; then the claim compares the raw binding. Anything else is passed on as given, so an
28775
+ * invented or edited envelope still mismatches and the send is refused.
28776
+ */
28777
+ function canonicalSuppliedExpectation(supplied, bound, account, id) {
28778
+ const boundary = /^<untrusted-content boundary="([A-Za-z0-9_-]+)" field="subject"/.exec(supplied.subject)?.[1];
28779
+ if (boundary === void 0) return supplied;
28780
+ const displayed = expectationFields(bound, {
28781
+ boundary,
28782
+ account,
28783
+ id
28784
+ });
28785
+ const same = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
28786
+ return displayed.subject === supplied.subject && same(displayed.to, supplied.to) && same(displayed.cc, supplied.cc) && same(displayed.bcc, supplied.bcc) ? bound : supplied;
28787
+ }
28788
+ /**
27781
28789
  * A used approval, as Resend's own surfaces refuse it (design 2026-10-05 §D2, the used-send row): Resend accepted the
27782
28790
  * email — which is not that it went, since one may be scheduled, bounce or be cancelled — and never "sent". The words
27783
28791
  * `resend_send_wait` shows for it too, core's for every provider.
27784
28792
  */
27785
28793
  const ACCEPTED_BY_RESEND = (usedAt) => `accepted by Resend at ${usedAt}`;
28794
+ /** A send's recipients and subject as Resend may return them: display-safe, never the raw approval binding. */
28795
+ function expectationFields(expect, envelope) {
28796
+ return {
28797
+ to: expect.to.map((address) => addressField(address, "to", envelope)),
28798
+ cc: expect.cc.map((address) => addressField(address, "cc", envelope)),
28799
+ bcc: expect.bcc.map((address) => addressField(address, "bcc", envelope)),
28800
+ subject: wrapField(expect.subject, "subject", envelope)
28801
+ };
28802
+ }
27786
28803
  /**
27787
28804
  * A send's approval as the one account that may use it, classified under its lock as the claim would classify it
27788
28805
  * (design 2026-10-05 §D2), or a refusal that does not touch it.
@@ -28043,9 +29060,13 @@ async function prepareSend(context, name, input) {
28043
29060
  effectivePolicy: effective,
28044
29061
  riskFlags: study.flags,
28045
29062
  reach,
28046
- expect: record.expect,
29063
+ expect: expectationFields(record.expect, {
29064
+ boundary: newBoundary(),
29065
+ account: name,
29066
+ id: record.approvalId
29067
+ }),
28047
29068
  expiresAt: record.expiresAt,
28048
- nextStep: effective === "confirm" ? `${approveAndWaitSentence(context.handoffs, context.surface, record.approvalId, (command, wait) => `Show the preview to the user, then have them run ${command} in their own terminal${wait === void 0 ? "" : `; learn when they have with ${wait}`}.`, { instead: "Show the preview to the user; they approve it at their own terminal." })} You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above.",
29069
+ nextStep: effective === "confirm" ? `${approveAndWaitSentence(context.handoffs, context.surface, record.approvalId, (command, wait) => `Show the preview to the user, then have them run ${command} in their own terminal${wait === void 0 ? "" : `; learn when they have with ${wait}`}.`, { instead: "Show the preview to the user; they approve it at their own terminal." })} You cannot approve this yourself. Then execute it with the same approval id.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id.",
28049
29070
  approval: await context.core.approvals.approvalOf(record),
28050
29071
  ...legacyDrain === void 0 ? {} : { legacyDrain }
28051
29072
  };
@@ -28236,7 +29257,7 @@ async function claimAndSend(context, named, name, options, known, _seen, legacyD
28236
29257
  contentDigest: digest,
28237
29258
  inboxId: named.account.id,
28238
29259
  inboxSub: named.account.userId,
28239
- expect: options.expect
29260
+ expect: options.expect === void 0 ? known.expect : canonicalSuppliedExpectation(options.expect, known.expect, name, known.approvalId)
28240
29261
  }, {
28241
29262
  pendingHint: `${approveAndWaitSentence(context.handoffs, context.surface, options.approvalId, (command, wait) => `Ask the user to run ${command} in their own terminal${wait === void 0 ? "," : `; learn when they have with ${wait},`} then execute it again with the same approval.`, { instead: "Ask the user to approve it at their own terminal, then execute it again with the same approval." })} You cannot approve it yourself.`,
28242
29263
  usedSaid: ACCEPTED_BY_RESEND,
@@ -28396,16 +29417,26 @@ async function claimAndSend(context, named, name, options, known, _seen, legacyD
28396
29417
  state: message.scheduledAt ? "scheduled" : "sent",
28397
29418
  scheduledAt: message.scheduledAt,
28398
29419
  said: acceptedSaid(resendId !== void 0, message.scheduledAt),
28399
- to: claimed.expect.to,
28400
- cc: claimed.expect.cc,
28401
- bcc: claimed.expect.bcc,
28402
- subject: claimed.expect.subject,
29420
+ ...expectationFields(claimed.expect, {
29421
+ boundary: newBoundary(),
29422
+ account: name,
29423
+ id: options.approvalId
29424
+ }),
28403
29425
  ...unrecorded.length > 0 ? { note: unrecorded.join("; ") } : {},
28404
29426
  approval: approval ?? await approvalNow(context, options.approvalId, named.account.id, claimedApproval),
28405
29427
  ...legacyDrain === void 0 ? {} : { legacyDrain }
28406
29428
  };
28407
29429
  });
28408
29430
  }
29431
+ /** A stored send as a result may show it: canonical recipients stay addresses; its subject is always prose. */
29432
+ function sendSummaryFields(summary, envelope) {
29433
+ if (summary === null) return null;
29434
+ return {
29435
+ ...summary,
29436
+ recipients: summary.recipients.map((address) => addressField(address, "recipient", envelope)),
29437
+ ...summary.subject === void 0 ? {} : { subject: wrapField(summary.subject, "subject", envelope) }
29438
+ };
29439
+ }
28409
29440
  const CANNOT_READ = "this key can only send, so it cannot read what Resend did with the email";
28410
29441
  /**
28411
29442
  * What this machine knows Resend accepted, from its own send record: a scheduled send is accepted for the time the
@@ -28439,11 +29470,16 @@ async function sendStatus(context, name, approvalId) {
28439
29470
  throw error;
28440
29471
  });
28441
29472
  const stored = seen?.stored ?? null;
29473
+ const envelope = {
29474
+ boundary: newBoundary(),
29475
+ account: name,
29476
+ id: approvalId
29477
+ };
28442
29478
  const base = {
28443
29479
  account: name,
28444
29480
  approvalId,
28445
- approval: seen ? publicApproval(seen.stored, seen.outcome) : null,
28446
- local
29481
+ approval: seen ? publicApproval(seen.stored, seen.outcome, { wrap: (text, field) => field === "subject" || field === "filename" ? wrapField(text, field, envelope) : addressField(text, field, envelope) }) : null,
29482
+ local: sendSummaryFields(local, envelope)
28447
29483
  };
28448
29484
  if (!local) return {
28449
29485
  ...base,