@agentcomms/resend 0.10.0 → 0.12.0

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.
@@ -5,14 +5,14 @@ import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { homedir, platform, userInfo } from "node:os";
8
+ import { constants as constants$1, realpathSync } from "node:fs";
8
9
  import { execFile, spawn } from "node:child_process";
9
10
  import "node:readline/promises";
10
11
  import { styleText } from "node:util";
11
- import { constants as constants$1 } from "node:fs";
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.10.0";
15
+ const VERSION$1 = "0.12.0";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -187,7 +187,7 @@ function refusedBecause(method, pathname) {
187
187
  return null;
188
188
  }
189
189
  //#endregion
190
- //#region ../core/dist/update-gate-BiceOnzC.mjs
190
+ //#region ../core/dist/update-check-pKViNOsH.mjs
191
191
  /**
192
192
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
193
193
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -1196,74 +1196,60 @@ function connectMailboxCommand() {
1196
1196
  return mail ? `${mail.binary} inbox add` : void 0;
1197
1197
  }
1198
1198
  /**
1199
- * The grammar of an account name in version 2 of the config: `<organisation>/<platform>[-<qualifier>]`.
1200
- *
1201
- * `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
1202
- * first half says who an account belongs to and the second what it is — and the schema checks the second half against
1203
- * the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
1204
- *
1205
- * Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
1206
- * without depending on each other.
1207
- */
1208
- /**
1209
- * Organisation names Windows cannot use as a directory.
1210
- *
1211
- * The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
1212
- * device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
1213
- * first time somebody on Windows saves an attachment.
1199
+ * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
1200
+ * and the preview renderer (which makes them visible), so the two can never disagree.
1214
1201
  */
1215
- const WINDOWS_RESERVED$1 = [
1216
- "con",
1217
- "prn",
1218
- "aux",
1219
- "nul",
1220
- ...range$1("com"),
1221
- ...range$1("lpt")
1202
+ /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
1203
+ const INVISIBLE_RANGES = [
1204
+ [173, 173],
1205
+ [847, 847],
1206
+ [1564, 1564],
1207
+ [4447, 4448],
1208
+ [6068, 6069],
1209
+ [6155, 6159],
1210
+ [8203, 8207],
1211
+ [8232, 8233],
1212
+ [8234, 8238],
1213
+ [8288, 8292],
1214
+ [8294, 8303],
1215
+ [12644, 12644],
1216
+ [65024, 65039],
1217
+ [65279, 65279],
1218
+ [65440, 65440],
1219
+ [917504, 917631]
1222
1220
  ];
1223
- function range$1(prefix) {
1224
- return Array.from({ length: 9 }, (_, index) => `${prefix}${index + 1}`);
1221
+ function isInvisible(codePoint) {
1222
+ for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
1223
+ return false;
1225
1224
  }
1226
- const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
1227
- const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
1228
1225
  /**
1229
- * The whole grammar as one expression, so a record key can be checked by the schema without a second pass.
1230
- *
1231
- * Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
1232
- * `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
1226
+ * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
1227
+ * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
1233
1228
  */
1234
- const NAME_PATTERN = new RegExp(`^(?!(?:${WINDOWS_RESERVED$1.join("|")})/)(${ORG})/(${PLATFORM$1})(?:-([a-z0-9](?:[a-z0-9-]{0,14}[a-z0-9])?))?$`);
1235
- /** A platform word on its own — `gmail`, `slack` — which is also a channel's word in its manifest. */
1236
- const PLATFORM_PATTERN = new RegExp(`^${PLATFORM$1}$`);
1237
- const NAME_MESSAGE = "names look like organisation/platform, optionally with a qualifier: cue/gmail, wf/gmail-tech, cue/slack";
1238
- /** The three parts of a valid name, or null for anything the grammar refuses. */
1239
- function parseName(name) {
1240
- const match = NAME_PATTERN.exec(name);
1241
- if (!match) return null;
1242
- const [, org = "", platform = "", qualifier] = match;
1243
- return qualifier === void 0 ? {
1244
- org,
1245
- platform
1246
- } : {
1247
- org,
1248
- platform,
1249
- qualifier
1250
- };
1229
+ function isControl(codePoint) {
1230
+ if (codePoint === 9 || codePoint === 10) return false;
1231
+ return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
1232
+ }
1233
+ function isDangerous(codePoint) {
1234
+ return isControl(codePoint) || isInvisible(codePoint);
1251
1235
  }
1252
1236
  /**
1253
- * Why a name is refused, in words a person can act on — or null when it is fine.
1237
+ * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
1238
+ * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1254
1239
  *
1255
- * `platform` is what the account actually is. Checked here as well as in the schema, so the error arrives when the
1256
- * name is proposed rather than as a refused config write after a sign-in has already been spent.
1240
+ * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
1241
+ * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
1242
+ * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1257
1243
  */
1258
- function nameShapeProblem(name, platform) {
1259
- const parsed = parseName(name);
1260
- if (!parsed) {
1261
- const org = name.split("/")[0] ?? "";
1262
- if (WINDOWS_RESERVED$1.includes(org)) return `"${org}" cannot be an organisation name: Windows reserves it, and the organisation becomes a folder`;
1263
- return `"${name}" is not a valid name: it should be the organisation, a slash, then ${platform} — for example acme/${platform}, or acme/${platform}-support for a second one`;
1264
- }
1265
- if (parsed.platform !== platform) return `"${name}" ends in /${parsed.platform}, but this is a ${platform} account`;
1266
- return null;
1244
+ function stripInvisible(text) {
1245
+ let removed = 0;
1246
+ let out = "";
1247
+ for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1248
+ else out += char;
1249
+ return {
1250
+ text: out,
1251
+ removed
1252
+ };
1267
1253
  }
1268
1254
  const APP_DIR_NAME = "agent-communications";
1269
1255
  /**
@@ -1327,6 +1313,23 @@ function expandHome(path, home = homedir(), joinPaths = join) {
1327
1313
  return path;
1328
1314
  }
1329
1315
  /**
1316
+ * A path as a preview shows it: the home at its start written `~`, the way a person reads and types it. The inverse of
1317
+ * `expandHome`, and like it, nothing else is touched. Case is ignored on Windows, whose paths are not case-sensitive.
1318
+ */
1319
+ function shortenHome(path, home, platform = process.platform) {
1320
+ if (!home) return path;
1321
+ const same = (a, b) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
1322
+ let end = home.length;
1323
+ while (end > 0 && (home[end - 1] === "/" || home[end - 1] === "\\")) end -= 1;
1324
+ const root = home.slice(0, end);
1325
+ if (same(path, root)) return "~";
1326
+ for (const separator of platform === "win32" ? ["\\", "/"] : ["/"]) {
1327
+ const prefix = `${root}${separator}`;
1328
+ if (path.length > prefix.length && same(path.slice(0, prefix.length), prefix)) return `~${separator}${path.slice(prefix.length)}`;
1329
+ }
1330
+ return path;
1331
+ }
1332
+ /**
1330
1333
  * The user's home directory, read from an environment.
1331
1334
  *
1332
1335
  * Windows does not set `HOME`; it sets `USERPROFILE`. Call sites that wrote `env.HOME ?? ''` and handed the result to
@@ -1341,6 +1344,265 @@ function expandHome(path, home = homedir(), joinPaths = join) {
1341
1344
  function homeDirectory(env = process.env) {
1342
1345
  return env.HOME || env.USERPROFILE || homedir();
1343
1346
  }
1347
+ const WINDOWS_RESERVED$1 = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
1348
+ const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
1349
+ /** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
1350
+ function truncateBytes(value, maxBytes) {
1351
+ let out = "";
1352
+ let bytes = 0;
1353
+ for (const char of value) {
1354
+ const size = Buffer.byteLength(char, "utf8");
1355
+ if (bytes + size > maxBytes) break;
1356
+ out += char;
1357
+ bytes += size;
1358
+ }
1359
+ return out;
1360
+ }
1361
+ /**
1362
+ * A file name that is safe to create on macOS, Linux and Windows: NFC-normalised, no separators or control
1363
+ * characters, no leading or trailing dots and spaces, not a Windows reserved device name, at most `maxBytes` UTF-8
1364
+ * bytes with the extension kept. Never empty.
1365
+ */
1366
+ function safeFilename(name, maxBytes = 255, fallback = "attachment") {
1367
+ let cleaned = [...name.normalize("NFC")].filter((character) => !isDangerous(character.codePointAt(0) ?? 0)).join("").replace(UNSAFE_FILENAME_CHARS, "_").replace(/\s+/g, " ");
1368
+ cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
1369
+ if (cleaned === "") cleaned = fallback;
1370
+ const extension = extname(cleaned);
1371
+ if (WINDOWS_RESERVED$1.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
1372
+ if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
1373
+ const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
1374
+ return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
1375
+ }
1376
+ /** True when `candidate` is `root` itself or strictly inside it (lexically). */
1377
+ function isInside(candidate, root) {
1378
+ const path = relative(resolve(root), resolve(candidate));
1379
+ return path === "" || !path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path);
1380
+ }
1381
+ async function realpathOfExistingAncestor(path) {
1382
+ let current = resolve(path);
1383
+ const tail = [];
1384
+ for (;;) try {
1385
+ const real = await realpath(current);
1386
+ return tail.length ? join(real, ...tail.reverse()) : real;
1387
+ } catch (error) {
1388
+ const parent = dirname(current);
1389
+ if (error.code !== "ENOENT" || parent === current) throw error;
1390
+ tail.push(basename(current));
1391
+ current = parent;
1392
+ }
1393
+ }
1394
+ /**
1395
+ * Resolves `target` for writing and proves it stays inside `root` after symlinks in its existing ancestors are
1396
+ * followed. Throws BAD_DATA otherwise. The root must already exist.
1397
+ */
1398
+ async function resolveInsideRoot(root, target) {
1399
+ const realRoot = await realpath(root);
1400
+ const lexical = resolve(root, target);
1401
+ if (!isInside(lexical, root)) throw new CommsError("BAD_DATA", `refusing to write outside ${root}`, { details: { target } });
1402
+ if (!isInside(await realpathOfExistingAncestor(lexical), realRoot)) throw new CommsError("BAD_DATA", `refusing to write through a link that leaves ${root}`, { details: { target } });
1403
+ return lexical;
1404
+ }
1405
+ /**
1406
+ * Creates a new file for writing without following a symlink at the final component and without overwriting an
1407
+ * existing file. Picks `name-2.ext`, `name-3.ext`… when the name is taken.
1408
+ */
1409
+ async function createUniqueFile(directory, filename) {
1410
+ const extension = extname(filename);
1411
+ const stem = extension ? filename.slice(0, -extension.length) : filename;
1412
+ const noFollow = process.platform === "win32" ? 0 : constants$1.O_NOFOLLOW;
1413
+ for (let attempt = 1; attempt < 1e3; attempt += 1) {
1414
+ const name = attempt === 1 ? filename : `${stem}-${attempt}${extension}`;
1415
+ const path = join(directory, name);
1416
+ try {
1417
+ return {
1418
+ path,
1419
+ handle: await open(path, constants$1.O_WRONLY | constants$1.O_CREAT | constants$1.O_EXCL | noFollow, 384)
1420
+ };
1421
+ } catch (error) {
1422
+ if (error.code === "EEXIST") continue;
1423
+ throw error;
1424
+ }
1425
+ }
1426
+ throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
1427
+ }
1428
+ /**
1429
+ * Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
1430
+ * directly under home — SSH, cloud, npm, git and shell credentials, agent configs; `**∕.git/**` any repository's git
1431
+ * directory; `**∕.env*` dotenv files anywhere.
1432
+ */
1433
+ function defaultAttachDeny(configDir, env = process.env) {
1434
+ const deny = [
1435
+ configDir,
1436
+ "~/.*",
1437
+ "~/Library",
1438
+ "**/.git/**",
1439
+ "**/.env*"
1440
+ ];
1441
+ if (env.APPDATA) deny.push(env.APPDATA);
1442
+ if (env.LOCALAPPDATA) deny.push(env.LOCALAPPDATA);
1443
+ return deny;
1444
+ }
1445
+ async function realOrResolved(path) {
1446
+ try {
1447
+ return await realpath(path);
1448
+ } catch {
1449
+ return resolve(path);
1450
+ }
1451
+ }
1452
+ /**
1453
+ * Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
1454
+ * drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
1455
+ * current when it is read, and `C:outgoing` is relative to that drive's current folder; either would move with the
1456
+ * process that reads it. A folder in the configuration that does not name its place — written by hand before 0.12.0,
1457
+ * say — allows nothing, and `agentcomms attach` takes none (#45).
1458
+ */
1459
+ function namesItsPlace(path, platform = process.platform) {
1460
+ if (path === "~" || path.startsWith("~/") || path.startsWith("~\\")) return true;
1461
+ if (platform === "win32") return /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/?.][^\\/]*[\\/][^\\/]+/.test(path);
1462
+ return isAbsolute(path);
1463
+ }
1464
+ /**
1465
+ * Proves a local file may be attached to a draft: it resolves (following links) to a regular file inside one of the
1466
+ * allowed roots and inside none of the deny entries. A deny entry of the form `**` + `/name*` matches by file name
1467
+ * prefix (so `.env*` matches `.env` and `.env.local`). Returns the real path.
1468
+ */
1469
+ async function checkAttachable(path, policy) {
1470
+ const home = policy.home;
1471
+ const requested = resolve(expandHome(path, home));
1472
+ let real;
1473
+ try {
1474
+ real = await realpath(requested);
1475
+ } catch {
1476
+ throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
1477
+ }
1478
+ if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
1479
+ if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Copy the file under your home folder — not into one of its hidden folders — and name the copy instead, or allow its folder with `agentcomms attach roots add <folder>` (needs your approval)." });
1480
+ const name = basename(real);
1481
+ const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
1482
+ for (const entry of policy.deny) {
1483
+ if (entry === "~/.*") {
1484
+ if (isInside(real, homeDir)) {
1485
+ const first = relative(homeDir, real).split(sep)[0] ?? "";
1486
+ if (first.startsWith(".")) throw new CommsError("BAD_DATA", `refusing to attach a file from ~/${first}: hidden folders in your home are never attached`);
1487
+ }
1488
+ continue;
1489
+ }
1490
+ if (entry === "**/.git/**") {
1491
+ if (real.split(sep).some((segment) => segment.toLowerCase() === ".git")) throw new CommsError("BAD_DATA", "refusing to attach a file from a .git folder");
1492
+ continue;
1493
+ }
1494
+ if (entry.startsWith("**/")) {
1495
+ const pattern = entry.slice(3).toLowerCase();
1496
+ const candidate = name.toLowerCase();
1497
+ const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
1498
+ if (pattern.endsWith("*") ? candidate.startsWith(prefix) : candidate === prefix) throw new CommsError("BAD_DATA", `refusing to attach ${name}: files matching ${entry} are never attached`);
1499
+ continue;
1500
+ }
1501
+ const denied = await realOrResolved(expandHome(entry, home));
1502
+ if (isInside(real, denied)) throw new CommsError("BAD_DATA", `refusing to attach a file from ${entry}`);
1503
+ }
1504
+ return real;
1505
+ }
1506
+ /**
1507
+ * A caller-supplied subdirectory, checked before it is joined to anything.
1508
+ *
1509
+ * `path.join` is not a boundary, and it was being used as one. `join('work', '../personal')` is `'personal'`: the
1510
+ * alias segment is cancelled, the result still resolves inside the downloads root, so `resolveInsideRoot` allows it
1511
+ * — and one mailbox's files are written into another mailbox's folder, over the `manifest.json` that is that
1512
+ * mailbox's own record of where its attachments came from. `join('work', '/etc/cron.d')` is `'work/etc/cron.d'`:
1513
+ * the leading separator is simply dropped, so an absolute path that every document describes as refused is quietly
1514
+ * accepted under a name the caller never asked for.
1515
+ *
1516
+ * Neither is an escape from the root, which is why neither showed up as a jail failure. In one respect they are
1517
+ * worse than an escape: they succeed, and report a path the caller was never told about.
1518
+ *
1519
+ * So the check happens here, on the caller's own string, before any join — absolute paths and `..` are refused
1520
+ * rather than normalised away. A nested `reports/august` is fine; that is what the option is for.
1521
+ */
1522
+ function relativeSubpath(out, field = "out") {
1523
+ const value = (out ?? "").trim();
1524
+ if (!value) return "";
1525
+ const refuse = (why) => {
1526
+ throw new CommsError("BAD_DATA", `${field} must be a relative subpath: ${why}`, {
1527
+ hint: "Pass something like \"reports/august\". It is always placed inside this account’s own folder.",
1528
+ details: { value }
1529
+ });
1530
+ };
1531
+ if (isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("/") || value.startsWith("\\")) refuse("it is an absolute path");
1532
+ const segments = value.split(/[/\\]+/);
1533
+ if (segments.includes("..")) refuse("it climbs out with \"..\"");
1534
+ return segments.filter((segment) => segment && segment !== ".").join("/");
1535
+ }
1536
+ /**
1537
+ * The grammar of an account name in version 2 of the config: `<organisation>/<platform>[-<qualifier>]`.
1538
+ *
1539
+ * `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
1540
+ * first half says who an account belongs to and the second what it is — and the schema checks the second half against
1541
+ * the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
1542
+ *
1543
+ * Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
1544
+ * without depending on each other.
1545
+ */
1546
+ /**
1547
+ * Organisation names Windows cannot use as a directory.
1548
+ *
1549
+ * The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
1550
+ * device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
1551
+ * first time somebody on Windows saves an attachment.
1552
+ */
1553
+ const WINDOWS_RESERVED = [
1554
+ "con",
1555
+ "prn",
1556
+ "aux",
1557
+ "nul",
1558
+ ...range$1("com"),
1559
+ ...range$1("lpt")
1560
+ ];
1561
+ function range$1(prefix) {
1562
+ return Array.from({ length: 9 }, (_, index) => `${prefix}${index + 1}`);
1563
+ }
1564
+ const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
1565
+ const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
1566
+ /**
1567
+ * The whole grammar as one expression, so a record key can be checked by the schema without a second pass.
1568
+ *
1569
+ * Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
1570
+ * `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
1571
+ */
1572
+ const NAME_PATTERN = new RegExp(`^(?!(?:${WINDOWS_RESERVED.join("|")})/)(${ORG})/(${PLATFORM$1})(?:-([a-z0-9](?:[a-z0-9-]{0,14}[a-z0-9])?))?$`);
1573
+ /** A platform word on its own — `gmail`, `slack` — which is also a channel's word in its manifest. */
1574
+ const PLATFORM_PATTERN = new RegExp(`^${PLATFORM$1}$`);
1575
+ const NAME_MESSAGE = "names look like organisation/platform, optionally with a qualifier: cue/gmail, wf/gmail-tech, cue/slack";
1576
+ /** The three parts of a valid name, or null for anything the grammar refuses. */
1577
+ function parseName(name) {
1578
+ const match = NAME_PATTERN.exec(name);
1579
+ if (!match) return null;
1580
+ const [, org = "", platform = "", qualifier] = match;
1581
+ return qualifier === void 0 ? {
1582
+ org,
1583
+ platform
1584
+ } : {
1585
+ org,
1586
+ platform,
1587
+ qualifier
1588
+ };
1589
+ }
1590
+ /**
1591
+ * Why a name is refused, in words a person can act on — or null when it is fine.
1592
+ *
1593
+ * `platform` is what the account actually is. Checked here as well as in the schema, so the error arrives when the
1594
+ * name is proposed rather than as a refused config write after a sign-in has already been spent.
1595
+ */
1596
+ function nameShapeProblem(name, platform) {
1597
+ const parsed = parseName(name);
1598
+ if (!parsed) {
1599
+ const org = name.split("/")[0] ?? "";
1600
+ if (WINDOWS_RESERVED.includes(org)) return `"${org}" cannot be an organisation name: Windows reserves it, and the organisation becomes a folder`;
1601
+ return `"${name}" is not a valid name: it should be the organisation, a slash, then ${platform} — for example acme/${platform}, or acme/${platform}-support for a second one`;
1602
+ }
1603
+ if (parsed.platform !== platform) return `"${name}" ends in /${parsed.platform}, but this is a ${platform} account`;
1604
+ return null;
1605
+ }
1344
1606
  /**
1345
1607
  * Whether this release may write version 2 of the config.
1346
1608
  *
@@ -1947,8 +2209,17 @@ const POLICY_RANK$1 = {
1947
2209
  * user a consent prompt for a change that is not one. Linux is case-sensitive, so case is kept.
1948
2210
  */
1949
2211
  function normalisePath(path) {
1950
- const expanded = withoutTrailingSeparators(resolve(expandHome(path.trim(), homedir())));
1951
- return platform() === "darwin" || platform() === "win32" ? expanded.toLowerCase() : expanded;
2212
+ return comparablePath(resolve(expandHome(path.trim(), homedir())));
2213
+ }
2214
+ /**
2215
+ * An absolute path in the form two paths are compared in here: no trailing separator, and case folded on macOS and
2216
+ * Windows, whose filesystems are case-insensitive by default (see `normalisePath`). The top of a disk keeps its
2217
+ * separator — `/`, `C:\` — so it is not read as no path at all.
2218
+ */
2219
+ function comparablePath(absolute) {
2220
+ const stripped = withoutTrailingSeparators(absolute);
2221
+ const kept = stripped === "" || /^[A-Za-z]:$/.test(stripped) ? absolute.slice(0, stripped.length + 1) : stripped;
2222
+ return platform() === "darwin" || platform() === "win32" ? kept.toLowerCase() : kept;
1952
2223
  }
1953
2224
  /**
1954
2225
  * The path with any separators at its end removed. A loop, not `/[/\\]+$/`: that pattern backtracks over every run of
@@ -1963,9 +2234,14 @@ function withoutTrailingSeparators(path) {
1963
2234
  function holdsSecrets(config) {
1964
2235
  return Object.values(config.clients).some((client) => Boolean(client.secretRef)) || Object.values(config.inboxes).some((inbox) => Boolean(inbox.secretRef)) || Object.values(config.accounts).some((account) => Boolean(account.secretRef));
1965
2236
  }
1966
- /** True when `candidate` is the same directory as `parent`, or inside it. Both may be unset. */
2237
+ /**
2238
+ * True when `candidate` is the same directory as `parent`, or inside it. Both may be unset. Both are compared as
2239
+ * written, so both have to be in one form first: `normalisePath`, or `comparablePath` of a real path. A parent that is
2240
+ * the top of a disk — `/`, `c:\` — holds everything on it.
2241
+ */
1967
2242
  function isInsideDirectory(candidate, parent) {
1968
2243
  if (!candidate || !parent) return false;
2244
+ if (parent.endsWith("/") || parent.endsWith("\\")) return candidate.startsWith(parent);
1969
2245
  return candidate === parent || candidate.startsWith(`${parent}/`) || candidate.startsWith(`${parent}\\`);
1970
2246
  }
1971
2247
  /**
@@ -2038,10 +2314,11 @@ function classifyChange(before, after) {
2038
2314
  if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
2039
2315
  if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
2040
2316
  if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
2041
- const roots = (list) => new Set(list.map(normalisePath));
2042
- const before_roots = roots(b.attachRoots);
2317
+ const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
2318
+ const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
2319
+ const before_roots = readRoots(b.attachRoots);
2043
2320
  const after_deny = roots(a.attachDeny);
2044
- if ([...roots(a.attachRoots)].some((root) => !before_roots.has(root))) loosen("defaults.attachRoots", b.attachRoots, a.attachRoots);
2321
+ if ([...readRoots(a.attachRoots)].some((root) => !before_roots.has(root))) loosen("defaults.attachRoots", b.attachRoots, a.attachRoots);
2045
2322
  if ([...roots(b.attachDeny)].some((deny) => !after_deny.has(deny))) loosen("defaults.attachDeny", b.attachDeny, a.attachDeny);
2046
2323
  const downloadsBefore = b.downloadsDir === void 0 ? void 0 : normalisePath(b.downloadsDir);
2047
2324
  const downloadsAfter = a.downloadsDir === void 0 ? void 0 : normalisePath(a.downloadsDir);
@@ -3900,7 +4177,7 @@ async function installManagedRuntime(context, product, version) {
3900
4177
  private: true
3901
4178
  }, null, 2)}\n`);
3902
4179
  const npmCli = await findNpmCli();
3903
- await run(process.execPath, [
4180
+ await run(context.env, process.execPath, [
3904
4181
  npmCli,
3905
4182
  "install",
3906
4183
  "--prefix",
@@ -3924,10 +4201,12 @@ async function findNpmCli() {
3924
4201
  throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
3925
4202
  }
3926
4203
  /**
3927
- * A command run to its end. Its environment is this process's, and on Windows also tells it never to take a program
3928
- * from its current folder: `npm` and a client's command both start programs of their own by name.
4204
+ * A command run to its end, in `env`: the environment the install resolved its target from — which client config,
4205
+ * which client CLI, which node — and not this process's, which may be another. On Windows it also tells the command
4206
+ * never to take a program from its current folder: `npm` and a client's command both start programs of their own by
4207
+ * name.
3929
4208
  */
3930
- function run(command, args) {
4209
+ function run(env, command, args) {
3931
4210
  return new Promise((resolvePromise, reject) => {
3932
4211
  const child = spawn(command, args, {
3933
4212
  stdio: [
@@ -3935,7 +4214,7 @@ function run(command, args) {
3935
4214
  "ignore",
3936
4215
  "pipe"
3937
4216
  ],
3938
- env: childEnvironment(process.env)
4217
+ env: childEnvironment(env)
3939
4218
  });
3940
4219
  let stderr = "";
3941
4220
  child.stderr?.on("data", (chunk) => {
@@ -3948,14 +4227,19 @@ function run(command, args) {
3948
4227
  });
3949
4228
  });
3950
4229
  }
3951
- async function buildEntry(context, product, options, apply) {
4230
+ /**
4231
+ * The entry for one server, starting the command its preflight resolved (`resolved`): what the plan named and the
4232
+ * person approved. Nothing here looks on PATH again — a node or an npx that turned up since would be another command
4233
+ * than the one the preview said.
4234
+ */
4235
+ async function buildEntry(context, product, options, apply, resolved) {
3952
4236
  const launcher = options.launcher ?? "managed";
3953
- const node = await resolveNode(context.env);
4237
+ const { node } = resolved;
3954
4238
  const env = minimalEnv(context, node);
3955
4239
  const serverArgs = product.serverArgs(options);
3956
4240
  if (launcher === "npx") return {
3957
4241
  entry: {
3958
- command: await whichExecutable("npx", context.env) ?? "npx",
4242
+ command: resolved.command,
3959
4243
  args: [
3960
4244
  "-y",
3961
4245
  `${product.npxPackage}@${product.version}`,
@@ -4099,6 +4383,13 @@ function claimName(product, options, name, existing) {
4099
4383
  throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name: \`${installCommand$1(product, options, other)}\`. --force does not replace a server this did not install.` });
4100
4384
  }
4101
4385
  if (taken.length > 0 && !options.force) {
4386
+ for (const pin of PIN_OPTIONS) {
4387
+ const wanted = options[pin];
4388
+ const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
4389
+ if (!wanted || !served || served === wanted) continue;
4390
+ const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
4391
+ throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: \`${installCommand$1(product, options, second === name ? `${name}-2` : second)}\`.` });
4392
+ }
4102
4393
  const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
4103
4394
  const again = {
4104
4395
  ...keepNarrowing(product, options, taken).options,
@@ -4164,9 +4455,10 @@ function installCommand$1(product, options, name, extra = []) {
4164
4455
  }
4165
4456
  /**
4166
4457
  * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
4167
- * `run` starts one, so that on Windows it takes no program from its current folder either.
4458
+ * `run` starts one — in the environment the target was resolved from — so that on Windows it takes no program from its
4459
+ * current folder either.
4168
4460
  */
4169
- function capture$1(command, args) {
4461
+ function capture$1(env, command, args) {
4170
4462
  return new Promise((resolvePromise, reject) => {
4171
4463
  const child = spawn(command, args, {
4172
4464
  stdio: [
@@ -4174,7 +4466,7 @@ function capture$1(command, args) {
4174
4466
  "pipe",
4175
4467
  "pipe"
4176
4468
  ],
4177
- env: childEnvironment(process.env)
4469
+ env: childEnvironment(env)
4178
4470
  });
4179
4471
  let stdout = "";
4180
4472
  let stderr = "";
@@ -4202,11 +4494,11 @@ function capture$1(command, args) {
4202
4494
  * or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
4203
4495
  * Nothing codex prints is repeated, because its answer carries the entry's env.
4204
4496
  */
4205
- async function codexRegistration(binary, name, path) {
4497
+ async function codexRegistration(env, binary, name, path) {
4206
4498
  const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with \`codex mcp get ${name}\`. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
4207
4499
  let answer;
4208
4500
  try {
4209
- answer = await capture$1(binary, [
4501
+ answer = await capture$1(env, binary, [
4210
4502
  "mcp",
4211
4503
  "get",
4212
4504
  name,
@@ -4308,21 +4600,48 @@ async function preflightInstall(context, product, options) {
4308
4600
  }
4309
4601
  let previous = writes ? claimName(product, options, name, existing) : [];
4310
4602
  if (writes && binary && options.client === "codex") {
4311
- const reported = await codexRegistration(binary, name, configPath ?? "codex");
4603
+ const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
4312
4604
  if (reported) previous = claimName(product, options, name, [reported]);
4313
4605
  }
4314
4606
  const { options: effective, kept } = keepNarrowing(product, options, previous);
4607
+ const node = await resolveNode(context.env);
4608
+ const command = (options.launcher ?? "managed") === "npx" ? await resolveNpx(context.env, node) : node;
4315
4609
  return {
4316
4610
  scan,
4317
4611
  target,
4318
4612
  previous,
4319
4613
  effective,
4320
- kept
4614
+ kept,
4615
+ node,
4616
+ command
4321
4617
  };
4322
4618
  }
4619
+ /**
4620
+ * The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
4621
+ *
4622
+ * Never the bare name. An entry that says only `npx` is resolved by the client, later, in its own environment — its
4623
+ * PATH, and on Windows its current folder — so the approval would bind a word, and the program run would be whichever
4624
+ * the client found. With no `npx` either way, nothing is written.
4625
+ */
4626
+ async function resolveNpx(env, node) {
4627
+ const found = await whichExecutable("npx", env) ?? await executableIn("npx", [dirname(node)], env);
4628
+ if (found !== null) return found;
4629
+ throw new CommsError("CONFIG", "npx could not be found, on PATH or beside node, so nothing was written", { hint: "Install Node.js with npm, which brings npx, or register with the managed launcher — the default — which needs no npx." });
4630
+ }
4631
+ /**
4632
+ * Where an install puts the entry and what the entry starts, as one sentence of the preview a person approves: the
4633
+ * client's own config file and the command, each with the home written `~`. In the approval's digest with the rest, so
4634
+ * a claim from an environment that resolves another file or another command — `CLAUDE_CONFIG_DIR` set to another
4635
+ * account's, a PATH that finds another node — is another change, and refused.
4636
+ */
4637
+ function entryDestination(client, target, command, env) {
4638
+ const home = homeDirectory(env);
4639
+ const file = target.own ?? target.configPath;
4640
+ return `the entry goes in ${file === void 0 ? `${client}'s own configuration` : shortenHome(file, home)}, and ${client} will start it with ${shortenHome(command, home)}`;
4641
+ }
4323
4642
  /** The plan a preflight arrived at, in the form `mcpInstall` compares: see `PlannedInstall`. */
4324
4643
  function plannedInstall(preflight) {
4325
- const { target, previous, effective } = preflight;
4644
+ const { target, previous, effective, node, command } = preflight;
4326
4645
  return {
4327
4646
  writes: target.writes,
4328
4647
  cliName: target.cliName,
@@ -4344,7 +4663,9 @@ function plannedInstall(preflight) {
4344
4663
  inbox: effective.inbox ?? null,
4345
4664
  workspace: effective.workspace ?? null,
4346
4665
  readOnly: effective.readOnly === true
4347
- }
4666
+ },
4667
+ node,
4668
+ command
4348
4669
  };
4349
4670
  }
4350
4671
  /**
@@ -4368,6 +4689,8 @@ function planDrift(product, client, name, planned, now) {
4368
4689
  }).join(" ") || "no pin";
4369
4690
  drift.push(`the server would start with ${flags(now.narrowing)}, not ${flags(planned.narrowing)}`);
4370
4691
  }
4692
+ if (planned.command !== now.command) drift.push(`it would start the server with ${now.command}, not ${planned.command}`);
4693
+ else if (planned.node !== now.node) drift.push(`it would run under the node at ${now.node}, not ${planned.node}`);
4371
4694
  return drift;
4372
4695
  }
4373
4696
  /** The planned pin and `--read-only`, as the options that start the server: each one given, none left to be found. */
@@ -4411,7 +4734,7 @@ async function mcpInstall(context, product, options, planned) {
4411
4734
  if (!writes) {
4412
4735
  if (existing.find((server) => server.client === options.client && server.name === name && server.scope !== "project" && !isProductServer(server, product))) warnings.push(`${options.client} already has a different server called "${name}"; pasting this would replace it. Use --name to choose another name.`);
4413
4736
  }
4414
- const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply);
4737
+ const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply, preflight);
4415
4738
  const snippet = snippetFor(options.client, name, entry);
4416
4739
  const backupPath = previous.length > 0 ? await backUp(context, options.client, name, previous, /* @__PURE__ */ new Date()) : void 0;
4417
4740
  const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
@@ -4465,13 +4788,13 @@ async function mcpInstall(context, product, options, planned) {
4465
4788
  name
4466
4789
  ];
4467
4790
  try {
4468
- await run(binary, removal);
4791
+ await run(context.env, binary, removal);
4469
4792
  } catch (error) {
4470
4793
  const message = error instanceof CommsError ? error.message : String(error);
4471
4794
  if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4472
4795
  }
4473
4796
  try {
4474
- await run(binary, args);
4797
+ await run(context.env, binary, args);
4475
4798
  } catch (error) {
4476
4799
  const restore = options.client === "claude-code" ? [
4477
4800
  "mcp",
@@ -4495,7 +4818,7 @@ async function mcpInstall(context, product, options, planned) {
4495
4818
  ];
4496
4819
  let restored = true;
4497
4820
  try {
4498
- await run(binary, restore);
4821
+ await run(context.env, binary, restore);
4499
4822
  } catch {
4500
4823
  restored = false;
4501
4824
  }
@@ -4505,7 +4828,7 @@ async function mcpInstall(context, product, options, planned) {
4505
4828
  });
4506
4829
  }
4507
4830
  } else try {
4508
- await run(binary, args);
4831
+ await run(context.env, binary, args);
4509
4832
  } catch (error) {
4510
4833
  const message = error instanceof CommsError ? error.message : String(error);
4511
4834
  if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
@@ -4994,62 +5317,6 @@ function requireChannelManifest(channel) {
4994
5317
  if (manifest === void 0) throw notAChannel(channel);
4995
5318
  return manifest;
4996
5319
  }
4997
- /**
4998
- * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
4999
- * and the preview renderer (which makes them visible), so the two can never disagree.
5000
- */
5001
- /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
5002
- const INVISIBLE_RANGES = [
5003
- [173, 173],
5004
- [847, 847],
5005
- [1564, 1564],
5006
- [4447, 4448],
5007
- [6068, 6069],
5008
- [6155, 6159],
5009
- [8203, 8207],
5010
- [8232, 8233],
5011
- [8234, 8238],
5012
- [8288, 8292],
5013
- [8294, 8303],
5014
- [12644, 12644],
5015
- [65024, 65039],
5016
- [65279, 65279],
5017
- [65440, 65440],
5018
- [917504, 917631]
5019
- ];
5020
- function isInvisible(codePoint) {
5021
- for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
5022
- return false;
5023
- }
5024
- /**
5025
- * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
5026
- * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
5027
- */
5028
- function isControl(codePoint) {
5029
- if (codePoint === 9 || codePoint === 10) return false;
5030
- return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
5031
- }
5032
- function isDangerous(codePoint) {
5033
- return isControl(codePoint) || isInvisible(codePoint);
5034
- }
5035
- /**
5036
- * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
5037
- * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
5038
- *
5039
- * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
5040
- * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
5041
- * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
5042
- */
5043
- function stripInvisible(text) {
5044
- let removed = 0;
5045
- let out = "";
5046
- for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
5047
- else out += char;
5048
- return {
5049
- text: out,
5050
- removed
5051
- };
5052
- }
5053
5320
  function okEnvelope(data) {
5054
5321
  return {
5055
5322
  ok: true,
@@ -6525,7 +6792,7 @@ async function npmInstallGlobal(env, spec) {
6525
6792
  }
6526
6793
  }
6527
6794
  /** The package version, read from package.json at build time. */
6528
- const VERSION = "0.10.0";
6795
+ const VERSION = "0.12.0";
6529
6796
  /**
6530
6797
  * Registering, pruning and listing the MCP servers of every channel, from the core.
6531
6798
  *
@@ -6700,9 +6967,10 @@ function installOptions(request) {
6700
6967
  *
6701
6968
  * `own` is the calling channel's product, from its own `mcp install`: see `ownProduct`. The sentences depend on the
6702
6969
  * request and the machine, not on which surface prepared them, so an approval prepared by one is claimed by the
6703
- * other. The one sentence that names code — `--launcher local`, which starts a checkout — names the file that will be
6704
- * started; a core running from another build of the checkout than the channel's command names another file, and that
6705
- * is another registration, refused as one.
6970
+ * other. The sentences that name code name it as this environment resolves it: the client's config file and the node
6971
+ * or npx the entry starts (#46), and for `--launcher local` the checkout's file. A claim from an environment that
6972
+ * resolves another — `CLAUDE_CONFIG_DIR` set to another account's, a PATH that finds another node, a core running
6973
+ * from another build of the checkout — is another registration, refused as one.
6706
6974
  */
6707
6975
  function serverInstallChange(core, env, request, own) {
6708
6976
  checkRequest(request);
@@ -6726,7 +6994,7 @@ function serverInstallChange(core, env, request, own) {
6726
6994
  const product = await productNow();
6727
6995
  const { version } = product;
6728
6996
  const preflight = await preflightInstall(context, product, installOptions(request));
6729
- const { target, previous, effective, kept } = preflight;
6997
+ const { target, previous, effective, kept, command } = preflight;
6730
6998
  planned = {
6731
6999
  product,
6732
7000
  install: plannedInstall(preflight)
@@ -6735,12 +7003,14 @@ function serverInstallChange(core, env, request, own) {
6735
7003
  if (target.writes) {
6736
7004
  const pinned = pinName === void 0 || pinName === "readOnly" ? void 0 : effective[pinName];
6737
7005
  const pins = [pinned !== void 0 ? `pinned to the ${noun} ${pinned}` : "", effective.readOnly ? "read-only" : ""].filter(Boolean);
6738
- const replacing = previous.length > 0 ? `, replacing its own earlier entry of that name${kept.length > 0 ? ` and keeping ${kept.join(" ")} from it` : ""}` : "";
7006
+ const what = (pinName === void 0 || pinName === "readOnly" ? [] : [...new Set(previous.map((server) => facts.narrowingOf(server.args)[pinName] ?? null))]).map((pin) => pin === null ? `every ${noun}` : `the ${noun} ${pin}`).join(" and ");
7007
+ const replacing = previous.length > 0 ? `, replacing its own earlier entry of that name${what ? `, which served ${what}${kept.length > 0 ? "," : ""}` : ""}${kept.length > 0 ? ` and keeping ${kept.join(" ")} from it` : ""}` : "";
6739
7008
  effects.push(`registers the ${label} MCP server with ${request.client} as "${name}"${pins.length > 0 ? `, ${pins.join(", ")}` : ""}${replacing}`);
6740
7009
  const everyAccount = pinName !== void 0 && pinned === void 0;
6741
7010
  const everyTool = switchable && effective.readOnly !== true;
6742
7011
  if (everyAccount) effects.push(`not pinned: it reaches every ${noun} on this machine${everyTool ? ", with every tool" : ""}`);
6743
7012
  else if (everyTool) effects.push(`not read-only: it has every tool for ${pinned}, including those that change it`);
7013
+ effects.push(entryDestination(request.client, target, command, env));
6744
7014
  if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
6745
7015
  else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
6746
7016
  }
@@ -6780,7 +7050,7 @@ function serverPruneChange(core, env, request, own) {
6780
7050
  };
6781
7051
  const { packageName, version } = ownProduct(request.channel, own) ?? {
6782
7052
  packageName: channelServer(request.channel).packageName,
6783
- version: "0.10.0"
7053
+ version: "0.12.0"
6784
7054
  };
6785
7055
  const product = {
6786
7056
  packageName,
@@ -7450,7 +7720,11 @@ function registrationEffects(step) {
7450
7720
  const { item } = step;
7451
7721
  const reach = reachOf(item.channel, channelServer(item.channel).narrowingOf(item.narrowing));
7452
7722
  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` : ""}`;
7453
- return item.launcher === "npx" ? [entry, `${item.client} will fetch ${item.package}@${item.latest} from npm each time it starts "${item.name}"`] : [entry];
7723
+ return item.launcher === "npx" ? [
7724
+ entry,
7725
+ step.destination,
7726
+ `${item.client} will fetch ${item.package}@${item.latest} from npm each time it starts "${item.name}"`
7727
+ ] : [entry, step.destination];
7454
7728
  }
7455
7729
  /** Every step as the sentences a person reads, in the order the steps are taken. */
7456
7730
  function effectsOf(planned) {
@@ -7510,7 +7784,9 @@ async function planRegistrations(context, candidates, request) {
7510
7784
  steps.push({
7511
7785
  item,
7512
7786
  product,
7513
- options
7787
+ options,
7788
+ install: plannedInstall(preflight),
7789
+ destination: entryDestination(item.client, preflight.target, preflight.command, context.env)
7514
7790
  });
7515
7791
  }
7516
7792
  return {
@@ -7605,7 +7881,7 @@ async function applyUpdate(context, planned, deps) {
7605
7881
  });
7606
7882
  }
7607
7883
  }
7608
- for (const { item, product, options } of planned.registrations) {
7884
+ for (const { item, product, options, install } of planned.registrations) {
7609
7885
  const base = {
7610
7886
  kind: "registration",
7611
7887
  channel: item.channel,
@@ -7631,7 +7907,7 @@ async function applyUpdate(context, planned, deps) {
7631
7907
  const result = await mcpInstall(context, product, {
7632
7908
  ...options,
7633
7909
  ...pins
7634
- });
7910
+ }, install);
7635
7911
  if (!result.applied) {
7636
7912
  steps.push({
7637
7913
  ...base,
@@ -7659,217 +7935,78 @@ async function applyUpdate(context, planned, deps) {
7659
7935
  for (const global of planned.globals) {
7660
7936
  const base = {
7661
7937
  kind: "global",
7662
- package: global.package,
7663
- from: global.version,
7664
- to: global.latest
7665
- };
7666
- try {
7667
- await installGlobal(`${global.package}@${global.latest}`);
7668
- steps.push({
7669
- ...base,
7670
- outcome: "updated"
7671
- });
7672
- } catch (error) {
7673
- steps.push({
7674
- ...base,
7675
- outcome: "failed",
7676
- detail: message(error)
7677
- });
7678
- }
7679
- }
7680
- const worked = (step) => step.kind === "registration" ? step.outcome === "registered" && step.verification !== "failed" : step.outcome !== "failed";
7681
- const ok = steps.every(worked);
7682
- const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
7683
- const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
7684
- const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${PRUNE_COMMANDS}.` : null;
7685
- return {
7686
- status,
7687
- latest: planned.latest,
7688
- steps,
7689
- manual: planned.manual,
7690
- ok,
7691
- next
7692
- };
7693
- }
7694
- /**
7695
- * "Not now": no server or command on this machine stops for the update until the next local midnight.
7696
- *
7697
- * `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
7698
- * for a newer release is a different change, and refused.
7699
- */
7700
- function updateLaterChange(core, options = {}) {
7701
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
7702
- let planned = null;
7703
- return {
7704
- plan: async (config) => {
7705
- const record = await readUpdateCheck(core.paths.stateDir);
7706
- const until = nextLocalMidnight(now());
7707
- const latest = countedLatest(record);
7708
- const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
7709
- planned = {
7710
- until,
7711
- latest,
7712
- changed
7713
- };
7714
- const what = latest === null ? "the daily update check" : `the update to ${latest}`;
7715
- return {
7716
- before: config,
7717
- after: config,
7718
- effects: changed ? [`puts off ${what} until midnight, local time (${localStamp(until)}): until then no server or command on this machine stops for it, and the first request after asks again`] : [],
7719
- summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
7720
- };
7721
- },
7722
- apply: async () => {
7723
- if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
7724
- const { until, latest, changed } = planned;
7725
- if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
7726
- ...record,
7727
- snoozedUntil: until.toISOString()
7728
- } }));
7729
- return {
7730
- snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
7731
- latest,
7732
- changed
7733
- };
7734
- }
7735
- };
7736
- }
7737
- /**
7738
- * The daily update check's checker: the half that asks the registry (design 2026-09-28 §1).
7739
- *
7740
- * At most once in 24 hours for the whole machine, it reads the `latest` dist-tag of `@agentcomms/core` — every
7741
- * package here is released together under one version, so that one number is the latest of each — and runs the same
7742
- * "what is behind" check `comms_update` runs with `check`, handing it that number, so the registry is asked once. What
7743
- * it found goes in `update-check.json`, where every server and command reads it.
7744
- *
7745
- * Two processes finding the file a day old at the same moment do not both ask: the first to take the file's lock
7746
- * claims the check (`checking`), and the second, reading the file under the same lock, finds it claimed. The day's
7747
- * `lastChecked` is written when the ask is over, so an ask cut short — a command the person interrupted, a server
7748
- * whose client closed it — does not use up the day: its claim runs out (`UPDATE_CHECK_LEASE_MS`) and the next process
7749
- * asks. An ask that fails — offline, a registry that does not answer — is recorded as the day's ask, keeps the last
7750
- * result, and stops nothing. It never throws.
7751
- *
7752
- * Nothing here gives up on the registry sooner than the registry's own timeout: a command at a terminal stops
7753
- * *waiting* after about three seconds and goes on, and the check carries on beside it. Cut off at three seconds, a
7754
- * registry that takes five would have been asked every day and heard from never.
7755
- *
7756
- * WhatsApp imports none of this: see `update-state.ts`.
7757
- */
7758
- const CORE_PACKAGE = channelServer("core").packageName;
7759
- /** The check, when it is on and due: once in 24 hours per machine, recorded in `update-check.json`. */
7760
- async function checkForUpdates(core, env, options = {}) {
7761
- const stateDir = core.paths.stateDir;
7762
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
7763
- try {
7764
- if (!(await updateCheckEnabled(core, env)).on) return {
7765
- asked: false,
7766
- record: await readUpdateCheck(stateDir)
7767
- };
7768
- const claim = await changeUpdateCheck(stateDir, (record) => {
7769
- const claimedAt = now();
7770
- if (!updateCheckDue(record, claimedAt) || updateCheckUnderway(record, claimedAt)) return null;
7771
- const checking = claimedAt.toISOString();
7772
- return {
7773
- record: {
7774
- ...record,
7775
- checking
7776
- },
7777
- result: checking
7778
- };
7779
- }).catch(() => null);
7780
- if (claim?.result === void 0) return {
7781
- asked: false,
7782
- record: await readUpdateCheck(stateDir)
7783
- };
7784
- await ask(core, env, {
7785
- ...options,
7786
- now,
7787
- claimedAt: claim.result
7788
- });
7789
- return {
7790
- asked: true,
7791
- record: await readUpdateCheck(stateDir)
7792
- };
7793
- } catch {
7794
- return {
7795
- asked: false,
7796
- record: await readUpdateCheck(stateDir).catch(() => ({ ...EMPTY_UPDATE_CHECK }))
7797
- };
7798
- }
7799
- }
7800
- async function ask(core, env, options) {
7801
- const deps = options.deps ?? {};
7802
- /**
7803
- * The ask is over: what it found is written, the day's check recorded as of the claim, and the claim given up — if
7804
- * the claim is still this ask's. A check a person asked for, or an update, records what it found and gives up any
7805
- * claim as it does; so does another process once this claim ran out and it claimed the check itself. Either way
7806
- * what is in the file is newer than what this ask found, and writing over it would put back the machine as it was
7807
- * before an update: "update" where "restart" is right.
7808
- */
7809
- const settle = (found) => changeUpdateCheck(core.paths.stateDir, (record) => record.checking === options.claimedAt ? { record: {
7810
- ...record,
7811
- ...found,
7812
- lastChecked: options.claimedAt,
7813
- checking: null
7814
- } } : null).catch(() => void 0);
7815
- const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 300);
7816
- let latest;
7817
- try {
7818
- latest = await (deps.latestVersion ?? ((name) => npmLatestVersion(name, { env })))(CORE_PACKAGE);
7819
- if (!isVersion(latest)) throw new Error(`the npm registry names ${JSON.stringify(String(latest)).slice(0, 60)} as the latest release, which is not a version`);
7820
- } catch (error) {
7821
- await settle({ lastError: reason(error) });
7822
- return;
7823
- }
7824
- if (isPrerelease(latest)) {
7825
- await settle({
7826
- latest,
7827
- behind: null,
7828
- current: null,
7829
- lastError: null
7830
- });
7831
- return;
7832
- }
7833
- try {
7834
- await updateCheck(core, env, {
7835
- ...deps,
7836
- latestVersion: async () => latest,
7837
- now: options.now
7838
- }, options.claimedAt);
7839
- } catch (error) {
7840
- await settle({
7841
- latest,
7842
- behind: null,
7843
- current: null,
7844
- lastError: reason(error)
7845
- });
7938
+ package: global.package,
7939
+ from: global.version,
7940
+ to: global.latest
7941
+ };
7942
+ try {
7943
+ await installGlobal(`${global.package}@${global.latest}`);
7944
+ steps.push({
7945
+ ...base,
7946
+ outcome: "updated"
7947
+ });
7948
+ } catch (error) {
7949
+ steps.push({
7950
+ ...base,
7951
+ outcome: "failed",
7952
+ detail: message(error)
7953
+ });
7954
+ }
7846
7955
  }
7956
+ const worked = (step) => step.kind === "registration" ? step.outcome === "registered" && step.verification !== "failed" : step.outcome !== "failed";
7957
+ const ok = steps.every(worked);
7958
+ const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
7959
+ const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
7960
+ const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${PRUNE_COMMANDS}.` : null;
7961
+ return {
7962
+ status,
7963
+ latest: planned.latest,
7964
+ steps,
7965
+ manual: planned.manual,
7966
+ ok,
7967
+ next
7968
+ };
7847
7969
  }
7848
7970
  /**
7849
- * What a command at a terminal hands the update gate (`updateGateAtTerminal`): the check, and the update itself for
7850
- * the person's "now" — its own preview and yes.
7971
+ * "Not now": no server or command on this machine stops for the update until the next local midnight.
7851
7972
  *
7852
- * Every command but WhatsApp's is given these. WhatsApp's is given neither, and reads the file as it is.
7973
+ * `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
7974
+ * for a newer release is a different change, and refused.
7853
7975
  */
7854
- function terminalUpdateHooks(core, env, options) {
7976
+ function updateLaterChange(core, options = {}) {
7977
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
7978
+ let planned = null;
7855
7979
  return {
7856
- check: async () => {
7857
- await checkForUpdates(core, env, {
7858
- deps: options.deps,
7859
- now: options.now
7860
- });
7980
+ plan: async (config) => {
7981
+ const record = await readUpdateCheck(core.paths.stateDir);
7982
+ const until = nextLocalMidnight(now());
7983
+ const latest = countedLatest(record);
7984
+ const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
7985
+ planned = {
7986
+ until,
7987
+ latest,
7988
+ changed
7989
+ };
7990
+ const what = latest === null ? "the daily update check" : `the update to ${latest}`;
7991
+ return {
7992
+ before: config,
7993
+ after: config,
7994
+ effects: changed ? [`puts off ${what} until midnight, local time (${localStamp(until)}): until then no server or command on this machine stops for it, and the first request after asks again`] : [],
7995
+ summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
7996
+ };
7861
7997
  },
7862
- update: async () => {
7863
- const result = await gatedChangeAtTerminal(core, updateChange(core, env, {}, options.deps), {
7864
- env,
7865
- output: options.output,
7866
- command: "agentcomms update",
7867
- approveCommand: options.approveCommand,
7868
- streams: options.streams
7869
- });
7870
- writeResult(result, options.output, (r) => renderUpdate(r, options.output.color), options.streams);
7871
- if (!result.ok) return "failed";
7872
- return result.status === "updated" && result.manual.length === 0 ? "updated" : "short";
7998
+ apply: async () => {
7999
+ if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
8000
+ const { until, latest, changed } = planned;
8001
+ if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
8002
+ ...record,
8003
+ snoozedUntil: until.toISOString()
8004
+ } }));
8005
+ return {
8006
+ snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
8007
+ latest,
8008
+ changed
8009
+ };
7873
8010
  }
7874
8011
  };
7875
8012
  }
@@ -8015,6 +8152,7 @@ function approvalsOf(command) {
8015
8152
  ...named
8016
8153
  ];
8017
8154
  }
8155
+ const TERMINAL_CHECK_WAIT_MS = 3e3;
8018
8156
  /**
8019
8157
  * The update gate at a terminal, before a command runs. Resolves to null to run the command, or to the exit status to
8020
8158
  * end with instead; throws `UPDATE_REQUIRED` (exit 11) where nobody can be asked.
@@ -8118,47 +8256,411 @@ function updateRequired(pending, binary) {
8118
8256
  });
8119
8257
  }
8120
8258
  /**
8121
- * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
8122
- * the update did everything and this command's release is now installed where it runs from — its global package at
8123
- * the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
8124
- * script that goes on after `cmd && next` must not take it for the command's success.
8259
+ * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
8260
+ * the update did everything and this command's release is now installed where it runs from — its global package at
8261
+ * the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
8262
+ * script that goes on after `cmd && next` must not take it for the command's success.
8263
+ */
8264
+ async function afterUpdate(outcome, options, before) {
8265
+ const { streams, binary } = options;
8266
+ const notRun = `${binary} did not run`;
8267
+ if (outcome === "failed") {
8268
+ streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
8269
+ return EXIT_CODES.UNAVAILABLE;
8270
+ }
8271
+ const after = updateVerdict(await readUpdateCheck(options.core.paths.stateDir), options.running, {
8272
+ channel: options.channel,
8273
+ surface: "command"
8274
+ });
8275
+ if (after === null || after.kind === "restart") {
8276
+ if (outcome === "updated") {
8277
+ streams.stdout.write("Updated. Run your command again.\n");
8278
+ return EXIT_CODES.OK;
8279
+ }
8280
+ streams.stdout.write(`${before.latest} is installed here: run your command again from it. ${notRun}.\n`);
8281
+ return EXIT_CODES.UPDATE;
8282
+ }
8283
+ streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. \`${UPDATE_WAYS.update.command}\` updates what this machine registers and has installed globally — what it found is said above — and this ${binary} ${before.running} is started from somewhere else: npx's cache, a project's own install, a checkout. Run it from ${before.latest}, or put this off until tomorrow with \`${UPDATE_WAYS.later.command}\`.\n`);
8284
+ return EXIT_CODES.UPDATE;
8285
+ }
8286
+ async function askLine(streams, question) {
8287
+ const { createInterface } = await import("node:readline/promises");
8288
+ const rl = createInterface({
8289
+ input: streams.stdin,
8290
+ output: streams.stderr
8291
+ });
8292
+ try {
8293
+ return await rl.question(question);
8294
+ } finally {
8295
+ rl.close();
8296
+ }
8297
+ }
8298
+ /**
8299
+ * The daily update check's checker: the half that asks the registry (design 2026-09-28 §1).
8300
+ *
8301
+ * At most once in 24 hours for the whole machine, it reads the `latest` dist-tag of `@agentcomms/core` — every
8302
+ * package here is released together under one version, so that one number is the latest of each — and runs the same
8303
+ * "what is behind" check `comms_update` runs with `check`, handing it that number, so the registry is asked once. What
8304
+ * it found goes in `update-check.json`, where every server and command reads it.
8305
+ *
8306
+ * Two processes finding the file a day old at the same moment do not both ask: the first to take the file's lock
8307
+ * claims the check (`checking`), and the second, reading the file under the same lock, finds it claimed. The day's
8308
+ * `lastChecked` is written when the ask is over, so an ask cut short — a command the person interrupted, a server
8309
+ * whose client closed it — does not use up the day: its claim runs out (`UPDATE_CHECK_LEASE_MS`) and the next process
8310
+ * asks. An ask that fails — offline, a registry that does not answer — is recorded as the day's ask, keeps the last
8311
+ * result, and stops nothing. It never throws.
8312
+ *
8313
+ * Nothing here gives up on the registry sooner than the registry's own timeout: a command at a terminal stops
8314
+ * *waiting* after about three seconds and goes on, and the check carries on beside it — in a detached child of its
8315
+ * own (`UPDATE_CHECK_CHILD_COMMAND`), so the command's process, and its output, end when the command does (#48). Cut
8316
+ * off at three seconds, a registry that takes five would have been asked every day and heard from never.
8317
+ *
8318
+ * WhatsApp imports none of this: see `update-state.ts`.
8319
+ */
8320
+ const CORE_PACKAGE = channelServer("core").packageName;
8321
+ /** The check, when it is on and due: once in 24 hours per machine, recorded in `update-check.json`. */
8322
+ async function checkForUpdates(core, env, options = {}) {
8323
+ const stateDir = core.paths.stateDir;
8324
+ try {
8325
+ if (!(await updateCheckEnabled(core, env)).on) return {
8326
+ asked: false,
8327
+ record: await readUpdateCheck(stateDir)
8328
+ };
8329
+ const claimedAt = await claimUpdateCheck(core, options);
8330
+ if (claimedAt === null) return {
8331
+ asked: false,
8332
+ record: await readUpdateCheck(stateDir)
8333
+ };
8334
+ await askUnderClaim(core, env, claimedAt, options);
8335
+ return {
8336
+ asked: true,
8337
+ record: await readUpdateCheck(stateDir)
8338
+ };
8339
+ } catch {
8340
+ return {
8341
+ asked: false,
8342
+ record: await readUpdateCheck(stateDir).catch(() => ({ ...EMPTY_UPDATE_CHECK }))
8343
+ };
8344
+ }
8345
+ }
8346
+ /**
8347
+ * The day's claim, when the check is due and nobody else is asking: the claim's time, written to the file as
8348
+ * `checking`, or null — not due, claimed by another process, or the file could not be written. Whoever is handed the
8349
+ * time asks under it (`askUnderClaim`). Whether the check is on at all is the caller's to decide first.
8350
+ *
8351
+ * Under the lock, due or not — and claimed by another process or not — is decided on the file as it is now, and the
8352
+ * claim written before anyone asks, so of two processes that both found it a day old, one asks. The time is read under
8353
+ * the lock too: read before it, the second process's time could be earlier than the first's claim, which reads as a
8354
+ * clock gone back.
8355
+ */
8356
+ async function claimUpdateCheck(core, options = {}) {
8357
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
8358
+ return (await changeUpdateCheck(core.paths.stateDir, (record) => {
8359
+ const claimedAt = now();
8360
+ if (!updateCheckDue(record, claimedAt) || updateCheckUnderway(record, claimedAt)) return null;
8361
+ const checking = claimedAt.toISOString();
8362
+ return {
8363
+ record: {
8364
+ ...record,
8365
+ checking
8366
+ },
8367
+ result: checking
8368
+ };
8369
+ }).catch(() => null))?.result ?? null;
8370
+ }
8371
+ /**
8372
+ * The ask, under a claim already taken (`claimUpdateCheck`): the registry, then `comms_update`'s own check. What it
8373
+ * found is written — the day's check recorded, and the claim given up — only while the claim in the file is still
8374
+ * `claimedAt`. It claims nothing itself.
8375
+ */
8376
+ async function askUnderClaim(core, env, claimedAt, options = {}) {
8377
+ await ask(core, env, {
8378
+ ...options,
8379
+ now: options.now ?? (() => /* @__PURE__ */ new Date()),
8380
+ claimedAt
8381
+ });
8382
+ }
8383
+ async function ask(core, env, options) {
8384
+ const deps = options.deps ?? {};
8385
+ /**
8386
+ * The ask is over: what it found is written, the day's check recorded as of the claim, and the claim given up — if
8387
+ * the claim is still this ask's. A check a person asked for, or an update, records what it found and gives up any
8388
+ * claim as it does; so does another process once this claim ran out and it claimed the check itself. Either way
8389
+ * what is in the file is newer than what this ask found, and writing over it would put back the machine as it was
8390
+ * before an update: "update" where "restart" is right.
8391
+ */
8392
+ const settle = (found) => changeUpdateCheck(core.paths.stateDir, (record) => record.checking === options.claimedAt ? { record: {
8393
+ ...record,
8394
+ ...found,
8395
+ lastChecked: options.claimedAt,
8396
+ checking: null
8397
+ } } : null).catch(() => void 0);
8398
+ const reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 300);
8399
+ let latest;
8400
+ try {
8401
+ latest = await (deps.latestVersion ?? ((name) => npmLatestVersion(name, { env })))(CORE_PACKAGE);
8402
+ if (!isVersion(latest)) throw new Error(`the npm registry names ${JSON.stringify(String(latest)).slice(0, 60)} as the latest release, which is not a version`);
8403
+ } catch (error) {
8404
+ await settle({ lastError: reason(error) });
8405
+ return;
8406
+ }
8407
+ if (isPrerelease(latest)) {
8408
+ await settle({
8409
+ latest,
8410
+ behind: null,
8411
+ current: null,
8412
+ lastError: null
8413
+ });
8414
+ return;
8415
+ }
8416
+ try {
8417
+ await updateCheck(core, env, {
8418
+ ...deps,
8419
+ latestVersion: async () => latest,
8420
+ now: options.now
8421
+ }, options.claimedAt);
8422
+ } catch (error) {
8423
+ await settle({
8424
+ latest,
8425
+ behind: null,
8426
+ current: null,
8427
+ lastError: reason(error)
8428
+ });
8429
+ }
8430
+ }
8431
+ /**
8432
+ * The hidden command every CLI that asks the registry answers to — `agentcomms`, `agent-gmail`, `agent-slack`,
8433
+ * `agent-resend` — with the claim's time after it: the ask a command handed on (`terminalUpdateHooks`). Not in any
8434
+ * help, never stopped by the update gate, and nothing a person or an agent runs.
8435
+ */
8436
+ const UPDATE_CHECK_CHILD_COMMAND = "update-check-child";
8437
+ /**
8438
+ * How to start the CLI this process is running: this Node, the flags it was started with, and the script it runs —
8439
+ * when that script is a CLI's entry, `cli.mjs` built or `cli.ts` from a checkout. Links are followed first: npm's
8440
+ * `agentcomms` is a link to `dist/cli.mjs`, and on Windows its `.cmd` shim has already started Node on the `.mjs`, so
8441
+ * no shim is ever started here. Every package that answers the hidden command bundles this function into its own
8442
+ * build, so the entry is the running script, not this module's neighbour — core's own CLI, next to a bundled copy of
8443
+ * core, is not the command the person ran.
8444
+ *
8445
+ * Null when the script is not a CLI's entry: a CLI's `run()` called from something else, a test runner for one. The
8446
+ * check is then asked in this process, as it always was.
8447
+ */
8448
+ function updateCheckChildEntry(script = process.argv[1], execArgv = process.execArgv) {
8449
+ if (!script) return null;
8450
+ let path;
8451
+ try {
8452
+ path = realpathSync.native(script);
8453
+ } catch {
8454
+ return null;
8455
+ }
8456
+ if (!/^cli\.(?:mjs|ts)$/.test(basename(path))) return null;
8457
+ return {
8458
+ command: process.execPath,
8459
+ args: [...withoutDebugger(execArgv), path]
8460
+ };
8461
+ }
8462
+ /**
8463
+ * Node's own flags, less a debugger's. A child started with `--inspect-brk` would wait for a debugger nobody attaches
8464
+ * until its claim ran out, and one with `--inspect` would ask for the port the command already holds. The rest —
8465
+ * `--experimental-strip-types` for a checkout, say — the child needs as the command did.
8466
+ */
8467
+ function withoutDebugger(execArgv) {
8468
+ return keptWords(execArgv.map((word) => ({
8469
+ word,
8470
+ raw: word
8471
+ }))).map(({ raw }) => raw);
8472
+ }
8473
+ /** Words as Node reads them, each with how it was written, less a debugger's; `--inspect-port 9229` is two words. */
8474
+ function keptWords(words) {
8475
+ const kept = [];
8476
+ for (let index = 0; index < words.length; index++) {
8477
+ const word = words[index];
8478
+ const equals = word.word.indexOf("=");
8479
+ const name = (equals < 0 ? word.word : word.word.slice(0, equals)).replaceAll("_", "-");
8480
+ if (!/^--(?:inspect|debug)(?:-brk(?:-node)?|-port|-wait)?$/.test(name)) {
8481
+ kept.push(word);
8482
+ continue;
8483
+ }
8484
+ if (equals < 0 && /^--(?:inspect|debug)-port$/.test(name)) index++;
8485
+ }
8486
+ return kept;
8487
+ }
8488
+ /**
8489
+ * `NODE_OPTIONS` split as Node splits it: at a space — not a tab or a new line — except inside double quotes, where a backslash takes the
8490
+ * character after it as it is. Each word keeps how it was written, so what is kept goes to the child unchanged —
8491
+ * `--require "/tmp/with --inspect hook.js"` is one word, and not a debugger's.
8492
+ */
8493
+ function nodeOptionWords(options) {
8494
+ const words = [];
8495
+ let index = 0;
8496
+ while (index < options.length) {
8497
+ while (index < options.length && options[index] === " ") index++;
8498
+ if (index >= options.length) break;
8499
+ const start = index;
8500
+ let word = "";
8501
+ let quoted = false;
8502
+ for (; index < options.length; index++) {
8503
+ const character = options[index] ?? "";
8504
+ if (quoted) {
8505
+ if (character === "\\" && index + 1 < options.length) word += options[++index];
8506
+ else if (character === "\"") quoted = false;
8507
+ else word += character;
8508
+ } else if (character === " ") break;
8509
+ else if (character === "\"") quoted = true;
8510
+ else word += character;
8511
+ }
8512
+ words.push({
8513
+ word,
8514
+ raw: options.slice(start, index)
8515
+ });
8516
+ }
8517
+ return words;
8518
+ }
8519
+ /**
8520
+ * The child's environment: the command's, with a debugger's flags taken out of `NODE_OPTIONS` too. Node reads
8521
+ * `NODE_OPTIONS` before any flag, so a command debugged through it — `NODE_OPTIONS=--inspect-brk` — would start a child
8522
+ * that waits for a debugger, holds its claim until the lease runs out, and outlives the command. Every other option in
8523
+ * it is kept as written; with none left, `NODE_OPTIONS` is left out. On Windows a variable's name is the same in any
8524
+ * case, so `node_options` is read, and taken out, as `NODE_OPTIONS` is.
8525
+ */
8526
+ function updateCheckChildEnvironment(env, platform = process.platform) {
8527
+ const child = { ...childEnvironment(env, platform) };
8528
+ const names = Object.keys(child).filter((name) => platform === "win32" ? name.toUpperCase() === "NODE_OPTIONS" : name === "NODE_OPTIONS");
8529
+ if (names.length === 0) return child;
8530
+ const kept = names.map((name) => keptWords(nodeOptionWords(child[name] ?? "")).map(({ raw }) => raw).join(" ")).filter((options) => options !== "").join(" ");
8531
+ for (const name of names) delete child[name];
8532
+ if (kept !== "") child.NODE_OPTIONS = kept;
8533
+ return child;
8534
+ }
8535
+ /**
8536
+ * The day's check at a terminal, handed on (#48). The command claims the check itself, as it always has, so only one
8537
+ * process asks — then starts this CLI again, detached, with the hidden command and the claim's time, and waits up to
8538
+ * the gate's three seconds for it to say the ask is over. After that it lets go of the child, which carries on alone,
8539
+ * and the command's process ends when the command does: before, it lived until the ask was over — the registry's ten
8540
+ * seconds, then `npm ls`'s minute — and a `$(…)`, a pipe or an agent's shell waited with it. A fast answer is written
8541
+ * before the wait ends, and the gate reads the file after it, so it still stops the day's first command.
8542
+ *
8543
+ * The child holds none of the command's output — stdin, stdout and stderr ignored, only the IPC channel for its one
8544
+ * message — or whatever reads that output would wait for the child as it waited for the check. Detached, so a
8545
+ * Ctrl-C at the terminal does not cut the ask short, and hidden, so Windows opens no console for it.
8546
+ *
8547
+ * When the child cannot be started — no entry to start, a program not there or not allowed — the check is asked in
8548
+ * this process instead, as it was before. It never throws.
8549
+ */
8550
+ async function handUpdateCheckToChild(core, env, options) {
8551
+ const deadline = Date.now() + TERMINAL_CHECK_WAIT_MS;
8552
+ try {
8553
+ if (!(await updateCheckEnabled(core, env)).on) return;
8554
+ const claimedAt = await claimUpdateCheck(core, { now: options.now });
8555
+ if (claimedAt === null) return;
8556
+ const entry = options.entry === void 0 ? updateCheckChildEntry() : options.entry;
8557
+ if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
8558
+ } catch {}
8559
+ }
8560
+ /**
8561
+ * Starts the child and waits, until `deadline` at most, for it to say the ask is over, or to end. False when it could
8562
+ * not be started at all; then nothing was asked, and the claim is still this process's to ask under.
8125
8563
  */
8126
- async function afterUpdate(outcome, options, before) {
8127
- const { streams, binary } = options;
8128
- const notRun = `${binary} did not run`;
8129
- if (outcome === "failed") {
8130
- streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
8131
- return EXIT_CODES.UNAVAILABLE;
8564
+ async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
8565
+ let child;
8566
+ try {
8567
+ child = spawn(entry.command, [
8568
+ ...entry.args,
8569
+ UPDATE_CHECK_CHILD_COMMAND,
8570
+ claimedAt
8571
+ ], {
8572
+ detached: true,
8573
+ windowsHide: true,
8574
+ stdio: [
8575
+ "ignore",
8576
+ "ignore",
8577
+ "ignore",
8578
+ "ipc"
8579
+ ],
8580
+ env: updateCheckChildEnvironment(env)
8581
+ });
8582
+ } catch {
8583
+ return false;
8132
8584
  }
8133
- const after = updateVerdict(await readUpdateCheck(options.core.paths.stateDir), options.running, {
8134
- channel: options.channel,
8135
- surface: "command"
8585
+ let failed = false;
8586
+ child.on("error", () => {
8587
+ if (child.pid === void 0) failed = true;
8136
8588
  });
8137
- if (after === null || after.kind === "restart") {
8138
- if (outcome === "updated") {
8139
- streams.stdout.write("Updated. Run your command again.\n");
8140
- return EXIT_CODES.OK;
8141
- }
8142
- streams.stdout.write(`${before.latest} is installed here: run your command again from it. ${notRun}.\n`);
8143
- return EXIT_CODES.UPDATE;
8144
- }
8145
- streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. \`${UPDATE_WAYS.update.command}\` updates what this machine registers and has installed globally — what it found is said above — and this ${binary} ${before.running} is started from somewhere else: npx's cache, a project's own install, a checkout. Run it from ${before.latest}, or put this off until tomorrow with \`${UPDATE_WAYS.later.command}\`.\n`);
8146
- return EXIT_CODES.UPDATE;
8147
- }
8148
- async function askLine(streams, question) {
8149
- const { createInterface } = await import("node:readline/promises");
8150
- const rl = createInterface({
8151
- input: streams.stdin,
8152
- output: streams.stderr
8589
+ await new Promise((resolve) => {
8590
+ const timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
8591
+ const done = () => {
8592
+ clearTimeout(timer);
8593
+ resolve();
8594
+ };
8595
+ child.once("error", done);
8596
+ child.once("exit", done);
8597
+ child.on("message", (message) => {
8598
+ if (message?.type === "settled") done();
8599
+ });
8153
8600
  });
8601
+ if (child.connected) child.disconnect();
8602
+ child.unref();
8603
+ return !failed;
8604
+ }
8605
+ /**
8606
+ * The hidden command's work, in the child a command started (`UPDATE_CHECK_CHILD_COMMAND`): the ask, under the claim
8607
+ * the command took and handed over. It claims nothing itself, and asks only while the file still holds that claim —
8608
+ * a claim that ran out, and that another process took since, is that process's to ask under. It says `settled` to the
8609
+ * command if the command is still listening, lets go of the channel, and ends; a command that stopped listening has
8610
+ * already gone on, and the ask is finished all the same. Results are written only while the claim is this child's.
8611
+ */
8612
+ async function runUpdateCheckChild(core, env, claimedAt) {
8613
+ if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: "Nothing runs this but a command finishing the day’s update check; `agentcomms update --check` asks now." });
8154
8614
  try {
8155
- return await rl.question(question);
8615
+ if ((await readUpdateCheck(core.paths.stateDir)).checking === claimedAt) await askUnderClaim(core, env, claimedAt);
8156
8616
  } finally {
8157
- rl.close();
8617
+ settled();
8158
8618
  }
8159
8619
  }
8620
+ /** Tells the command that started this child the ask is over, if it is still listening, and closes the channel. */
8621
+ function settled() {
8622
+ if (!process.send || !process.connected) return;
8623
+ process.send({ type: "settled" }, void 0, {}, () => {
8624
+ if (process.connected) process.disconnect();
8625
+ });
8626
+ }
8627
+ /**
8628
+ * What a command at a terminal hands the update gate (`updateGateAtTerminal`): the check, and the update itself for
8629
+ * the person's "now" — its own preview and yes.
8630
+ *
8631
+ * Every command but WhatsApp's is given these. WhatsApp's is given neither, and reads the file as it is.
8632
+ */
8633
+ function terminalUpdateHooks(core, env, options) {
8634
+ return {
8635
+ check: async () => {
8636
+ if (options.deps !== void 0) {
8637
+ await checkForUpdates(core, env, {
8638
+ deps: options.deps,
8639
+ now: options.now
8640
+ });
8641
+ return;
8642
+ }
8643
+ await handUpdateCheckToChild(core, env, {
8644
+ now: options.now,
8645
+ entry: options.childEntry
8646
+ });
8647
+ },
8648
+ update: async () => {
8649
+ const result = await gatedChangeAtTerminal(core, updateChange(core, env, {}, options.deps), {
8650
+ env,
8651
+ output: options.output,
8652
+ command: "agentcomms update",
8653
+ approveCommand: options.approveCommand,
8654
+ streams: options.streams
8655
+ });
8656
+ writeResult(result, options.output, (r) => renderUpdate(r, options.output.color), options.streams);
8657
+ if (!result.ok) return "failed";
8658
+ return result.status === "updated" && result.manual.length === 0 ? "updated" : "short";
8659
+ }
8660
+ };
8661
+ }
8160
8662
  //#endregion
8161
- //#region ../core/dist/tool-arguments-De_3CNxx.mjs
8663
+ //#region ../core/dist/tool-arguments-Bc-xUPsF.mjs
8162
8664
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
8163
8665
  const TARGET = "draft-2020-12";
8164
8666
  /**
@@ -17948,183 +18450,6 @@ strictObject({
17948
18450
  if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
17949
18451
  }
17950
18452
  });
17951
- const WINDOWS_RESERVED = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
17952
- const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
17953
- /** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
17954
- function truncateBytes(value, maxBytes) {
17955
- let out = "";
17956
- let bytes = 0;
17957
- for (const char of value) {
17958
- const size = Buffer.byteLength(char, "utf8");
17959
- if (bytes + size > maxBytes) break;
17960
- out += char;
17961
- bytes += size;
17962
- }
17963
- return out;
17964
- }
17965
- /**
17966
- * A file name that is safe to create on macOS, Linux and Windows: NFC-normalised, no separators or control
17967
- * characters, no leading or trailing dots and spaces, not a Windows reserved device name, at most `maxBytes` UTF-8
17968
- * bytes with the extension kept. Never empty.
17969
- */
17970
- function safeFilename(name, maxBytes = 255, fallback = "attachment") {
17971
- let cleaned = [...name.normalize("NFC")].filter((character) => !isDangerous(character.codePointAt(0) ?? 0)).join("").replace(UNSAFE_FILENAME_CHARS, "_").replace(/\s+/g, " ");
17972
- cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
17973
- if (cleaned === "") cleaned = fallback;
17974
- const extension = extname(cleaned);
17975
- if (WINDOWS_RESERVED.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
17976
- if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
17977
- const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
17978
- return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
17979
- }
17980
- /** True when `candidate` is `root` itself or strictly inside it (lexically). */
17981
- function isInside(candidate, root) {
17982
- const path = relative(resolve(root), resolve(candidate));
17983
- return path === "" || !path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path);
17984
- }
17985
- async function realpathOfExistingAncestor(path) {
17986
- let current = resolve(path);
17987
- const tail = [];
17988
- for (;;) try {
17989
- const real = await realpath(current);
17990
- return tail.length ? join(real, ...tail.reverse()) : real;
17991
- } catch (error) {
17992
- const parent = dirname(current);
17993
- if (error.code !== "ENOENT" || parent === current) throw error;
17994
- tail.push(basename(current));
17995
- current = parent;
17996
- }
17997
- }
17998
- /**
17999
- * Resolves `target` for writing and proves it stays inside `root` after symlinks in its existing ancestors are
18000
- * followed. Throws BAD_DATA otherwise. The root must already exist.
18001
- */
18002
- async function resolveInsideRoot(root, target) {
18003
- const realRoot = await realpath(root);
18004
- const lexical = resolve(root, target);
18005
- if (!isInside(lexical, root)) throw new CommsError("BAD_DATA", `refusing to write outside ${root}`, { details: { target } });
18006
- if (!isInside(await realpathOfExistingAncestor(lexical), realRoot)) throw new CommsError("BAD_DATA", `refusing to write through a link that leaves ${root}`, { details: { target } });
18007
- return lexical;
18008
- }
18009
- /**
18010
- * Creates a new file for writing without following a symlink at the final component and without overwriting an
18011
- * existing file. Picks `name-2.ext`, `name-3.ext`… when the name is taken.
18012
- */
18013
- async function createUniqueFile(directory, filename) {
18014
- const extension = extname(filename);
18015
- const stem = extension ? filename.slice(0, -extension.length) : filename;
18016
- const noFollow = process.platform === "win32" ? 0 : constants$1.O_NOFOLLOW;
18017
- for (let attempt = 1; attempt < 1e3; attempt += 1) {
18018
- const name = attempt === 1 ? filename : `${stem}-${attempt}${extension}`;
18019
- const path = join(directory, name);
18020
- try {
18021
- return {
18022
- path,
18023
- handle: await open(path, constants$1.O_WRONLY | constants$1.O_CREAT | constants$1.O_EXCL | noFollow, 384)
18024
- };
18025
- } catch (error) {
18026
- if (error.code === "EEXIST") continue;
18027
- throw error;
18028
- }
18029
- }
18030
- throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
18031
- }
18032
- /**
18033
- * Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
18034
- * directly under home — SSH, cloud, npm, git and shell credentials, agent configs; `**∕.git/**` any repository's git
18035
- * directory; `**∕.env*` dotenv files anywhere.
18036
- */
18037
- function defaultAttachDeny(configDir, env = process.env) {
18038
- const deny = [
18039
- configDir,
18040
- "~/.*",
18041
- "~/Library",
18042
- "**/.git/**",
18043
- "**/.env*"
18044
- ];
18045
- if (env.APPDATA) deny.push(env.APPDATA);
18046
- if (env.LOCALAPPDATA) deny.push(env.LOCALAPPDATA);
18047
- return deny;
18048
- }
18049
- async function realOrResolved(path) {
18050
- try {
18051
- return await realpath(path);
18052
- } catch {
18053
- return resolve(path);
18054
- }
18055
- }
18056
- /**
18057
- * Proves a local file may be attached to a draft: it resolves (following links) to a regular file inside one of the
18058
- * allowed roots and inside none of the deny entries. A deny entry of the form `**` + `/name*` matches by file name
18059
- * prefix (so `.env*` matches `.env` and `.env.local`). Returns the real path.
18060
- */
18061
- async function checkAttachable(path, policy) {
18062
- const home = policy.home;
18063
- const requested = resolve(expandHome(path, home));
18064
- let real;
18065
- try {
18066
- real = await realpath(requested);
18067
- } catch {
18068
- throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
18069
- }
18070
- if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
18071
- if (!(await Promise.all(policy.roots.map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Move the file into an allowed folder, or add its folder to defaults.attachRoots with the CLI." });
18072
- const name = basename(real);
18073
- const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
18074
- for (const entry of policy.deny) {
18075
- if (entry === "~/.*") {
18076
- if (isInside(real, homeDir)) {
18077
- const first = relative(homeDir, real).split(sep)[0] ?? "";
18078
- if (first.startsWith(".")) throw new CommsError("BAD_DATA", `refusing to attach a file from ~/${first}: hidden folders in your home are never attached`);
18079
- }
18080
- continue;
18081
- }
18082
- if (entry === "**/.git/**") {
18083
- if (real.split(sep).some((segment) => segment.toLowerCase() === ".git")) throw new CommsError("BAD_DATA", "refusing to attach a file from a .git folder");
18084
- continue;
18085
- }
18086
- if (entry.startsWith("**/")) {
18087
- const pattern = entry.slice(3).toLowerCase();
18088
- const candidate = name.toLowerCase();
18089
- const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
18090
- if (pattern.endsWith("*") ? candidate.startsWith(prefix) : candidate === prefix) throw new CommsError("BAD_DATA", `refusing to attach ${name}: files matching ${entry} are never attached`);
18091
- continue;
18092
- }
18093
- const denied = await realOrResolved(expandHome(entry, home));
18094
- if (isInside(real, denied)) throw new CommsError("BAD_DATA", `refusing to attach a file from ${entry}`);
18095
- }
18096
- return real;
18097
- }
18098
- /**
18099
- * A caller-supplied subdirectory, checked before it is joined to anything.
18100
- *
18101
- * `path.join` is not a boundary, and it was being used as one. `join('work', '../personal')` is `'personal'`: the
18102
- * alias segment is cancelled, the result still resolves inside the downloads root, so `resolveInsideRoot` allows it
18103
- * — and one mailbox's files are written into another mailbox's folder, over the `manifest.json` that is that
18104
- * mailbox's own record of where its attachments came from. `join('work', '/etc/cron.d')` is `'work/etc/cron.d'`:
18105
- * the leading separator is simply dropped, so an absolute path that every document describes as refused is quietly
18106
- * accepted under a name the caller never asked for.
18107
- *
18108
- * Neither is an escape from the root, which is why neither showed up as a jail failure. In one respect they are
18109
- * worse than an escape: they succeed, and report a path the caller was never told about.
18110
- *
18111
- * So the check happens here, on the caller's own string, before any join — absolute paths and `..` are refused
18112
- * rather than normalised away. A nested `reports/august` is fine; that is what the option is for.
18113
- */
18114
- function relativeSubpath(out, field = "out") {
18115
- const value = (out ?? "").trim();
18116
- if (!value) return "";
18117
- const refuse = (why) => {
18118
- throw new CommsError("BAD_DATA", `${field} must be a relative subpath: ${why}`, {
18119
- hint: "Pass something like \"reports/august\". It is always placed inside this account’s own folder.",
18120
- details: { value }
18121
- });
18122
- };
18123
- if (isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.startsWith("/") || value.startsWith("\\")) refuse("it is an absolute path");
18124
- const segments = value.split(/[/\\]+/);
18125
- if (segments.includes("..")) refuse("it climbs out with \"..\"");
18126
- return segments.filter((segment) => segment && segment !== ".").join("/");
18127
- }
18128
18453
  /**
18129
18454
  * Email HTML is attacker-controlled. Before any of it becomes text a model reads, this removes what a human reading
18130
18455
  * the message in a mail client would not see — the channel used by hidden-text prompt injection (e.g. zero-size or
@@ -22255,4 +22580,4 @@ async function revokeSendApproval(context, approvalId) {
22255
22580
  });
22256
22581
  }
22257
22582
  //#endregion
22258
- export { openCore as $, CHANGE_POLICIES as A, approvalKind as B, policyChange as C, sendPolicyOf as D, removeAccountChange as E, CHANNEL_SERVERS as F, checkForUpdates as G, approveChangeAtTerminal as H, CommsError as I, escapeForDisplay as J, colorEnabled as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, installExitStatus as Q, SEND_LOOKUP as R, policyApprovalRefusal as S, policyWanted as T, canPrompt as U, approvalsOf as V, changeToolResult as W, gatedChange as X, exemptFromUpdateGate as Y, gatedChangeAtTerminal as Z, runDoctor as _, VERSION$1 as _t, revokeSendApproval as a, serverInstallChange as at, listAccounts as b, downloadReceived as c, toCommsError as ct, listReceived as d, updateToolGate as dt, paint as et, listScheduled as f, writeResult as ft, showSentEmail as g, ROUTES as gt, showReceived as h, RESEND_DOWNLOAD_ORIGIN as ht, prepareSend as i, runCommand as it, MODES as j, ResendContext as k, getMetrics as l, truncateDisplay as lt, listSuppressions as m, RESEND_API_ORIGIN as mt, executeSend as n, renderInstall as nt, sendStatus as o, serverPruneChange as ot, listSentEmails as p, REFUSED as pt, commandPathOf as q, finishSendApproval as r, renderPrune as rt, cancelScheduledChange as s, terminalUpdateHooks as st, beginSendApproval as t, refuseUnclaimedApproval as tt, listDomains as u, updateGateAtTerminal as ut, addAccountChange as v, policyReport as w, modeOf as x, inspectKey as y, agentMarker as z };
22583
+ export { installExitStatus as $, CHANGE_POLICIES as A, agentMarker as B, policyChange as C, sendPolicyOf as D, removeAccountChange as E, CHANNEL_SERVERS as F, changeToolResult as G, approvalsOf as H, CommsError as I, commandPathOf as J, checkForUpdates as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, gatedChangeAtTerminal as Q, SEND_LOOKUP as R, policyApprovalRefusal as S, policyWanted as T, approveChangeAtTerminal as U, approvalKind as V, canPrompt as W, exemptFromUpdateGate as X, escapeForDisplay as Y, gatedChange as Z, runDoctor as _, RESEND_DOWNLOAD_ORIGIN as _t, revokeSendApproval as a, runCommand as at, listAccounts as b, downloadReceived as c, serverPruneChange as ct, listReceived as d, truncateDisplay as dt, openCore as et, listScheduled as f, updateGateAtTerminal as ft, showSentEmail as g, RESEND_API_ORIGIN as gt, showReceived as h, REFUSED as ht, prepareSend as i, renderPrune as it, MODES as j, ResendContext as k, getMetrics as l, terminalUpdateHooks as lt, listSuppressions as m, writeResult as mt, executeSend as n, refuseUnclaimedApproval as nt, sendStatus as o, runUpdateCheckChild as ot, listSentEmails as p, updateToolGate as pt, colorEnabled as q, finishSendApproval as r, renderInstall as rt, cancelScheduledChange as s, serverInstallChange as st, beginSendApproval as t, paint as tt, listDomains as u, toCommsError as ut, addAccountChange as v, ROUTES as vt, policyReport as w, modeOf as x, inspectKey as y, VERSION$1 as yt, UPDATE_CHECK_CHILD_COMMAND as z };