@agentcomms/whatsapp 0.19.0 → 0.21.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.
@@ -1,17 +1,17 @@
1
- import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
1
+ import { C as unknown, S as union, b as strictObject, c as boolean, g as object, h as number, i as _enum, l as discriminatedUnion, m as looseObject, n as datetime, p as literal, s as array, x as string, y as record } from "./iso-USuVU_sk.mjs";
2
2
  import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, unlink, utimes } from "node:fs/promises";
3
- import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import path, { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
4
4
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { styleText } from "node:util";
8
8
  import { homedir, platform, userInfo } from "node:os";
9
- import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
9
+ import { accessSync, constants as constants$1, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$1 = "0.19.0";
14
+ const VERSION$2 = "0.21.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -28,23 +28,20 @@ const CALLER = Object.freeze({
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
30
  //#endregion
31
- //#region ../core/dist/update-check-CVvpp5G8.mjs
31
+ //#region ../core/dist/update-check-CrKGPUCv.mjs
32
32
  /**
33
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
35
- * on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10 and 11 are ours.
35
+ * on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10 is ours.
36
+ *
37
+ * 11 was ours too, for a command the daily update check stopped (design 2026-09-28). Nothing is stopped for being behind
38
+ * any more (design 2026-10-16, U1), so `UPDATE_REQUIRED` and 11 are retired: a client that still sees one is talking to
39
+ * a server older than 0.20.0.
36
40
  */
37
41
  const EXIT_CODES = {
38
42
  OK: 0,
39
43
  UNEXPECTED: 1,
40
44
  APPROVAL: 10,
41
- /**
42
- * A command that did not run because a newer release is out and nobody at a terminal could be asked about it
43
- * (design 2026-09-28 §3). None of sysexits' codes means "run something else first", and a script that branches on
44
- * it has exactly two things to do — update, or put it off — so it has one of its own. 11, beside 10: both mean a
45
- * person has to decide before the command can go on.
46
- */
47
- UPDATE: 11,
48
45
  USAGE: 64,
49
46
  BAD_DATA: 65,
50
47
  NOT_FOUND: 66,
@@ -109,10 +106,10 @@ const ERROR_REGISTRY = {
109
106
  retryable: false,
110
107
  summary: "a safety setting can only be loosened by a person"
111
108
  },
112
- UPDATE_REQUIRED: {
113
- exit: EXIT_CODES.UPDATE,
109
+ NEEDS_NEWER_RELEASE: {
110
+ exit: EXIT_CODES.UNEXPECTED,
114
111
  retryable: false,
115
- summary: "a newer release is out: update first, or put it off until tomorrow"
112
+ summary: "this needs a later agent-communications; update first, then run it again"
116
113
  },
117
114
  USAGE: {
118
115
  exit: EXIT_CODES.USAGE,
@@ -983,11 +980,26 @@ const ACT_KINDS = Object.freeze([
983
980
  "file",
984
981
  "send"
985
982
  ]);
983
+ /**
984
+ * How an `agent` act looks in one Slack workspace (the owner's decision, 2026-10-17): `app`, the default, posts as the
985
+ * app where S2 says it can, with Slack's APP tag, and labelled as the person elsewhere; `labelled` posts every one as
986
+ * the person, labelled as S3's fallback is, even where the app could post. Slack's APP tag cannot be hidden or renamed,
987
+ * so this is the person's choice between that tag and words in the message.
988
+ */
989
+ const SLACK_STYLES = Object.freeze(["app", "labelled"]);
986
990
  /** L2's characters a name may not hold: parentheses would fake a second label, the rest break an address header. */
987
991
  const FORBIDDEN_NAME_CHARACTERS = /[()<>"@,;:\\]/u;
988
992
  function isAgentLabelWord(value) {
989
993
  return typeof value === "string" && AGENT_LABEL_WORDS.includes(value);
990
994
  }
995
+ function isSlackStyle(value) {
996
+ return typeof value === "string" && SLACK_STYLES.includes(value);
997
+ }
998
+ /** A Slack workspace's style, by its account id: `app` unless `labelled` is recorded for it. */
999
+ function slackStyleOf(settings, accountId) {
1000
+ const styles = settings?.slackStyles;
1001
+ return styles !== void 0 && Object.hasOwn(styles, accountId) && styles[accountId] === "labelled" ? "labelled" : "app";
1002
+ }
991
1003
  /** Why a name cannot be labelled, or undefined when it can. */
992
1004
  function nameProblem(name) {
993
1005
  if (name.length === 0) return "the name is empty";
@@ -1004,7 +1016,9 @@ const ruleKey = (rule) => JSON.stringify([
1004
1016
  ]);
1005
1017
  /**
1006
1018
  * L5: the changes that need the person's confirmation — anything that could leave a message unlabelled or change how
1007
- * the label looks. Adding an `agent` rule or removing a `me` rule tightens, and is not listed.
1019
+ * the label looks. Adding an `agent` rule or removing a `me` rule tightens, and is not listed. A Slack workspace moved
1020
+ * from `app` to `labelled` trades Slack's own APP tag, which no one can fake, for words in the message, so it waits;
1021
+ * moved back to `app`, it tightens. Its values are each moved workspace's style, before and after.
1008
1022
  */
1009
1023
  function classifyAgentLabelChange(before, after) {
1010
1024
  const changes = [];
@@ -1034,6 +1048,12 @@ function classifyAgentLabelChange(before, after) {
1034
1048
  was: before?.slackIcons ?? {},
1035
1049
  now: after?.slackIcons ?? {}
1036
1050
  });
1051
+ const styled = [.../* @__PURE__ */ new Set([...Object.keys(before?.slackStyles ?? {}), ...Object.keys(after?.slackStyles ?? {})])].filter((id) => slackStyleOf(before, id) !== slackStyleOf(after, id)).sort();
1052
+ if (styled.some((id) => slackStyleOf(after, id) === "labelled")) changes.push({
1053
+ path: "agentLabel.slackStyles",
1054
+ was: Object.fromEntries(styled.map((id) => [id, slackStyleOf(before, id)])),
1055
+ now: Object.fromEntries(styled.map((id) => [id, slackStyleOf(after, id)]))
1056
+ });
1037
1057
  return changes;
1038
1058
  }
1039
1059
  function sorted(record) {
@@ -1053,7 +1073,8 @@ function isRule(value) {
1053
1073
  /**
1054
1074
  * The stored `agentLabel`, read so that a bad value can only tighten. An unknown word reads as `agent`, an invalid
1055
1075
  * name as no name, and a rule list with any invalid rule is ignored whole: dropping one `agent` rule could leave a
1056
- * broader `me` rule in charge. A malformed icon entry is dropped, falling back to the plain photo.
1076
+ * broader `me` rule in charge. A malformed icon entry is dropped, falling back to the plain photo. A Slack style that is
1077
+ * neither `app` nor `labelled` is dropped too, so that workspace is `app`: the stronger disclosure.
1057
1078
  */
1058
1079
  function readAgentLabelSettings(raw) {
1059
1080
  if (typeof raw !== "object" || raw === null) return {};
@@ -1066,6 +1087,10 @@ function readAgentLabelSettings(raw) {
1066
1087
  const icons = Object.entries(stored.slackIcons).filter((entry) => typeof entry[1] === "string" && EMOJI_NAME.test(entry[1]));
1067
1088
  if (icons.length > 0) settings.slackIcons = Object.fromEntries(icons);
1068
1089
  }
1090
+ if (typeof stored.slackStyles === "object" && stored.slackStyles !== null && !Array.isArray(stored.slackStyles)) {
1091
+ const styles = Object.entries(stored.slackStyles).filter((entry) => isSlackStyle(entry[1]));
1092
+ if (styles.length > 0) settings.slackStyles = Object.fromEntries(styles);
1093
+ }
1069
1094
  return settings;
1070
1095
  }
1071
1096
  /** The person's label settings from a loaded configuration, read as `readAgentLabelSettings` reads them. */
@@ -3141,6 +3166,404 @@ function commandLinePackage(command, args) {
3141
3166
  const line = [command, ...withoutPathOptions(args).words].join(" ");
3142
3167
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
3143
3168
  }
3169
+ /** The scope every server package is published under; a launcher is named for the rest of the package name. */
3170
+ const SCOPE$1 = "@agentcomms";
3171
+ const LAUNCH = "launch";
3172
+ const RUNTIMES = "runtime";
3173
+ const POINTER = ".current";
3174
+ const FALLBACK = ".fallback";
3175
+ const LOCK = ".lock";
3176
+ /**
3177
+ * What a launcher hands the process it starts (re-review of 0.20.0, severity 6): its own name, so the process knows it
3178
+ * was started through its package's stable launcher — the one the background job switches — and not by npx, a
3179
+ * versioned or local registration, or a command from somewhere else. The update notice holds back only for such a
3180
+ * process what the job will install (`update-gate.ts`), and only a run of `update --background` started through the
3181
+ * core launcher, as the job starts it, records that the job ran (`update-background.ts`).
3182
+ */
3183
+ const LAUNCHER_ENV = "AGENT_COMMS_LAUNCHER";
3184
+ /** A launcher's name: one plain word, never a path — the unscoped name of a server package. */
3185
+ const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3186
+ /** A runtime's folder in `<data>/runtime`: no separator, nothing that starts with a dot. */
3187
+ const FOLDER = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
3188
+ /** The version a managed runtime's folder starts with, as `mcp prune` reads one. */
3189
+ const VERSION$1 = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
3190
+ /** The CLI inside a runtime, as words from the runtime's folder: what `managedRuntimeEntry` names. */
3191
+ function cliWords(name) {
3192
+ return [
3193
+ "node_modules",
3194
+ SCOPE$1,
3195
+ name,
3196
+ "dist",
3197
+ "cli.mjs"
3198
+ ];
3199
+ }
3200
+ function checkedName(name) {
3201
+ if (!NAME.test(name)) throw new CommsError("CONFIG", `${JSON.stringify(name)} is not a launcher name: one plain word, such as gmail`);
3202
+ return name;
3203
+ }
3204
+ /** The launcher a server package is started through: its name without the scope, `@agentcomms/gmail` → `gmail`. */
3205
+ function launcherName(packageName) {
3206
+ const [scope, rest, extra] = packageName.split("/");
3207
+ if (scope !== SCOPE$1 || rest === void 0 || extra !== void 0) throw new CommsError("CONFIG", `${packageName} is not a package a stable launcher starts`);
3208
+ return checkedName(rest);
3209
+ }
3210
+ /** Where a launcher and its pointer are: `<data>/launch/<name>` and `<data>/launch/<name>.current`. */
3211
+ function launcherPaths(dataDir, name) {
3212
+ const directory = join(dataDir, LAUNCH);
3213
+ checkedName(name);
3214
+ return {
3215
+ launcher: join(directory, name),
3216
+ pointer: join(directory, `${name}${POINTER}`)
3217
+ };
3218
+ }
3219
+ /** Where the launcher records that it fell back: `<data>/launch/<name>.fallback`. */
3220
+ function fallbackRecordPath(dataDir, name) {
3221
+ return join(dataDir, LAUNCH, `${checkedName(name)}${FALLBACK}`);
3222
+ }
3223
+ /** The CLI a runtime holds for a launcher: `<runtime>/node_modules/@agentcomms/<name>/dist/cli.mjs`. */
3224
+ function launcherCli(runtimeDir, name) {
3225
+ return join(runtimeDir, ...cliWords(checkedName(name)));
3226
+ }
3227
+ /** The version a runtime folder of this launcher is, `0.20.0` for `0.20.0-gmail`; null for any other folder. */
3228
+ function launcherRuntimeVersion(runtimeDir, name) {
3229
+ const folder = basename(runtimeDir);
3230
+ const suffix = `-${checkedName(name)}`;
3231
+ if (!folder.endsWith(suffix)) return null;
3232
+ const version = folder.slice(0, -suffix.length);
3233
+ return VERSION$1.test(version) ? version : null;
3234
+ }
3235
+ /** A runtime folder this launcher may point at: a folder of its own in `<data>/runtime`, named for it. */
3236
+ function isOwnFolder(folder, name) {
3237
+ return FOLDER.test(folder) && folder.endsWith(`-${name}`) && folder.length > name.length + 1;
3238
+ }
3239
+ /** The pointer's text: one runtime folder a line, read by the launcher with nothing but its shell's built-ins. */
3240
+ function pointerText(current, previous) {
3241
+ return [
3242
+ "# The runtime the stable launcher beside this file starts, written by agent-communications. Do not edit.",
3243
+ `current=${current}`,
3244
+ ...previous === void 0 ? [] : [`previous=${previous}`],
3245
+ ""
3246
+ ].join("\n");
3247
+ }
3248
+ /** The pointer in `text`, as runtime folders, or null when it is not one this writes. */
3249
+ function parsePointer(text, name) {
3250
+ let current;
3251
+ let previous;
3252
+ for (const line of text.split(/\r?\n/)) {
3253
+ if (line === "" || line.startsWith("#")) continue;
3254
+ const at = line.indexOf("=");
3255
+ const key = at < 0 ? line : line.slice(0, at);
3256
+ const value = at < 0 ? "" : line.slice(at + 1);
3257
+ if (key === "current" && current === void 0) current = value;
3258
+ else if (key === "previous" && previous === void 0) previous = value;
3259
+ else return null;
3260
+ }
3261
+ if (current === void 0 || !isOwnFolder(current, name)) return null;
3262
+ if (previous !== void 0 && !isOwnFolder(previous, name)) return null;
3263
+ return previous === void 0 ? { current } : {
3264
+ current,
3265
+ previous
3266
+ };
3267
+ }
3268
+ function asPaths(dataDir, folders) {
3269
+ const runtimes = join(dataDir, RUNTIMES);
3270
+ return folders.previous === void 0 ? { current: join(runtimes, folders.current) } : {
3271
+ current: join(runtimes, folders.current),
3272
+ previous: join(runtimes, folders.previous)
3273
+ };
3274
+ }
3275
+ function unreadable$1(pointer, why) {
3276
+ return new CommsError("CONFIG", `${pointer} cannot be read as a pointer: ${why}`, { hint: "It is written by managed installs and updates, never by hand. Run a managed install or an update again: it writes the pointer afresh." });
3277
+ }
3278
+ /** The pointer's runtime folders, or undefined when there is no pointer; refused when it is not one this writes. */
3279
+ async function readFolders(dataDir, name) {
3280
+ const { pointer } = launcherPaths(dataDir, name);
3281
+ let text;
3282
+ try {
3283
+ text = await readFile(pointer, "utf8");
3284
+ } catch (error) {
3285
+ if (error.code === "ENOENT") return void 0;
3286
+ throw unreadable$1(pointer, error.code ?? String(error));
3287
+ }
3288
+ const folders = parsePointer(text, name);
3289
+ if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
3290
+ return folders;
3291
+ }
3292
+ /**
3293
+ * The runtime a launcher starts, and the one before it; undefined when it has no pointer yet.
3294
+ *
3295
+ * A pointer that is there and is not one this writes is refused rather than taken for none: what it names may be in
3296
+ * use, and "no pointer" would let a prune or an update act as though nothing were.
3297
+ */
3298
+ async function readPointer(dataDir, name) {
3299
+ const folders = await readFolders(dataDir, name);
3300
+ return folders === void 0 ? void 0 : asPaths(dataDir, folders);
3301
+ }
3302
+ /** `readPointer`, for a reader that cannot wait: the registration readers, which the command locator calls. */
3303
+ function readPointerSync(dataDir, name) {
3304
+ const { pointer } = launcherPaths(dataDir, name);
3305
+ let text;
3306
+ try {
3307
+ text = readFileSync(pointer, "utf8");
3308
+ } catch (error) {
3309
+ if (error.code === "ENOENT") return void 0;
3310
+ throw unreadable$1(pointer, error.code ?? String(error));
3311
+ }
3312
+ const folders = parsePointer(text, name);
3313
+ if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
3314
+ return asPaths(dataDir, folders);
3315
+ }
3316
+ /** Whether the CLI a runtime holds for this launcher is a file that can be read: whether the launcher can start it. */
3317
+ async function launcherCanStart(runtimeDir, name) {
3318
+ const cli = launcherCli(runtimeDir, name);
3319
+ try {
3320
+ if (!(await stat(cli)).isFile()) return false;
3321
+ await access(cli, constants$1.R_OK);
3322
+ return true;
3323
+ } catch {
3324
+ return false;
3325
+ }
3326
+ }
3327
+ /** `launcherCanStart`, for a reader that cannot wait. */
3328
+ function launcherCanStartSync(runtimeDir, name) {
3329
+ const cli = launcherCli(runtimeDir, name);
3330
+ try {
3331
+ if (!statSync(cli).isFile()) return false;
3332
+ accessSync(cli, constants$1.R_OK);
3333
+ return true;
3334
+ } catch {
3335
+ return false;
3336
+ }
3337
+ }
3338
+ /** The folder of a runtime this launcher may be switched to, or why not. Nothing outside `<data>/runtime` is one. */
3339
+ async function switchableFolder(dataDir, name, runtimeDir) {
3340
+ const folder = basename(runtimeDir);
3341
+ if (!(resolve(dirname(runtimeDir)) === resolve(join(dataDir, RUNTIMES)) && isOwnFolder(folder, name))) throw new CommsError("CONFIG", `${runtimeDir} is not a runtime of ${name} in ${join(dataDir, RUNTIMES)}, so the launcher was not switched to it`);
3342
+ if (!await launcherCanStart(runtimeDir, name)) throw new CommsError("CONFIG", `${runtimeDir} has no CLI to start at ${launcherCli(runtimeDir, name)}, so the launcher was not switched to it`, { hint: "Install that runtime again: a managed install or an update puts its CLI in place." });
3343
+ return folder;
3344
+ }
3345
+ /** One change of a pointer at a time, across every process of this installation. */
3346
+ function withPointerLock(dataDir, name, change) {
3347
+ return withFileLock(join(dataDir, LAUNCH, `${checkedName(name)}${LOCK}`), change, {
3348
+ timeoutMs: 1e4,
3349
+ staleMs: 3e4
3350
+ });
3351
+ }
3352
+ /**
3353
+ * Points the launcher at `runtimeDir`, and returns the pointer it replaced (undefined when there was none).
3354
+ *
3355
+ * `switchPointer` without the answer; exported for an install or an update that may have to put it back
3356
+ * (`restorePointer`) and must know exactly what it was.
3357
+ */
3358
+ async function exchangePointer(dataDir, name, runtimeDir) {
3359
+ const folder = await switchableFolder(dataDir, name, runtimeDir);
3360
+ return withPointerLock(dataDir, name, async () => {
3361
+ const before = await readFolders(dataDir, name).catch(() => void 0);
3362
+ if (before?.current === folder) return asPaths(dataDir, before);
3363
+ await writeFileAtomic(launcherPaths(dataDir, name).pointer, pointerText(folder, before?.current));
3364
+ return before === void 0 ? void 0 : asPaths(dataDir, before);
3365
+ });
3366
+ }
3367
+ /**
3368
+ * Puts back the pointer a switch replaced (`exchangePointer`'s answer; undefined removes the pointer), only while the
3369
+ * pointer still names `expected` — what that switch pointed it at. Anything else means something switched it since,
3370
+ * and that newer pointer stays. Says whether it put it back.
3371
+ */
3372
+ async function restorePointer(dataDir, name, before, expected) {
3373
+ return withPointerLock(dataDir, name, async () => {
3374
+ const now = await readFolders(dataDir, name).catch(() => void 0);
3375
+ if (now === void 0 || now.current !== basename(expected)) return false;
3376
+ const { pointer } = launcherPaths(dataDir, name);
3377
+ if (before === void 0) await rm(pointer, { force: true });
3378
+ else await writeFileAtomic(pointer, pointerText(basename(before.current), before.previous === void 0 ? void 0 : basename(before.previous)));
3379
+ return true;
3380
+ });
3381
+ }
3382
+ function posixWord$1(word) {
3383
+ return `'${word.replaceAll("'", `'"'"'`)}'`;
3384
+ }
3385
+ /** The launcher for macOS and Linux: the shell's built-ins only, so nothing is looked for on PATH. */
3386
+ function posixSource(dataDir, name, node) {
3387
+ const runtimes = join(dataDir, RUNTIMES);
3388
+ const { pointer } = launcherPaths(dataDir, name);
3389
+ const record = fallbackRecordPath(dataDir, name);
3390
+ return `#!/bin/sh
3391
+ # The stable launcher of agent-communications for ${name}, format 1. Written by managed installs and updates; do not edit.
3392
+ # It starts the runtime the pointer beside it names, with the Node below. When that runtime's CLI is not there to
3393
+ # start, it starts the previous runtime instead, once, and records that beside the pointer. It never falls back from a
3394
+ # runtime that started, and never writes to stdout: an MCP client speaks there.
3395
+ node=${posixWord$1(node)}
3396
+ runtimes=${posixWord$1(runtimes)}
3397
+ pointer=${posixWord$1(pointer)}
3398
+ record=${posixWord$1(record)}
3399
+ cli=${posixWord$1(cliWords(name).join("/"))}
3400
+ suffix=${posixWord$1(`-${name}`)}
3401
+ ${LAUNCHER_ENV}=${posixWord$1(name)}
3402
+ export ${LAUNCHER_ENV}
3403
+ current=
3404
+ previous=
3405
+ if [ -f "$pointer" ] && [ -r "$pointer" ]; then
3406
+ while IFS= read -r line || [ -n "$line" ]; do
3407
+ case $line in
3408
+ current=*) [ -z "$current" ] && current=\${line#current=} ;;
3409
+ previous=*) [ -z "$previous" ] && previous=\${line#previous=} ;;
3410
+ esac
3411
+ done < "$pointer"
3412
+ fi
3413
+ startable() {
3414
+ case $1 in
3415
+ ''|.*|*[!0-9A-Za-z.+_-]*) return 1 ;;
3416
+ esac
3417
+ case $1 in
3418
+ *"$suffix") ;;
3419
+ *) return 1 ;;
3420
+ esac
3421
+ [ "$1" != "$suffix" ] && [ -f "$runtimes/$1/$cli" ] && [ -r "$runtimes/$1/$cli" ]
3422
+ }
3423
+ if startable "$current"; then
3424
+ exec "$node" "$runtimes/$current/$cli" "$@"
3425
+ fi
3426
+ if [ "$previous" != "$current" ] && startable "$previous"; then
3427
+ { printf 'from=%s\\n' "$current"; printf 'to=%s\\n' "$previous"; } 2>/dev/null > "$record"
3428
+ exec "$node" "$runtimes/$previous/$cli" "$@"
3429
+ fi
3430
+ printf '%s\\n' "agent-communications: the ${name} launcher has no runtime it can start: its pointer, $pointer, names \${current:-none}, and the runtime before it \${previous:-none}, and neither CLI is in $runtimes. A managed install or an update puts one in place." >&2
3431
+ exit 78
3432
+ `;
3433
+ }
3434
+ /**
3435
+ * The launcher for Windows: a CommonJS script Node runs — the registration starts the Node, and this is its first word —
3436
+ * which runs the runtime's CLI in the same process, so nothing re-parses the words and no second process is left behind
3437
+ * when the client stops the server. `argv[1]` is set to that CLI, as though Node had been started on it.
3438
+ */
3439
+ function windowsSource(dataDir, name, node) {
3440
+ const runtimes = join(dataDir, RUNTIMES);
3441
+ const { pointer } = launcherPaths(dataDir, name);
3442
+ const record = fallbackRecordPath(dataDir, name);
3443
+ const json = JSON.stringify;
3444
+ return `// The stable launcher of agent-communications for ${name}, format 1, for Windows. Written by managed installs and
3445
+ // updates; do not edit. Started by ${json(node)} with this file as its first word: it starts the runtime the pointer
3446
+ // beside it names, in this process. When that runtime's CLI is not there to start, it starts the previous runtime
3447
+ // instead, once, and records that beside the pointer. It never falls back from a runtime that started.
3448
+ 'use strict';
3449
+ const fs = require('node:fs');
3450
+ const path = require('node:path');
3451
+ const { pathToFileURL } = require('node:url');
3452
+ const runtimes = ${json(runtimes)};
3453
+ const pointer = ${json(pointer)};
3454
+ const record = ${json(record)};
3455
+ const cli = ${json(cliWords(name))};
3456
+ const suffix = ${json(`-${name}`)};
3457
+ let current = '';
3458
+ let previous = '';
3459
+ try {
3460
+ for (const line of fs.readFileSync(pointer, 'utf8').split(/\\r?\\n/)) {
3461
+ if (line.startsWith('current=') && current === '') current = line.slice(8);
3462
+ else if (line.startsWith('previous=') && previous === '') previous = line.slice(9);
3463
+ }
3464
+ } catch {}
3465
+ function startable(folder) {
3466
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(folder) || !folder.endsWith(suffix) || folder === suffix) return null;
3467
+ const entry = path.join(runtimes, folder, ...cli);
3468
+ try {
3469
+ if (!fs.statSync(entry).isFile()) return null;
3470
+ fs.accessSync(entry, fs.constants.R_OK);
3471
+ return entry;
3472
+ } catch {
3473
+ return null;
3474
+ }
3475
+ }
3476
+ let entry = startable(current);
3477
+ if (entry === null && previous !== current) {
3478
+ entry = startable(previous);
3479
+ if (entry !== null) {
3480
+ try {
3481
+ const temp = record + '.' + process.pid + '.tmp';
3482
+ fs.writeFileSync(temp, 'from=' + current + '\\nto=' + previous + '\\n');
3483
+ fs.renameSync(temp, record);
3484
+ } catch {}
3485
+ }
3486
+ }
3487
+ if (entry === null) {
3488
+ process.stderr.write('agent-communications: the ' + ${json(name)} + ' launcher has no runtime it can start: its pointer, ' + pointer + ', names ' + (current || 'none') + ', and the runtime before it ' + (previous || 'none') + ', and neither CLI is in ' + runtimes + '. A managed install or an update puts one in place.\\n');
3489
+ process.exit(78);
3490
+ }
3491
+ process.argv[1] = entry;
3492
+ process.env.${LAUNCHER_ENV} = ${json(name)};
3493
+ import(pathToFileURL(entry).href).catch((error) => {
3494
+ process.exitCode = 1;
3495
+ console.error(error);
3496
+ });
3497
+ `;
3498
+ }
3499
+ /**
3500
+ * Writes the launcher `<data>/launch/<name>`: a temporary file, then a rename, so a launcher starting meanwhile runs
3501
+ * the old one or the new, whole. It starts the runtime its pointer names with `node` — given as the registration and
3502
+ * the shim name it, a link left a link (CUE-492) — and falls back once to `previous` when that runtime cannot start
3503
+ * (see the top of this module).
3504
+ *
3505
+ * POSIX: a shell script, executable by its owner alone. Windows (`platform`): a script for Node, with a `package.json`
3506
+ * beside it saying it is CommonJS, whatever a folder above the data directory says; the registration starts `node`.
3507
+ */
3508
+ async function writeLauncher(dataDir, name, node, options = {}) {
3509
+ const platform = options.platform ?? process.platform;
3510
+ const { launcher } = launcherPaths(dataDir, name);
3511
+ if (!(platform === "win32" ? win32.isAbsolute(node) : posix.isAbsolute(node))) throw new CommsError("CONFIG", `the launcher's Node has to be named by its full path, not ${JSON.stringify(node)}`);
3512
+ const data = resolve(dataDir);
3513
+ if (platform === "win32") {
3514
+ await writeFileAtomic(join(dirname(launcher), "package.json"), `${JSON.stringify({ type: "commonjs" })}\n`);
3515
+ await writeFileAtomic(launcher, windowsSource(data, name, node));
3516
+ return;
3517
+ }
3518
+ await writeFileAtomic(launcher, posixSource(data, name, node), 448);
3519
+ }
3520
+ /** Whether the launcher file is there. */
3521
+ async function launcherExists(dataDir, name) {
3522
+ try {
3523
+ return (await stat(launcherPaths(dataDir, name).launcher)).isFile();
3524
+ } catch {
3525
+ return false;
3526
+ }
3527
+ }
3528
+ /** The `--data-dir` a registration pins, in either form, before any `--`. */
3529
+ function dataDirPin(args) {
3530
+ for (let index = 0; index < args.length; index += 1) {
3531
+ const word = args[index];
3532
+ if (word === "--") return void 0;
3533
+ if (word === "--data-dir") return args[index + 1] === "--" ? void 0 : args[index + 1] || void 0;
3534
+ if (word.startsWith("--data-dir=")) return word.slice(11) || void 0;
3535
+ }
3536
+ }
3537
+ /** A path as words to compare: separators made one, a trailing one dropped, without case on Windows. */
3538
+ function comparable(path, platform) {
3539
+ const plain = path.replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/$/, "");
3540
+ return platform === "win32" ? plain.toLowerCase() : plain;
3541
+ }
3542
+ /**
3543
+ * The stable launcher a registered entry starts, read from its own words, or null when it starts none.
3544
+ *
3545
+ * Ours only when the launcher is in the very data directory the entry itself pins with `--data-dir` — the program on
3546
+ * POSIX, the first word after Node on Windows (`platform`) — named like one: a program called `launch/gmail` anywhere
3547
+ * else is somebody else's. Nothing is read from disk.
3548
+ */
3549
+ function registeredLauncher(server, platform) {
3550
+ const dataDir = dataDirPin(server.args);
3551
+ if (dataDir === void 0) return null;
3552
+ const directory = `${comparable(dataDir, platform)}/${LAUNCH}`;
3553
+ for (const candidate of [server.command, server.args[0]]) {
3554
+ if (candidate === void 0) continue;
3555
+ const words = comparable(candidate, platform);
3556
+ if (!words.startsWith(`${directory}/`)) continue;
3557
+ const name = candidate.split(/[\\/]+/).at(-1) ?? "";
3558
+ if (words !== `${directory}/${comparable(name, platform)}` || !NAME.test(name)) continue;
3559
+ return {
3560
+ dataDir,
3561
+ name,
3562
+ launcher: candidate
3563
+ };
3564
+ }
3565
+ return null;
3566
+ }
3144
3567
  /**
3145
3568
  * The programs this package starts, named so that no folder it happens to be in can stand in for them.
3146
3569
  *
@@ -3239,12 +3662,12 @@ const RECEIPT_FILE = "managed-shims.json";
3239
3662
  const MUTATION_LOCK_FILE = "managed-shims.lock";
3240
3663
  const POSIX_SHIM_MODE = 493;
3241
3664
  const WINDOWS_SHIM_NOTICE = "Managed CLI shims are not offered on Windows yet because cmd.exe re-parses arguments forwarded through .cmd files, including shell metacharacters. Use the full-path command or the labelled PowerShell & line the result provides.";
3242
- function platformOf(context) {
3665
+ function platformOf$1(context) {
3243
3666
  return context.platform ?? process.platform;
3244
3667
  }
3245
3668
  /** Why the selected platform has no managed shim in this release. */
3246
3669
  function managedShimUnsupportedNotice(context = {}) {
3247
- return platformOf(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
3670
+ return platformOf$1(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
3248
3671
  }
3249
3672
  /** The per-user directory managed installs put their command shims in. */
3250
3673
  function managedShimDirectory(context) {
@@ -3253,7 +3676,7 @@ function managedShimDirectory(context) {
3253
3676
  }
3254
3677
  /** Managed CLI shims are POSIX-only until Windows has an argument-forwarding form that does not re-parse input. */
3255
3678
  function managedShimsSupported(context = {}) {
3256
- const platform = platformOf(context);
3679
+ const platform = platformOf$1(context);
3257
3680
  return platform === "darwin" || platform === "linux";
3258
3681
  }
3259
3682
  /** A managed shim's name: one plain command word, never a path, so joining it to the directory stays inside it. */
@@ -3278,12 +3701,32 @@ function emptyReceipt() {
3278
3701
  };
3279
3702
  }
3280
3703
  async function readReceipt(context) {
3281
- let parsed;
3704
+ let text;
3705
+ try {
3706
+ text = await readFile(receiptPath(context), "utf8");
3707
+ } catch (error) {
3708
+ return error.code === "ENOENT" ? emptyReceipt() : null;
3709
+ }
3710
+ return receiptFrom(text);
3711
+ }
3712
+ /** `readReceipt`, for a reader that cannot wait: the command locator. */
3713
+ function readReceiptSync(context) {
3714
+ let text;
3282
3715
  try {
3283
- parsed = JSON.parse(await readFile(receiptPath(context), "utf8"));
3716
+ text = readFileSync(receiptPath(context), "utf8");
3284
3717
  } catch (error) {
3285
3718
  return error.code === "ENOENT" ? emptyReceipt() : null;
3286
3719
  }
3720
+ return receiptFrom(text);
3721
+ }
3722
+ /** The receipt a file holds, or null when it is not one this installation writes. */
3723
+ function receiptFrom(text) {
3724
+ let parsed;
3725
+ try {
3726
+ parsed = JSON.parse(text);
3727
+ } catch {
3728
+ return null;
3729
+ }
3287
3730
  if (typeof parsed !== "object" || parsed === null || parsed.version !== RECEIPT_VERSION || typeof parsed.shims !== "object" || parsed.shims === null) return null;
3288
3731
  const shims = {};
3289
3732
  for (const [binary, value] of Object.entries(parsed.shims)) {
@@ -3437,7 +3880,7 @@ function normalPath(path, platform) {
3437
3880
  return platform === "win32" ? trimmed.toLowerCase() : trimmed;
3438
3881
  }
3439
3882
  function directoryOnPath(context, directory) {
3440
- const platform = platformOf(context);
3883
+ const platform = platformOf$1(context);
3441
3884
  const value = context.env.PATH ?? context.env.Path ?? "";
3442
3885
  const separator = platform === "win32" ? ";" : ":";
3443
3886
  return value.split(separator).filter(Boolean).some((part) => normalPath(part, platform) === normalPath(directory, platform));
@@ -3445,6 +3888,31 @@ function directoryOnPath(context, directory) {
3445
3888
  function pathNotice(directory) {
3446
3889
  return `The shim directory ${directory} is not on PATH. For this shell, run: export PATH=${posixWord(directory)}:"$PATH". No shell profile was edited.`;
3447
3890
  }
3891
+ /**
3892
+ * The managed shim for `binary` that starts `entry`, for a reader that cannot wait — the command locator, which names
3893
+ * it in place of a versioned runtime's entry when this installation has no stable launcher for it (0.21): its path as a
3894
+ * person puts it on PATH, or null.
3895
+ *
3896
+ * Only a shim this installation's receipt records at that very place, whose file is still the one it wrote, unchanged,
3897
+ * and which starts `entry` — the same file — with a Node that is there: what it runs now is exactly what the command it
3898
+ * stands in for would run. Never on a platform that has no shims, and never a file anyone else wrote.
3899
+ */
3900
+ function managedShimStarting(context, binary, entry) {
3901
+ if (!managedShimsSupported(context) || !isSafeBinary(binary)) return null;
3902
+ try {
3903
+ const publicPath = managedShimPath(context, binary);
3904
+ const path = join(realpathSync(dirname(publicPath)), basename(publicPath));
3905
+ const recorded = readReceiptSync(context)?.shims[binary];
3906
+ if (recorded === void 0 || recorded.path !== path) return null;
3907
+ if (!lstatSync(path).isFile() || sha256(readFileSync(path, "utf8")) !== recorded.digest) return null;
3908
+ if (realpathSync(recorded.entry) !== realpathSync(entry)) return null;
3909
+ accessSync(recorded.node, constants$1.X_OK);
3910
+ accessSync(path, constants$1.X_OK);
3911
+ return publicPath;
3912
+ } catch {
3913
+ return null;
3914
+ }
3915
+ }
3448
3916
  /** Create or repoint one shim, but only while its target is free or still matches this installation's receipt. */
3449
3917
  async function writeManagedShim(context, spec) {
3450
3918
  checkedBinary(spec.binary);
@@ -3571,6 +4039,111 @@ async function removeManagedShimsForRuntimes(context, runtimes, options = {}) {
3571
4039
  };
3572
4040
  });
3573
4041
  }
4042
+ /**
4043
+ * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
4044
+ * to decide whether the release it last heard of is newer than the one running.
4045
+ *
4046
+ * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
4047
+ * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
4048
+ * all of it, so nothing that imported these from there changes.
4049
+ */
4050
+ /**
4051
+ * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
4052
+ *
4053
+ * Anything the registry answers is checked against this before it is used, because a version goes into a directory
4054
+ * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
4055
+ * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
4056
+ */
4057
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
4058
+ function isVersion(value) {
4059
+ return typeof value === "string" && VERSION_PATTERN.test(value);
4060
+ }
4061
+ /** Two numeric strings with no leading zeros, compared as numbers of any size. */
4062
+ function compareNumeric(a, b) {
4063
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
4064
+ return a < b ? -1 : a > b ? 1 : 0;
4065
+ }
4066
+ /**
4067
+ * Semver precedence: -1, 0 or 1, or null when either is not a version.
4068
+ *
4069
+ * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
4070
+ * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
4071
+ * few packages as it can.
4072
+ */
4073
+ function compareVersions(a, b) {
4074
+ const left = VERSION_PATTERN.exec(a);
4075
+ const right = VERSION_PATTERN.exec(b);
4076
+ if (!left || !right) return null;
4077
+ for (const index of [
4078
+ 1,
4079
+ 2,
4080
+ 3
4081
+ ]) {
4082
+ const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
4083
+ if (order !== 0) return order < 0 ? -1 : 1;
4084
+ }
4085
+ const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
4086
+ const [ours, theirs] = [pre(left), pre(right)];
4087
+ if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
4088
+ for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
4089
+ const x = ours[index];
4090
+ const y = theirs[index];
4091
+ if (x === void 0) return -1;
4092
+ if (y === void 0) return 1;
4093
+ const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
4094
+ if (numericX && numericY) {
4095
+ const order = compareNumeric(x, y);
4096
+ if (order !== 0) return order < 0 ? -1 : 1;
4097
+ } else if (numericX !== numericY) return numericX ? -1 : 1;
4098
+ else if (x !== y) return x < y ? -1 : 1;
4099
+ }
4100
+ return 0;
4101
+ }
4102
+ /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
4103
+ function isBehind(version, latest) {
4104
+ if (version === latest) return false;
4105
+ const order = compareVersions(version, latest);
4106
+ return order === null ? true : order < 0;
4107
+ }
4108
+ /**
4109
+ * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
4110
+ * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
4111
+ */
4112
+ /**
4113
+ * A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
4114
+ * `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
4115
+ * text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
4116
+ */
4117
+ function isPlainVersion(value) {
4118
+ return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
4119
+ }
4120
+ /**
4121
+ * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
4122
+ *
4123
+ * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
4124
+ * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
4125
+ * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
4126
+ * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
4127
+ */
4128
+ function satisfiesRange(version, range) {
4129
+ const wanted = version.replace(/^v/, "");
4130
+ if (!isVersion(wanted)) return null;
4131
+ const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
4132
+ if (alternatives.some((comparators) => comparators.length === 0)) return null;
4133
+ let any = false;
4134
+ for (const comparators of alternatives) {
4135
+ let all = true;
4136
+ for (const comparator of comparators) {
4137
+ const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
4138
+ const bound = match?.[2] ?? "";
4139
+ if (!isVersion(bound)) return null;
4140
+ const order = compareVersions(wanted, bound);
4141
+ if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
4142
+ }
4143
+ if (all) any = true;
4144
+ }
4145
+ return any;
4146
+ }
3574
4147
  /** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
3575
4148
  const PIN_OPTIONS = Object.freeze([
3576
4149
  "account",
@@ -3752,6 +4325,8 @@ function pinnedVersion(argument, product, platform) {
3752
4325
  */
3753
4326
  function isProductServer(server, product, platform) {
3754
4327
  const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
4328
+ const stable = registeredLauncher(server, platform);
4329
+ if (stable !== null) return fold(stable.name) === fold(unscoped(product.packageName));
3755
4330
  const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
3756
4331
  if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
3757
4332
  if (server.packageName) {
@@ -3819,12 +4394,14 @@ async function installManagedRuntime(context, product, version) {
3819
4394
  private: true
3820
4395
  }, null, 2)}\n`);
3821
4396
  const npmCli = await findNpmCli();
4397
+ const prefix = await realpath(root);
3822
4398
  await run(context.env, process.execPath, [
3823
4399
  npmCli,
3824
4400
  "install",
3825
4401
  "--prefix",
3826
- root,
4402
+ prefix,
3827
4403
  "--save-exact",
4404
+ "--ignore-scripts",
3828
4405
  "--no-audit",
3829
4406
  "--no-fund",
3830
4407
  `${product.packageName}@${version}`
@@ -3913,40 +4490,49 @@ async function buildEntry(context, product, options, apply, resolved) {
3913
4490
  runtimeMissing: false
3914
4491
  };
3915
4492
  }
4493
+ const { dataDir } = context.core.paths;
4494
+ const entry = {
4495
+ ...managedEntryShape(context, product, node, [
4496
+ ...pathArgs,
4497
+ "mcp",
4498
+ ...serverArgs
4499
+ ]),
4500
+ env
4501
+ };
3916
4502
  if (!apply) {
3917
- const { dataDir } = context.core.paths;
4503
+ const name = launcherName(product.packageName);
3918
4504
  const ready = await reusableRuntime(dataDir, product.packageName, product.version);
4505
+ const pointer = await readPointer(dataDir, name).catch(() => void 0);
3919
4506
  return {
3920
- entry: {
3921
- command: node,
3922
- args: [
3923
- ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
3924
- ...pathArgs,
3925
- "mcp",
3926
- ...serverArgs
3927
- ],
3928
- env
3929
- },
4507
+ entry,
3930
4508
  launcher,
3931
- runtimeMissing: ready === null
4509
+ runtimeMissing: !(ready !== null && await launcherExists(dataDir, name) && pointer?.current === managedRuntimeDir(dataDir, product.packageName, product.version))
3932
4510
  };
3933
4511
  }
4512
+ await installManagedRuntime(context, product, product.version);
3934
4513
  return {
3935
- entry: {
3936
- command: node,
3937
- args: [
3938
- await installManagedRuntime(context, product, product.version),
3939
- ...pathArgs,
3940
- "mcp",
3941
- ...serverArgs
3942
- ],
3943
- env
3944
- },
4514
+ entry,
3945
4515
  launcher,
3946
4516
  runtimeMissing: false
3947
4517
  };
3948
4518
  }
3949
4519
  /**
4520
+ * Points this product's stable launcher at the runtime of the release being installed — every registration of it on
4521
+ * this machine starts that from its next start — and writes the launcher afresh, with the Node the preview named.
4522
+ * Returns how to put the pointer back (`restorePointer`) if what comes after does not land.
4523
+ */
4524
+ async function pointLauncher(context, product, node) {
4525
+ const { dataDir } = context.core.paths;
4526
+ const name = launcherName(product.packageName);
4527
+ const runtime = managedRuntimeDir(dataDir, product.packageName, product.version);
4528
+ await writeLauncher(dataDir, name, node, { platform: platformOf(context) });
4529
+ return {
4530
+ name,
4531
+ runtime,
4532
+ before: await exchangePointer(dataDir, name, runtime)
4533
+ };
4534
+ }
4535
+ /**
3950
4536
  * The calling package's own CLI entry: `src/cli.ts` from source, `dist/cli.mjs` when bundled.
3951
4537
  *
3952
4538
  * `moduleUrl` comes from the product, not from this file. While this code lived inside the Gmail package,
@@ -4285,8 +4871,9 @@ async function preflightInstall(context, product, options) {
4285
4871
  if (reported) previous = claimName(product, options, name, [reported], context.platform);
4286
4872
  }
4287
4873
  const { options: effective, kept } = keepNarrowing(product, options, previous);
4288
- const node = await resolveNode(context.env);
4289
- const command = (options.launcher ?? "managed") === "npx" ? await resolveNpx(context.env, node) : node;
4874
+ const launcher = options.launcher ?? "managed";
4875
+ const node = launcher === "managed" ? nodeProgram(context.env, platformOf(context)) : await resolveNode(context.env);
4876
+ const command = launcher === "npx" ? await resolveNpx(context.env, node) : launcher === "managed" ? managedEntryShape(context, product, node, []).command : node;
4290
4877
  return {
4291
4878
  scan,
4292
4879
  target,
@@ -4297,6 +4884,27 @@ async function preflightInstall(context, product, options) {
4297
4884
  command
4298
4885
  };
4299
4886
  }
4887
+ function platformOf(context) {
4888
+ return context.platform ?? process.platform;
4889
+ }
4890
+ /** A product's stable launcher in this installation's data directory: `<data>/launch/<name>`. */
4891
+ function productLauncher(dataDir, product) {
4892
+ return launcherPaths(dataDir, launcherName(product.packageName)).launcher;
4893
+ }
4894
+ /**
4895
+ * A managed entry: the stable launcher with `words`. On POSIX it is the program; on Windows Node is, with the launcher
4896
+ * as its first word, since a client starts no script there without a shell (see `launcher.ts`).
4897
+ */
4898
+ function managedEntryShape(context, product, node, words) {
4899
+ const launcher = productLauncher(context.core.paths.dataDir, product);
4900
+ return platformOf(context) === "win32" ? {
4901
+ command: node,
4902
+ args: [launcher, ...words]
4903
+ } : {
4904
+ command: launcher,
4905
+ args: [...words]
4906
+ };
4907
+ }
4300
4908
  /**
4301
4909
  * The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
4302
4910
  *
@@ -4450,109 +5058,116 @@ async function mcpInstall(context, product, options, planned) {
4450
5058
  let method = "printed";
4451
5059
  let applied = false;
4452
5060
  let notApplied;
4453
- if (cliName && cliPath && writes) {
4454
- const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
4455
- const args = options.client === "claude-code" ? [
4456
- "mcp",
4457
- "add-json",
4458
- name,
4459
- JSON.stringify(entry),
4460
- "--scope",
4461
- "user"
4462
- ] : [
4463
- "mcp",
4464
- "add",
4465
- name,
4466
- ...codexEnv(entry.env),
4467
- "--",
4468
- entry.command,
4469
- ...entry.args
4470
- ];
4471
- const replacing = previous.find((server) => server.client === options.client);
4472
- if (replacing) {
4473
- const removal = options.client === "claude-code" ? [
5061
+ const pointed = launcher === "managed" && apply ? await pointLauncher(context, product, preflight.node) : null;
5062
+ const unpoint = async () => pointed !== null && await restorePointer(context.core.paths.dataDir, pointed.name, pointed.before, pointed.runtime).catch(() => false);
5063
+ try {
5064
+ if (cliName && cliPath && writes) {
5065
+ const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
5066
+ const args = options.client === "claude-code" ? [
4474
5067
  "mcp",
4475
- "remove",
5068
+ "add-json",
4476
5069
  name,
5070
+ JSON.stringify(entry),
4477
5071
  "--scope",
4478
5072
  "user"
4479
5073
  ] : [
4480
5074
  "mcp",
4481
- "remove",
4482
- name
5075
+ "add",
5076
+ name,
5077
+ ...codexEnv(entry.env),
5078
+ "--",
5079
+ entry.command,
5080
+ ...entry.args
4483
5081
  ];
4484
- try {
4485
- await run(context.env, cliPath, removal);
4486
- } catch (error) {
4487
- const message = error instanceof CommsError ? error.message : String(error);
4488
- if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4489
- }
4490
- try {
4491
- await run(context.env, cliPath, args);
4492
- } catch (error) {
4493
- const restore = options.client === "claude-code" ? [
5082
+ const replacing = previous.find((server) => server.client === options.client);
5083
+ if (replacing) {
5084
+ const removal = options.client === "claude-code" ? [
4494
5085
  "mcp",
4495
- "add-json",
5086
+ "remove",
4496
5087
  name,
4497
- JSON.stringify({
4498
- command: replacing.command,
4499
- args: replacing.args,
4500
- ...replacing.env ? { env: replacing.env } : {}
4501
- }),
4502
5088
  "--scope",
4503
5089
  "user"
4504
5090
  ] : [
4505
5091
  "mcp",
4506
- "add",
4507
- name,
4508
- ...codexEnv(replacing.env),
4509
- "--",
4510
- replacing.command,
4511
- ...replacing.args
5092
+ "remove",
5093
+ name
4512
5094
  ];
4513
- let restored = true;
4514
5095
  try {
4515
- await run(context.env, cliPath, restore);
4516
- } catch {
4517
- restored = false;
5096
+ await run(context.env, cliPath, removal);
5097
+ } catch (error) {
5098
+ const message = error instanceof CommsError ? error.message : String(error);
5099
+ if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
5100
+ }
5101
+ try {
5102
+ await run(context.env, cliPath, args);
5103
+ } catch (error) {
5104
+ const restore = options.client === "claude-code" ? [
5105
+ "mcp",
5106
+ "add-json",
5107
+ name,
5108
+ JSON.stringify({
5109
+ command: replacing.command,
5110
+ args: replacing.args,
5111
+ ...replacing.env ? { env: replacing.env } : {}
5112
+ }),
5113
+ "--scope",
5114
+ "user"
5115
+ ] : [
5116
+ "mcp",
5117
+ "add",
5118
+ name,
5119
+ ...codexEnv(replacing.env),
5120
+ "--",
5121
+ replacing.command,
5122
+ ...replacing.args
5123
+ ];
5124
+ let restored = true;
5125
+ try {
5126
+ await run(context.env, cliPath, restore);
5127
+ } catch {
5128
+ restored = false;
5129
+ }
5130
+ if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
5131
+ hint: "Check the client is not running, then try again.",
5132
+ cause: error
5133
+ });
5134
+ throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
5135
+ binary: product.binary,
5136
+ words: installWords(product, effective, name, []),
5137
+ say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
5138
+ }, { cause: error });
4518
5139
  }
4519
- if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
4520
- hint: "Check the client is not running, then try again.",
5140
+ } else try {
5141
+ await run(context.env, cliPath, args);
5142
+ } catch (error) {
5143
+ const message = error instanceof CommsError ? error.message : String(error);
5144
+ if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
5145
+ hint: `Look at it with ${clientCommandHint([
5146
+ cliName,
5147
+ "mcp",
5148
+ "get",
5149
+ name
5150
+ ], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
5151
+ cliName,
5152
+ "mcp",
5153
+ "remove",
5154
+ name
5155
+ ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
4521
5156
  cause: error
4522
5157
  });
4523
- throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
4524
- binary: product.binary,
4525
- words: installWords(product, effective, name, []),
4526
- say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
4527
- }, { cause: error });
5158
+ throw error;
4528
5159
  }
4529
- } else try {
4530
- await run(context.env, cliPath, args);
4531
- } catch (error) {
4532
- const message = error instanceof CommsError ? error.message : String(error);
4533
- if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
4534
- hint: `Look at it with ${clientCommandHint([
4535
- cliName,
4536
- "mcp",
4537
- "get",
4538
- name
4539
- ], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
4540
- cliName,
4541
- "mcp",
4542
- "remove",
4543
- name
4544
- ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
4545
- cause: error
4546
- });
4547
- throw error;
5160
+ method = "cli";
5161
+ applied = true;
5162
+ } else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
5163
+ else if (configPath && writes) {
5164
+ await mergeIntoJsonConfig(configPath, options.client, name, entry);
5165
+ method = "file";
5166
+ applied = true;
4548
5167
  }
4549
- method = "cli";
4550
- applied = true;
4551
- } else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
4552
- else if (configPath && writes) {
4553
- await mergeIntoJsonConfig(configPath, options.client, name, entry);
4554
- method = "file";
4555
- applied = true;
5168
+ } catch (error) {
5169
+ await unpoint();
5170
+ throw error;
4556
5171
  }
4557
5172
  if (record && !applied) try {
4558
5173
  await appendPrivateLine(ledger, JSON.stringify(record));
@@ -4564,12 +5179,13 @@ async function mcpInstall(context, product, options, planned) {
4564
5179
  let verification = "skipped";
4565
5180
  let verifyDetail;
4566
5181
  if (!options.noVerify) {
4567
- if (runtimeMissing) verifyDetail = "the managed runtime is not installed, and --print installs nothing";
5182
+ if (runtimeMissing) verifyDetail = "the stable launcher does not start this release's managed runtime yet, and --print installs nothing";
4568
5183
  else {
4569
5184
  const check = await verifyEntry(entry, product);
4570
5185
  verified = check.ok;
4571
5186
  verification = check.ok ? "passed" : "failed";
4572
5187
  verifyDetail = check.detail;
5188
+ if (!check.ok && pointed !== null && await unpoint()) verifyDetail = pointed.before === void 0 ? `${check.detail}; so the stable launcher's pointer was taken away again, as there was none before this install` : `${check.detail}; so the stable launcher was pointed back at ${pointed.before.current}, which it starts until this is fixed`;
4573
5189
  }
4574
5190
  }
4575
5191
  return {
@@ -4746,6 +5362,10 @@ async function listManagedRuntimes(dataDir, packageName) {
4746
5362
  * - only directories directly inside `<data>/runtime`, named like a version, that hold this product's package —
4747
5363
  * never a symbolic link, never another product's runtime, never anything else in that directory;
4748
5364
  * - never this release's own;
5365
+ * - never one newer than this release, or than the runtime its stable launcher starts: what an update is installing,
5366
+ * or checking before it switches, is newer than anything a pointer names yet. The update lock keeps a person's
5367
+ * prune from running beside an update (`serverPruneChange`); this keeps that runtime even when no lock is held —
5368
+ * an update interrupted half-way, a prune that is not a person's;
4749
5369
  * - never one an entry names in a client config `scanRegisteredServers` reads, in any scope — the ones this
4750
5370
  * environment names, and every one an install recorded registering into, wherever `CLAUDE_CONFIG_DIR` or
4751
5371
  * `CODEX_HOME` pointed then. When one of those files is there and cannot be read, nothing is removed at all: a
@@ -4808,11 +5428,33 @@ async function pruneManagedRuntimes(context, product, options = {}) {
4808
5428
  const registered = scan.servers;
4809
5429
  const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
4810
5430
  const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
5431
+ const name = launcherName(product.packageName);
5432
+ let pointer;
5433
+ try {
5434
+ pointer = await readPointer(context.core.paths.dataDir, name);
5435
+ } catch {
5436
+ const at = launcherPaths(context.core.paths.dataDir, name).pointer;
5437
+ return refuse(`${at} could not be read, so none of these can be shown not to be what the stable launcher starts`);
5438
+ }
5439
+ const pointed = (aliases) => {
5440
+ const named = (runtime) => runtime !== void 0 && aliases.some((alias) => resolve(alias) === resolve(runtime));
5441
+ if (named(pointer?.current)) return "the stable launcher starts it";
5442
+ if (named(pointer?.previous)) return "the stable launcher's previous runtime: what it falls back to, and what a rollback goes back to";
5443
+ return null;
5444
+ };
4811
5445
  const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
5446
+ const starts = pointer === void 0 ? null : launcherRuntimeVersion(pointer.current, name);
5447
+ /** Why a runtime newer than this release, or than what the launcher starts, stays; null for any other. */
5448
+ const newer = (version) => {
5449
+ const installing = "an update may be installing it, or checking it before it switches";
5450
+ if (compareVersions(version, product.version) === 1) return `newer than this release, ${product.version}: ${installing}`;
5451
+ if (starts !== null && compareVersions(version, starts) === 1) return `newer than ${starts}, which the stable launcher starts: ${installing}`;
5452
+ return null;
5453
+ };
4812
5454
  for (const { path, version, aliases } of candidates) {
4813
5455
  const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
4814
5456
  const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
4815
- const reason = path === current ? "this release" : owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null;
5457
+ const reason = path === current ? "this release" : pointed(aliases) ?? newer(version) ?? (owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null);
4816
5458
  if (reason) {
4817
5459
  result.kept.push({
4818
5460
  path,
@@ -5308,6 +5950,19 @@ const READABLE_CONFIG_VERSIONS = [
5308
5950
  ];
5309
5951
  const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
5310
5952
  const ALIAS_MESSAGE = "names must be 1–32 lowercase letters, digits or hyphens, starting with a letter or digit";
5953
+ /**
5954
+ * How this machine updates (design 2026-10-16, U2), from the most updating to the least: `auto` — the default — checks
5955
+ * daily and installs in the background once its job is set up; `every-14-days` checks at most once every 14 days and
5956
+ * installs only with the person's yes; `never` asks the registry nothing.
5957
+ */
5958
+ const UPDATE_LEVELS = [
5959
+ "auto",
5960
+ "every-14-days",
5961
+ "never"
5962
+ ];
5963
+ function isUpdateLevel(value) {
5964
+ return typeof value === "string" && UPDATE_LEVELS.includes(value);
5965
+ }
5311
5966
  /** Which naming a configuration's body has: 1 — plain words — or 2 — `organisation/platform`. */
5312
5967
  function namingOf(config) {
5313
5968
  return config.version === 3 ? config.naming : config.version;
@@ -5418,7 +6073,8 @@ const defaultsSchema = looseObject({
5418
6073
  downloadsDir: string().optional(),
5419
6074
  timezone: string().default("system"),
5420
6075
  confirm: looseObject({ elicitationClients: array(string()).default([]) }).default({ elicitationClients: [] }),
5421
- updateCheck: _enum(["on", "off"]).optional()
6076
+ updateCheck: _enum(["on", "off"]).optional(),
6077
+ updates: string().min(1).optional()
5422
6078
  });
5423
6079
  const RESERVED_ALIASES = /* @__PURE__ */ new Set(["all"]);
5424
6080
  /** Within one map, a reserved alias or a duplicate id is a hard error in every version. */
@@ -5492,6 +6148,10 @@ const slackAppRecordSchema = looseObject({
5492
6148
  clientId: string().min(1),
5493
6149
  appId: string().min(1).optional()
5494
6150
  });
6151
+ const agentAppRecordSchema = looseObject({
6152
+ appId: string().min(1),
6153
+ sha256: string().min(1)
6154
+ });
5495
6155
  const organisationRecordSchema = looseObject({
5496
6156
  label: string(),
5497
6157
  source: looseObject({
@@ -5512,9 +6172,16 @@ const organisationRecordSchema = looseObject({
5512
6172
  redirectPort: number().int().min(1).max(65535),
5513
6173
  apps: looseObject({
5514
6174
  read: slackAppRecordSchema.optional(),
5515
- send: slackAppRecordSchema.optional()
6175
+ send: slackAppRecordSchema.optional(),
6176
+ agent: agentAppRecordSchema.optional()
5516
6177
  }).default({})
5517
- }).optional()
6178
+ }).optional(),
6179
+ updates: looseObject({
6180
+ max: string().min(1).optional(),
6181
+ background: boolean().optional(),
6182
+ sha256: string().min(1)
6183
+ }).optional(),
6184
+ ignoredExtensions: unknown().optional()
5518
6185
  });
5519
6186
  const formerNameSchema = looseObject({
5520
6187
  name: nameSchema,
@@ -5632,6 +6299,25 @@ function schemaFor(version, naming = 2) {
5632
6299
  function schemaOf(config) {
5633
6300
  return schemaFor(config.version, config.version === 3 ? config.naming : void 0);
5634
6301
  }
6302
+ function connectedAccounts(config) {
6303
+ const mail = Object.entries(config.inboxes).map(([alias, inbox]) => ({
6304
+ kind: "mail",
6305
+ alias,
6306
+ id: inbox.id,
6307
+ platform: inbox.provider,
6308
+ secretRef: inbox.secretRef,
6309
+ inbox
6310
+ }));
6311
+ const channel = Object.entries(config.accounts).map(([alias, account]) => ({
6312
+ kind: "channel",
6313
+ alias,
6314
+ id: account.id,
6315
+ platform: account.platform,
6316
+ secretRef: account.secretRef,
6317
+ account
6318
+ }));
6319
+ return [...mail, ...channel].sort((a, b) => a.alias.localeCompare(b.alias));
6320
+ }
5635
6321
  /** The secret backend in use: the recorded one, or the keychain before anything has been stored. */
5636
6322
  function secretsStoreOf(config) {
5637
6323
  return config.secrets?.store ?? "keychain";
@@ -5695,10 +6381,30 @@ function governingChangePolicy(config, change) {
5695
6381
  };
5696
6382
  return [byId(change.target?.id), ...change.loosened.map((loosening) => byId(loosening.id))].includes("confirm") ? "confirm" : "chat";
5697
6383
  }
5698
- /** Whether this machine's daily update check is on: `defaults.updateCheck`, absent reading as `on`. */
6384
+ /** 0.18's switch, as written: `defaults.updateCheck`, absent reading as `on`. */
5699
6385
  function updateCheckSetting(config) {
5700
6386
  return config.defaults.updateCheck ?? "on";
5701
6387
  }
6388
+ /**
6389
+ * The level this machine's configuration sets (design 2026-10-16, U2): `defaults.updates`, else 0.18's switch read as a
6390
+ * level — `on` as `auto`, `off` as `never`. A word this release does not know reads as `auto`, the default.
6391
+ */
6392
+ function updateLevelOf(config) {
6393
+ const set = config.defaults.updates;
6394
+ if (set !== void 0) return isUpdateLevel(set) ? set : "auto";
6395
+ return updateCheckSetting(config) === "off" ? "never" : "auto";
6396
+ }
6397
+ /**
6398
+ * How much a level updates, for `classifyChange`: `auto` 2, `every-14-days` 1, `never` 0. A word this release does not
6399
+ * know is judged as the most updating on the side it leaves and the least on the side it arrives on, as an account's
6400
+ * mode is — an unknown word can cost a person a question, never skip one.
6401
+ */
6402
+ function updateRank(config, side) {
6403
+ const set = config.defaults.updates;
6404
+ const level = set === void 0 ? updateLevelOf(config) : isUpdateLevel(set) ? set : null;
6405
+ if (level === null) return side === "before" ? UPDATE_LEVELS.length - 1 : 0;
6406
+ return UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
6407
+ }
5702
6408
  /** `naming` is the body's: 2 for `organisation/platform` names (version 2, or version 3 over it), 1 for plain ones. */
5703
6409
  function describeIssues(error, naming) {
5704
6410
  return error.issues.slice(0, 5).map((issue) => {
@@ -6255,7 +6961,8 @@ function classifyChange(before, after) {
6255
6961
  if (POLICY_RANK$1[a.sendPolicy] < POLICY_RANK$1[b.sendPolicy]) loosen("defaults.sendPolicy", b.sendPolicy, a.sendPolicy);
6256
6962
  if (POLICY_RANK$1[defaultChangePolicy(after)] < POLICY_RANK$1[defaultChangePolicy(before)]) loosen("defaults.changePolicy", defaultChangePolicy(before), defaultChangePolicy(after));
6257
6963
  if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
6258
- if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
6964
+ if (!(b.updates !== void 0 && b.updates === a.updates) && updateRank(after, "after") < updateRank(before, "before")) loosen("defaults.updates", before.defaults.updates ?? updateLevelOf(before), after.defaults.updates ?? updateLevelOf(after));
6965
+ else if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
6259
6966
  if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
6260
6967
  const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
6261
6968
  const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
@@ -6271,7 +6978,7 @@ function classifyChange(before, after) {
6271
6978
  if (before.secrets === void 0 && after.secrets !== void 0 && after.secrets.store !== "keychain") {
6272
6979
  if (holdsSecrets(before)) loosen("secrets.store", secretsStoreOf(before), after.secrets.store);
6273
6980
  }
6274
- for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was), labelSettingValue(loosening, loosening.now));
6981
+ for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was, before), labelSettingValue(loosening, loosening.now, after));
6275
6982
  return {
6276
6983
  loosened: changes.map((change) => change.path),
6277
6984
  changes
@@ -6286,11 +6993,22 @@ function agentLabelRuleText(rule) {
6286
6993
  const kinds = rule.kinds === void 0 ? "" : ` for ${rule.kinds.join("/")}`;
6287
6994
  return `${rule.account}${where}${kinds}: ${rule.as}`;
6288
6995
  }
6289
- /** A label setting's value as a loosening carries it: the rules in words, the icons as they are, the rest as text. */
6290
- function labelSettingValue(loosening, value) {
6996
+ /**
6997
+ * A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles by the name of
6998
+ * the workspace each is for, in `config` — the side the value was read from — and the rest as text.
6999
+ *
7000
+ * Icons and styles are stored by the account's id, so they follow a rename; a preview names the workspace as the person
7001
+ * knows it, `acme/slack`, never its id, which they have never seen (0.21). The ids are still what an approval binds:
7002
+ * `changedSettings` records the stored values as they are. An id that names no connected account — one removed since —
7003
+ * has no other name, and keeps its id.
7004
+ */
7005
+ function labelSettingValue(loosening, value, config) {
6291
7006
  if (value === void 0 || value === null) return void 0;
6292
7007
  if (loosening.path === "agentLabel.rules") return value.map(agentLabelRuleText);
6293
- if (loosening.path === "agentLabel.slackIcons") return { ...value };
7008
+ if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") {
7009
+ const accounts = connectedAccounts(config);
7010
+ return Object.fromEntries(Object.entries(value).map(([id, entry]) => [accounts.find((account) => account.id === id)?.alias ?? id, entry]));
7011
+ }
6294
7012
  return String(value);
6295
7013
  }
6296
7014
  /** The label settings a change can write, as the file holds them — raw, as `changedSettings` records every setting. */
@@ -6298,7 +7016,8 @@ const LABEL_SETTINGS = [
6298
7016
  "word",
6299
7017
  "name",
6300
7018
  "rules",
6301
- "slackIcons"
7019
+ "slackIcons",
7020
+ "slackStyles"
6302
7021
  ];
6303
7022
  function storedAgentLabel(config) {
6304
7023
  const stored = config.agentLabel;
@@ -7161,11 +7880,15 @@ var ApprovalStore = class {
7161
7880
  #now;
7162
7881
  #handoffs;
7163
7882
  #loadConfig;
7883
+ /** `ApprovalStoreOptions.answered`, never throwing. */
7884
+ answered;
7164
7885
  constructor(stateDir, options = {}) {
7165
7886
  this.directory = join(stateDir, "approvals");
7166
7887
  this.#handoffs = options.handoffs;
7167
7888
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
7168
7889
  this.#loadConfig = options.loadConfig;
7890
+ const answered = options.answered;
7891
+ this.answered = answered === void 0 ? void 0 : (approvalId) => answered(approvalId).catch(() => void 0);
7169
7892
  registerStoreInternals(this, {
7170
7893
  directory: this.directory,
7171
7894
  now: () => this.#now(),
@@ -7754,6 +8477,7 @@ var ApprovalStore = class {
7754
8477
  }, { usedSaid: options.usedSaid });
7755
8478
  const failed = failure;
7756
8479
  if (failed) throw refusedAfter(refusalFor(done.record)(failed.code, failed.reason, done.record), done);
8480
+ if (kind === "send" && done.record.state === "approved") await this.answered?.(approvalId);
7757
8481
  return done.record;
7758
8482
  }
7759
8483
  /**
@@ -7803,6 +8527,7 @@ var ApprovalStore = class {
7803
8527
  if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
7804
8528
  const claimToken = randomBytes(16).toString("hex");
7805
8529
  await this.#markClaimed(done, claimToken);
8530
+ await this.answered?.(approvalId);
7806
8531
  return {
7807
8532
  record: done.record,
7808
8533
  claimToken,
@@ -9436,6 +10161,7 @@ function registrationFactsOf({ packageName, manifest }) {
9436
10161
  /** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
9437
10162
  function launcherOf(server, facts, platform) {
9438
10163
  const fold = folding(platform);
10164
+ if (stableLauncherOf(server, facts, platform) !== null) return "managed";
9439
10165
  const parts = [server.command, ...server.args];
9440
10166
  if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
9441
10167
  if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
@@ -9443,10 +10169,49 @@ function launcherOf(server, facts, platform) {
9443
10169
  if (parts.some((part) => local.test(part))) return "local";
9444
10170
  return "other";
9445
10171
  }
9446
- /** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
10172
+ /**
10173
+ * The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none.
10174
+ *
10175
+ * An entry that starts the stable launcher names no version: it runs the release its launcher's pointer names, read
10176
+ * here — null when the pointer is not there, cannot be read, or names a folder that is not a release.
10177
+ */
9447
10178
  function registrationVersion(server, facts, platform) {
10179
+ const stable = stableLauncherOf({
10180
+ command: server.command ?? "",
10181
+ args: server.args
10182
+ }, facts, platform);
10183
+ if (stable !== null) {
10184
+ const current = pointedRuntime(stable.dataDir, stable.name);
10185
+ return current === null ? null : launcherRuntimeVersion(current, stable.name);
10186
+ }
9448
10187
  return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
9449
10188
  }
10189
+ /**
10190
+ * The stable launcher of this product an entry starts, in the data directory the entry pins, or null: see
10191
+ * `registeredLauncher`. Another product's launcher is not this one's.
10192
+ */
10193
+ function stableLauncherOf(server, facts, platform) {
10194
+ const stable = registeredLauncher(server, platform);
10195
+ if (stable === null) return null;
10196
+ let own;
10197
+ try {
10198
+ own = launcherName(facts.packageName);
10199
+ } catch {
10200
+ return null;
10201
+ }
10202
+ return folding(platform)(stable.name) === folding(platform)(own) ? {
10203
+ ...stable,
10204
+ name: own
10205
+ } : null;
10206
+ }
10207
+ /** The runtime a launcher's pointer names as current, or null when it has none that can be read. */
10208
+ function pointedRuntime(dataDir, name) {
10209
+ try {
10210
+ return readPointerSync(dataDir, name)?.current ?? null;
10211
+ } catch {
10212
+ return null;
10213
+ }
10214
+ }
9450
10215
  PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
9451
10216
  /**
9452
10217
  * Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
@@ -9454,6 +10219,7 @@ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
9454
10219
  * A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
9455
10220
  */
9456
10221
  function startsProduct(server, facts, platform) {
10222
+ if (registeredLauncher(server, platform) !== null) return stableLauncherOf(server, facts, platform) !== null;
9457
10223
  const { words: args } = withoutPathOptions(server.args);
9458
10224
  const packageName = commandLinePackage(server.command, args);
9459
10225
  return isProductServer({
@@ -9479,6 +10245,24 @@ function isNpxRegistration(server, facts, platform) {
9479
10245
  * program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
9480
10246
  */
9481
10247
  function registrationFiles(server, facts, platform) {
10248
+ const { packageName } = facts;
10249
+ const stable = packageName === void 0 ? null : stableLauncherOf(server, { packageName }, platform);
10250
+ if (stable !== null) {
10251
+ let pointer;
10252
+ try {
10253
+ pointer = readPointerSync(stable.dataDir, stable.name);
10254
+ } catch {
10255
+ return {
10256
+ scripts: [],
10257
+ command: null
10258
+ };
10259
+ }
10260
+ const runtime = pointer === void 0 ? void 0 : launcherCanStartSync(pointer.current, stable.name) || pointer.previous === void 0 ? pointer.current : pointer.previous;
10261
+ return {
10262
+ scripts: runtime === void 0 ? [] : [launcherCli(runtime, stable.name)],
10263
+ command: null
10264
+ };
10265
+ }
9482
10266
  const { words } = withoutPathOptions(server.args);
9483
10267
  const sentinel = words.indexOf("--");
9484
10268
  const options = sentinel < 0 ? words : words.slice(0, sentinel);
@@ -9492,107 +10276,7 @@ function folding(platform) {
9492
10276
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
9493
10277
  }
9494
10278
  /** The package version, read from package.json at build time. */
9495
- const VERSION = "0.19.0";
9496
- /**
9497
- * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
9498
- * to decide whether the release it last heard of is newer than the one running.
9499
- *
9500
- * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
9501
- * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
9502
- * all of it, so nothing that imported these from there changes.
9503
- */
9504
- /**
9505
- * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
9506
- *
9507
- * Anything the registry answers is checked against this before it is used, because a version goes into a directory
9508
- * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
9509
- * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
9510
- */
9511
- const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
9512
- function isVersion(value) {
9513
- return typeof value === "string" && VERSION_PATTERN.test(value);
9514
- }
9515
- /** Two numeric strings with no leading zeros, compared as numbers of any size. */
9516
- function compareNumeric(a, b) {
9517
- if (a.length !== b.length) return a.length < b.length ? -1 : 1;
9518
- return a < b ? -1 : a > b ? 1 : 0;
9519
- }
9520
- /**
9521
- * Semver precedence: -1, 0 or 1, or null when either is not a version.
9522
- *
9523
- * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
9524
- * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
9525
- * few packages as it can.
9526
- */
9527
- function compareVersions(a, b) {
9528
- const left = VERSION_PATTERN.exec(a);
9529
- const right = VERSION_PATTERN.exec(b);
9530
- if (!left || !right) return null;
9531
- for (const index of [
9532
- 1,
9533
- 2,
9534
- 3
9535
- ]) {
9536
- const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
9537
- if (order !== 0) return order < 0 ? -1 : 1;
9538
- }
9539
- const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
9540
- const [ours, theirs] = [pre(left), pre(right)];
9541
- if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
9542
- for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
9543
- const x = ours[index];
9544
- const y = theirs[index];
9545
- if (x === void 0) return -1;
9546
- if (y === void 0) return 1;
9547
- const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
9548
- if (numericX && numericY) {
9549
- const order = compareNumeric(x, y);
9550
- if (order !== 0) return order < 0 ? -1 : 1;
9551
- } else if (numericX !== numericY) return numericX ? -1 : 1;
9552
- else if (x !== y) return x < y ? -1 : 1;
9553
- }
9554
- return 0;
9555
- }
9556
- /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
9557
- function isBehind(version, latest) {
9558
- if (version === latest) return false;
9559
- const order = compareVersions(version, latest);
9560
- return order === null ? true : order < 0;
9561
- }
9562
- /**
9563
- * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
9564
- * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
9565
- */
9566
- function isPrerelease(version) {
9567
- return VERSION_PATTERN.exec(version)?.[4] !== void 0;
9568
- }
9569
- /**
9570
- * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
9571
- *
9572
- * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
9573
- * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
9574
- * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
9575
- * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
9576
- */
9577
- function satisfiesRange(version, range) {
9578
- const wanted = version.replace(/^v/, "");
9579
- if (!isVersion(wanted)) return null;
9580
- const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
9581
- if (alternatives.some((comparators) => comparators.length === 0)) return null;
9582
- let any = false;
9583
- for (const comparators of alternatives) {
9584
- let all = true;
9585
- for (const comparator of comparators) {
9586
- const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
9587
- const bound = match?.[2] ?? "";
9588
- if (!isVersion(bound)) return null;
9589
- const order = compareVersions(wanted, bound);
9590
- if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
9591
- }
9592
- if (all) any = true;
9593
- }
9594
- return any;
9595
- }
10279
+ const VERSION = "0.21.0";
9596
10280
  /**
9597
10281
  * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
9598
10282
  *
@@ -9723,7 +10407,13 @@ function locateCliCommand(request, runtime = {
9723
10407
  } catch (error) {
9724
10408
  return fail("arguments", error instanceof Error ? error.message : String(error));
9725
10409
  }
9726
- const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform, program);
10410
+ const build = (found) => stableWayIn(buildCommand(target, found, words, runtime, callerSource, platform, program), found, target, {
10411
+ paths: request.paths,
10412
+ env: request.env,
10413
+ words,
10414
+ platform,
10415
+ program
10416
+ });
9727
10417
  const located = (built, found, direction) => Object.freeze({
9728
10418
  ok: true,
9729
10419
  command: built.command,
@@ -9909,6 +10599,70 @@ function buildCommand(target, found, words, runtime, callerSource, platform, pro
9909
10599
  };
9910
10600
  }
9911
10601
  /**
10602
+ * A managed runtime's command, as its stable way in (0.21). A command naming `<data>/runtime/0.20.0-core/…/cli.mjs`
10603
+ * stops working once that runtime is pruned — and the background job prunes old runtimes after each update — so a
10604
+ * command printed from a managed runtime of this installation names what stays put instead:
10605
+ *
10606
+ * - **its stable launcher**, `<data>/launch/<name>` (`launcherPaths`), when it is there and its pointer names a runtime
10607
+ * holding this package at exactly this release, whose CLI can start: the launcher, then the words, as a registration
10608
+ * starts it — on Windows, where the launcher is a script Node runs, this Node first;
10609
+ * - else **its managed CLI shim**, where shims are offered, when this installation's receipt records one that is
10610
+ * unchanged and starts this very entry (`managedShimStarting`): a release before the launcher wrote such shims, and
10611
+ * an update rewrites them;
10612
+ * - else the runtime's entry, as before.
10613
+ *
10614
+ * Only for a package inside `<data>/runtime` of the folders this command pins: a checkout, an npx cache, a global
10615
+ * install or another data folder's runtime is named as it always was. Checked when the command is printed (D6), as the
10616
+ * entry is: a launcher switched later runs what it is then switched to, as every registration that starts it does.
10617
+ */
10618
+ function stableWayIn(built, found, target, printing) {
10619
+ if (!built.ok || found.source) return built;
10620
+ const { paths, words, platform, program } = printing;
10621
+ const runtimes = realpathOfExisting(join(paths.dataDir, "runtime"));
10622
+ if (runtimes === null || !isWithin$1(runtimes, found.root) || resolve(runtimes) === resolve(found.root)) return built;
10623
+ const launcher = stableLauncher(paths.dataDir, target.packageName, found.manifest.version, platform);
10624
+ if (launcher !== null) return {
10625
+ ok: true,
10626
+ command: new PrintedCommand(GATE, platform === "win32" ? [
10627
+ program,
10628
+ launcher,
10629
+ ...words
10630
+ ] : [launcher, ...words], platform, realpathOfExisting(launcher) ?? launcher),
10631
+ entryKind: "launcher"
10632
+ };
10633
+ const shim = managedShimStarting({
10634
+ env: printing.env ?? process.env,
10635
+ dataDir: paths.dataDir,
10636
+ platform
10637
+ }, target.manifest.binary, built.command.entry);
10638
+ if (shim === null) return built;
10639
+ return {
10640
+ ok: true,
10641
+ command: new PrintedCommand(GATE, [shim, ...words], platform, realpathOfExisting(shim) ?? shim),
10642
+ entryKind: "shim"
10643
+ };
10644
+ }
10645
+ /**
10646
+ * This installation's stable launcher for `packageName`, when it starts that package at exactly `version`: the file is
10647
+ * there (executable, where it is a shell script), its pointer is one the launcher reads, and the runtime it names holds
10648
+ * the package at that version with a CLI the launcher can start. Null otherwise — a pointer that cannot be read too.
10649
+ */
10650
+ function stableLauncher(dataDir, packageName, version, platform) {
10651
+ if (typeof version !== "string") return null;
10652
+ try {
10653
+ const name = launcherName(packageName);
10654
+ const { launcher } = launcherPaths(dataDir, name);
10655
+ if (!statSync(launcher).isFile()) return null;
10656
+ accessSync(launcher, platform === "win32" ? constants$1.R_OK : constants$1.X_OK);
10657
+ const pointer = readPointerSync(dataDir, name);
10658
+ if (pointer === void 0 || !launcherCanStartSync(pointer.current, name)) return null;
10659
+ const started = readPackageManifest(join(pointer.current, "node_modules", ...packageName.split("/")));
10660
+ return started?.name === packageName && started.version === version ? launcher : null;
10661
+ } catch {
10662
+ return null;
10663
+ }
10664
+ }
10665
+ /**
9912
10666
  * The command a package declares for itself: a channel's in its `agentcomms` field, a service's in `agentcommsPackage`
9913
10667
  * (D14). A library declares none, and no package declares both: the registry refuses one that does.
9914
10668
  */
@@ -10411,8 +11165,30 @@ function renderInstall(result, color) {
10411
11165
  for (const warning of result.warnings) lines.push("", paint(color, "red", warning));
10412
11166
  return lines.join("\n");
10413
11167
  }
10414
- /** What `mcp prune` removed and kept, and why each one it kept is still needed. */
11168
+ /**
11169
+ * What `mcp prune` removed and kept, and why each one it kept is still needed. A prune of every managed server lists
11170
+ * each server that had a runtime under its own name, in the registry's order.
11171
+ */
10415
11172
  function renderPrune(result, color) {
11173
+ if ("servers" in result) {
11174
+ const groups = result.servers.map((part) => ({
11175
+ label: part.label,
11176
+ lines: pruneLines(part, color)
11177
+ })).filter((group) => group.lines.length > 0).map((group) => [`${group.label}:`, ...group.lines.map((line) => ` ${line}`)].join("\n"));
11178
+ if (groups.length === 0) return `No managed runtimes to remove in ${result.runtimeDir}.`;
11179
+ const unchecked = (result.refusedServers ?? []).map(({ label, refused }) => `${label} (${refused})`);
11180
+ if (unchecked.length > 0) {
11181
+ const its = unchecked.length === 1 ? "its" : "their";
11182
+ groups.push(paint(color, "yellow", `Not checked, so none of ${its} runtimes was removed: ${unchecked.join("; ")}.`));
11183
+ }
11184
+ return groups.join("\n");
11185
+ }
11186
+ const lines = pruneLines(result, color);
11187
+ if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
11188
+ return lines.join("\n");
11189
+ }
11190
+ /** One server's prune, a line a runtime or shim; none when it had nothing to look at. */
11191
+ function pruneLines(result, color) {
10416
11192
  const lines = [];
10417
11193
  if (result.refused) lines.push(paint(color, "yellow", `Nothing removed: ${result.refused}.`));
10418
11194
  const verb = result.dryRun ? "Would remove" : "Removed";
@@ -10420,8 +11196,7 @@ function renderPrune(result, color) {
10420
11196
  for (const path of result.shims.removed) lines.push(paint(color, "green", `${verb} CLI shim: ${path}`));
10421
11197
  for (const path of result.shims.kept) lines.push(`Kept foreign CLI shim: ${path}`);
10422
11198
  for (const item of result.kept) lines.push(`Kept ${item.version} (${item.reason}): ${item.path}`);
10423
- if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
10424
- return lines.join("\n");
11199
+ return lines;
10425
11200
  }
10426
11201
  /**
10427
11202
  * What a change is about: the inbox or account by the id it has before the change, or by name alone when this change
@@ -10515,7 +11290,7 @@ async function prepareChange(core, request, options) {
10515
11290
  preview: renderChangePreview({
10516
11291
  ...record,
10517
11292
  change: binding
10518
- }),
11293
+ }, { askedHere: options.askedHere === true }),
10519
11294
  expiresAt: record.expiresAt,
10520
11295
  next: nextStep(record.approvalId, policy, core, options),
10521
11296
  approval: await core.approvals.approvalOf(record)
@@ -10605,7 +11380,7 @@ async function beginChangeApproval(core, approvalId, options) {
10605
11380
  const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
10606
11381
  return {
10607
11382
  approvalId,
10608
- preview: renderChangePreview(record),
11383
+ preview: renderChangePreview(record, { codeHere: true }),
10609
11384
  challenge
10610
11385
  };
10611
11386
  } catch (error) {
@@ -10738,11 +11513,13 @@ const SETTING_LABELS = {
10738
11513
  "defaults.downloadsDir": "downloads folder",
10739
11514
  "defaults.confirm.elicitationClients": "clients trusted to ask for a send approval",
10740
11515
  "defaults.updateCheck": "daily update check",
11516
+ "defaults.updates": "how this machine updates",
10741
11517
  "secrets.store": "where credentials are kept",
10742
11518
  "agentLabel.word": "the word after your name in the agent label",
10743
11519
  "agentLabel.name": "the name in the agent label",
10744
11520
  "agentLabel.rules": "who each kind of message appears to come from",
10745
- "agentLabel.slackIcons": "the icon of the agent’s Slack posts"
11521
+ "agentLabel.slackIcons": "the icon of the agent’s Slack posts",
11522
+ "agentLabel.slackStyles": "how the agent’s Slack messages look"
10746
11523
  };
10747
11524
  /**
10748
11525
  * What the loosening means for the person, in their terms.
@@ -10765,12 +11542,14 @@ function meaning(field, after, scope) {
10765
11542
  case "defaults.attachDeny": return "files the removed entries protected can be attached";
10766
11543
  case "defaults.downloadsDir": return "files from other people will be saved here";
10767
11544
  case "defaults.confirm.elicitationClients": return "these clients may ask you to approve a send in their own window";
10768
- case "defaults.updateCheck": return "nothing on this machine will ask npm for a newer release, or stop until one is installed";
11545
+ case "defaults.updateCheck": return "a release before 0.20 on this machine will no longer ask npm for a newer release";
11546
+ case "defaults.updates": return after === "every-14-days" ? "this machine will ask npm for a newer release at most once every 14 days, say one is out at most once every 14 days, and install nothing in the background" : after === "never" ? "nothing on this machine will ask npm for a newer release, say one is out, or install one" : "this release cannot tell how that level updates, so it counts as updating least";
10769
11547
  case "secrets.store": return "credentials will move out of the system keychain into files on this disk";
10770
11548
  case "agentLabel.word": return "what an agent sends will carry this word after your name";
10771
11549
  case "agentLabel.name": return after === null ? "where a channel has no name of its own for you, an agent’s message will be refused until a name is set" : "where a channel has no name of its own for you, what an agent sends will carry this name";
10772
11550
  case "agentLabel.rules": return "what a rule sends as `me` goes out as you, with no label: it will look as if you wrote it yourself";
10773
11551
  case "agentLabel.slackIcons": return "the app’s Slack posts will show this icon beside your name";
11552
+ case "agentLabel.slackStyles": return "every agent message there goes out as you, labelled in its words, even where the app could post: it trades Slack’s own APP tag, which no one can fake, for words in the message";
10774
11553
  default: return "";
10775
11554
  }
10776
11555
  }
@@ -10781,6 +11560,31 @@ function shown(value) {
10781
11560
  if (typeof value === "object") return Object.entries(value).map(([key, entry]) => `${key} ${String(entry)}`).join(", ");
10782
11561
  return String(value);
10783
11562
  }
11563
+ /**
11564
+ * The label settings kept for each Slack workspace, by its name (`labelSettingValue`), and what a workspace with none
11565
+ * has: its style is `app`, and the app's posts show the person's photo.
11566
+ */
11567
+ const PER_WORKSPACE = {
11568
+ "agentLabel.slackStyles": "app",
11569
+ "agentLabel.slackIcons": "your Slack photo"
11570
+ };
11571
+ function recordOf$1(value) {
11572
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11573
+ }
11574
+ /**
11575
+ * A setting kept for each workspace, as the person reads it: each workspace it moves, by name, from → to —
11576
+ * `in acme/slack: app → labelled` — and none it leaves as it was. Undefined for any other setting, or a value of
11577
+ * another shape, which is shown as every other setting is.
11578
+ */
11579
+ function perWorkspace(field, before, after) {
11580
+ const none = PER_WORKSPACE[field];
11581
+ const [was, now] = [recordOf$1(before ?? {}), recordOf$1(after ?? {})];
11582
+ if (none === void 0 || was === void 0 || now === void 0) return void 0;
11583
+ const value = (record, name) => Object.hasOwn(record, name) ? String(record[name]) : none;
11584
+ const moved = [.../* @__PURE__ */ new Set([...Object.keys(was), ...Object.keys(now)])].filter((name) => value(was, name) !== value(now, name)).sort();
11585
+ if (moved.length === 0) return void 0;
11586
+ return moved.map((name) => `in ${name}: ${value(was, name)} → ${value(now, name)}`).join(", and ");
11587
+ }
10784
11588
  /** One loosening as a line a person reads: what, from, to, and what that means. */
10785
11589
  function describeLoosening(loosening) {
10786
11590
  const scoped = /^(inboxes|accounts)\.([^.]+)\.(.+)$/.exec(loosening.path);
@@ -10788,7 +11592,8 @@ function describeLoosening(loosening) {
10788
11592
  const label = SETTING_LABELS[field] ?? field;
10789
11593
  const what = scoped ? `${scoped[2]} ${label}${loosening.id === void 0 ? " (connected by this change)" : ""}` : label;
10790
11594
  const words = meaning(field, loosening.after, scoped ? scoped[1] : void 0);
10791
- return truncateDisplay(`${what}: ${shown(loosening.before)} → ${shown(loosening.after)}${words ? ` — ${words}` : ""}`, 300);
11595
+ const workspaces = scoped ? void 0 : perWorkspace(field, loosening.before, loosening.after);
11596
+ return truncateDisplay(`${workspaces === void 0 ? `${what}: ${shown(loosening.before)} → ${shown(loosening.after)}` : `${what} ${workspaces}`}${words ? ` — ${words}` : ""}`, 300);
10792
11597
  }
10793
11598
  /**
10794
11599
  * The change as a person reads it before agreeing: what it is for, every setting it loosens from → to in words, and
@@ -10798,10 +11603,14 @@ function describeLoosening(loosening) {
10798
11603
  * once as it was prepared (`doneAtOnce`). There it says what is true: those lines, listed apart, are done already, and
10799
11604
  * the rest is what waits for the approval. Decided by that field alone, never by the words of an effect, which can
10800
11605
  * carry text a person or a profile chose; every other preview's header is word for word what it was.
11606
+ *
11607
+ * It says how the change is approved: under `confirm`, a code typed at a terminal; under `chat`, a yes in the chat —
11608
+ * or, `askedHere`, a yes typed here, at this terminal, where a CLI asks the person reading it at once; or, `codeHere`,
11609
+ * a code typed here, at this terminal, where `approve <id>` shows a change prepared under `chat` and asks for its code.
10801
11610
  */
10802
- function renderChangePreview(record) {
11611
+ function renderChangePreview(record, options = {}) {
10803
11612
  const { change } = record;
10804
- const how = changePolicyOf(record) === "chat" ? "a yes in the chat" : "a code typed at a terminal";
11613
+ const how = changePolicyOf(record) === "chat" ? options.codeHere === true ? "a code typed here, at this terminal" : options.askedHere === true ? "a yes typed here, at this terminal" : "a yes in the chat" : "a code typed at a terminal";
10805
11614
  const target = change.target;
10806
11615
  const about = target === null ? "the whole configuration" : `${target.kind} ${target.name}${target.id === void 0 ? ", which this change connects" : ""}`;
10807
11616
  const doneAlready = (change.doneAtOnce ?? []).length > 0;
@@ -10862,7 +11671,8 @@ async function gatedChange(core, change, options) {
10862
11671
  prepared: await prepareChange(core, request, {
10863
11672
  surface: options.surface,
10864
11673
  channel: options.channel,
10865
- platform: options.platform
11674
+ platform: options.platform,
11675
+ askedHere: options.askedHere
10866
11676
  })
10867
11677
  };
10868
11678
  const { consent, approval } = await claimChangeApproval(core, options.approvalId, request, {
@@ -10885,15 +11695,17 @@ async function gatedChange(core, change, options) {
10885
11695
  */
10886
11696
  async function gatedChangeAtTerminal(core, change, options) {
10887
11697
  const streams = options.streams ?? defaultStreams;
11698
+ const person = agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true });
10888
11699
  const first = await gatedChange(core, change, {
10889
11700
  surface: "cli",
10890
11701
  channel: options.channel,
10891
11702
  approvalId: options.approvalId,
10892
- platform: options.output.platform
11703
+ platform: options.output.platform,
11704
+ askedHere: person
10893
11705
  });
10894
11706
  if (first.status === "applied") return first.result;
10895
11707
  const { prepared } = first;
10896
- if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
11708
+ if (!person) {
10897
11709
  if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
10898
11710
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
10899
11711
  const handoffs = requireHandoffs(core, options.output.platform);
@@ -10912,7 +11724,7 @@ async function gatedChangeAtTerminal(core, change, options) {
10912
11724
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
10913
11725
  } else if (options.answered !== true) {
10914
11726
  streams.stdout.write(`${prepared.preview}\n\n`);
10915
- if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
11727
+ if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
10916
11728
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
10917
11729
  surface: "cli",
10918
11730
  disposition: "person"
@@ -10977,7 +11789,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
10977
11789
  }
10978
11790
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
10979
11791
  streams.stdout.write(`${prompt.preview}\n\n`);
10980
- const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
11792
+ const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
10981
11793
  if (!answer.trim()) {
10982
11794
  await revokeChange(core, approvalId, "cancelled at the terminal", {
10983
11795
  surface: "cli",
@@ -10997,7 +11809,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
10997
11809
  function cancelled() {
10998
11810
  return new CommsError("USAGE", "cancelled: nothing was changed");
10999
11811
  }
11000
- async function askLine$1(streams, question) {
11812
+ async function askLine(streams, question) {
11001
11813
  const { createInterface } = await import("node:readline/promises");
11002
11814
  const rl = createInterface({
11003
11815
  input: streams.stdin,
@@ -11363,80 +12175,6 @@ var InboxStateStore = class {
11363
12175
  });
11364
12176
  }
11365
12177
  };
11366
- function openCore(options = {}) {
11367
- const { paths, pathOverrides } = resolvePathIdentity(options);
11368
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
11369
- const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
11370
- caller: options.caller,
11371
- paths,
11372
- platform: options.platform,
11373
- env: options.env
11374
- });
11375
- const config = new ConfigStore(paths.configDir, { handoffs });
11376
- const audit = new AuditLog(paths.stateDir, now);
11377
- let cached = null;
11378
- return {
11379
- paths,
11380
- pathOverrides,
11381
- config,
11382
- states: new InboxStateStore(paths.stateDir),
11383
- approvals: new ApprovalStore(paths.stateDir, {
11384
- now,
11385
- handoffs,
11386
- loadConfig: () => config.load(),
11387
- audit
11388
- }),
11389
- ledger: new SendLedger(paths.stateDir, now),
11390
- plans: new PlanStore(paths.stateDir, now),
11391
- taint: new TaintStore(paths.stateDir, now),
11392
- audit,
11393
- handoffs,
11394
- async secrets(kind) {
11395
- const chosen = kind ?? secretsStoreOf(await config.load());
11396
- if (cached?.kind === chosen) return cached.store;
11397
- const store = await openSecretStore(chosen, {
11398
- secretsDir: paths.secretsDir,
11399
- namespace: keychainNamespace(paths.configDir),
11400
- handoffs
11401
- });
11402
- cached = {
11403
- kind: chosen,
11404
- store
11405
- };
11406
- return store;
11407
- }
11408
- };
11409
- }
11410
- /**
11411
- * A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
11412
- * whole — and `NaN` for anything else.
11413
- *
11414
- * `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
11415
- * empty string as 0. Each of those is a number nobody typed, so none of them is read as one here. A value too large to
11416
- * hold exactly is not one either: it would be used as some other number.
11417
- */
11418
- function readWholeNumber(raw) {
11419
- const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
11420
- return Number.isSafeInteger(value) ? value : NaN;
11421
- }
11422
- /**
11423
- * A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
11424
- * names the option and its range.
11425
- *
11426
- * One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
11427
- * operations those commands share with their tools, so a number the command refuses is refused by the tool in the
11428
- * same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
11429
- */
11430
- function wholeNumber(raw, rule) {
11431
- if (raw === void 0) return void 0;
11432
- const value = readWholeNumber(raw);
11433
- if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
11434
- const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
11435
- const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
11436
- throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
11437
- }
11438
- return value;
11439
- }
11440
12178
  /**
11441
12179
  * A Google client id as an organisation profile has to write it (design 2026-10-02 §D2): the project number, a hyphen,
11442
12180
  * the client's own part, and Google's suffix — and nothing else.
@@ -11457,9 +12195,10 @@ const GOOGLE_CLIENT_ID_PATTERN = /^[0-9]{1,30}-[a-z0-9]{1,64}\.apps\.googleuserc
11457
12195
  * one place a profile changes the configuration, each change approved first.
11458
12196
  *
11459
12197
  * **A profile is data, never code, and none of it is trusted.** Every field comes from whoever wrote the document, so
11460
- * each has a grammar and a bound, an unknown key is an error, and the two free-text fields are neutralised and put on
11461
- * one line wherever they are shown. The client secret is never shown anywhere — not in a preview, an effect, an error,
11462
- * the audit log, a command's output or a tool's result.
12198
+ * each has a grammar and a bound, an unknown key is an error — anywhere but inside `extensions`, the one door a later
12199
+ * release's fields come in by (design 2026-10-19) — and the two free-text fields are neutralised and put on one line
12200
+ * wherever they are shown. The client secret is never shown anywhere — not in a preview, an effect, an error, the audit
12201
+ * log, a command's output or a tool's result.
11463
12202
  */
11464
12203
  /** What a profile says it is, in its first key. */
11465
12204
  const PROFILE_KIND = "organisation-profile";
@@ -11486,6 +12225,18 @@ const slackAppSchema = strictObject({
11486
12225
  clientId: string().regex(SLACK_CLIENT_ID_PATTERN, "a Slack client id is two runs of digits around a dot"),
11487
12226
  appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits").optional()
11488
12227
  });
12228
+ /** An extension's name: a lower-case letter, then 1–39 lower-case letters, digits or hyphens (X1). */
12229
+ const EXTENSION_NAME_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
12230
+ const extensionEntrySchema = looseObject({
12231
+ required: boolean({ message: "required is true or false: whether a release that does not know it must refuse it" }),
12232
+ since: string({ message: "since is the first release that understands it, as MAJOR.MINOR.PATCH" }).regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, "since is the first release that understands it, as MAJOR.MINOR.PATCH: 0.23.0")
12233
+ });
12234
+ /**
12235
+ * The one door a version-1 profile has for what later releases add (design 2026-10-19, X1): at most 16 entries, each
12236
+ * named to the grammar above and each an object carrying `required` and `since`. Its other fields are the extension's;
12237
+ * every other object of the profile stays strict.
12238
+ */
12239
+ const extensionsSchema = record(string().regex(EXTENSION_NAME_PATTERN, "an extension is named by a lower-case letter, then 1–39 lower-case letters, digits or hyphens"), extensionEntrySchema, { message: "extensions is an object of named entries" }).refine((entries) => Object.keys(entries).length <= 16, { message: `extensions has at most 16 entries` });
11489
12240
  strictObject({
11490
12241
  agentcomms: literal(PROFILE_KIND),
11491
12242
  version: literal(1),
@@ -11505,8 +12256,145 @@ strictObject({
11505
12256
  read: slackAppSchema.optional(),
11506
12257
  send: slackAppSchema.optional()
11507
12258
  })
11508
- }).optional()
12259
+ }).optional(),
12260
+ extensions: extensionsSchema.optional()
11509
12261
  });
12262
+ /**
12263
+ * A string that came from a profile, as it is shown anywhere: chat-template tokens, role markers and envelope look-alikes
12264
+ * neutralised (`neutralise`), every control and invisible character made visible, tabs and line breaks flattened to a
12265
+ * space, and cut to `width`. `neutralise` keeps line feeds and tabs on purpose — they are legitimate in a message body —
12266
+ * so the flattening is what keeps a label from printing a second line of a preview.
12267
+ */
12268
+ function shownText(value, width = 120) {
12269
+ return truncateDisplay(neutralise(typeof value === "string" ? value : String(value ?? "")).text, width);
12270
+ }
12271
+ /**
12272
+ * A profile's path as it is shown — in a preview, an error, a command's output: neutralised and on one line, as every
12273
+ * other string a person did not write themselves. The path is chosen by whoever named the file — a repository, a
12274
+ * message with an attachment — and its name is text like any other.
12275
+ */
12276
+ function shownPath(path) {
12277
+ return shownText(path, 400);
12278
+ }
12279
+ /**
12280
+ * The bytes at an absolute path, in one bounded read: open once, check it is a regular file, read at most one byte more
12281
+ * than `PROFILE_MAX_BYTES` from the same handle. A path somebody typed can name a FIFO that never ends, a device, a
12282
+ * directory; none of them is a profile, and none may be how a command hangs. A symlink is followed: the path is one the
12283
+ * person chose. `unopened` says what a file that cannot be opened is: a refusal for the profile, whatever the reason;
12284
+ * nothing for a file beside it (`readSideFile`) when it is not there, and a refusal when it is there and cannot be
12285
+ * opened.
12286
+ */
12287
+ async function readBounded(path, what) {
12288
+ let handle;
12289
+ try {
12290
+ handle = await open(path, constants$1.O_RDONLY | (constants$1.O_NONBLOCK ?? 0));
12291
+ } catch (error) {
12292
+ return what.unopened(error);
12293
+ }
12294
+ try {
12295
+ if (!(await handle.stat()).isFile()) throw new CommsError("USAGE", `${shownPath(path)} is not a file`);
12296
+ const buffer = Buffer.allocUnsafe(65537);
12297
+ const { bytesRead } = await handle.read(buffer, 0, 65537, 0);
12298
+ if (bytesRead > 65536) throw new CommsError("BAD_DATA", `${shownPath(path)} is larger than ${what.size} can be (64 KiB)`, { hint: `${what.size.charAt(0).toUpperCase()}${what.size.slice(1)} is a few hundred bytes of JSON. Check this is the file your organisation sent.` });
12299
+ return Buffer.from(buffer.subarray(0, bytesRead));
12300
+ } catch (error) {
12301
+ if (error instanceof CommsError) throw error;
12302
+ throw new CommsError("NOT_FOUND", `${what.noun} at ${shownPath(path)} could not be read`, { cause: error });
12303
+ } finally {
12304
+ await handle.close();
12305
+ }
12306
+ }
12307
+ /** The most symbolic links followed from a profile's path to its file: as many as the system itself follows. */
12308
+ const LINK_HOPS = 40;
12309
+ /**
12310
+ * The file a profile's path names, through every symbolic link at its end: the path itself when it is not a link, or
12311
+ * cannot be looked at — what reading it then finds is the bounded read's to say. A link's target is read against the
12312
+ * folder the link is in. The folders on the way are left as they are spelt: the file beside the result is in the same
12313
+ * folder whatever they resolve to.
12314
+ *
12315
+ * What the files beside a profile are looked for beside (`readProfileFile`): an organisation keeps them beside its
12316
+ * profile, in its own checkout, and a person who links to that profile from somewhere else has linked to the profile
12317
+ * alone. Looked for beside the link, a caps file is not there, and the caps it names would be lifted.
12318
+ */
12319
+ function profileTarget(path) {
12320
+ let at = path;
12321
+ for (let hops = 0; hops < LINK_HOPS; hops += 1) {
12322
+ let link;
12323
+ try {
12324
+ if (!lstatSync(at).isSymbolicLink()) return at;
12325
+ link = readlinkSync(at);
12326
+ } catch {
12327
+ return at;
12328
+ }
12329
+ at = resolve(dirname(at), link);
12330
+ }
12331
+ throw new CommsError("USAGE", `${shownPath(path)} is a chain of more than ${LINK_HOPS} symbolic links`, { hint: "Pass the path of the profile file itself." });
12332
+ }
12333
+ /** A side file's path for a profile: in the same folder, the profile's name with `.<suffix>.json` for its `.json`. */
12334
+ function sidePathFor(profilePath, suffix) {
12335
+ return /\.json$/i.test(profilePath) ? `${profilePath.slice(0, -5)}.${suffix}.json` : `${profilePath}.${suffix}.json`;
12336
+ }
12337
+ /**
12338
+ * Reads one file beside a profile, if there is one, and holds it to the profile: the same organisation, and whatever
12339
+ * else `agrees` asks. Read in one bounded read as the profile is (`readBounded`), strict as the profile is, and refused
12340
+ * in the same words — naming each problem by where it is, never by the value found there — but naming this file, so
12341
+ * the person knows which of the two to fix. Only a file that is not there is none: one that is there and cannot be
12342
+ * opened is refused, since read as none it would lift what it says — the caps, or the one app `workspace bot` takes.
12343
+ */
12344
+ async function readSideFile(path, profile, side) {
12345
+ const where = shownPath(path);
12346
+ const bytes = await readBounded(path, {
12347
+ unopened: (error) => {
12348
+ if (error.code === "ENOENT") return null;
12349
+ throw new CommsError("NOT_FOUND", `${side.noun} at ${where} could not be opened`, { cause: error });
12350
+ },
12351
+ noun: side.noun,
12352
+ size: side.size
12353
+ });
12354
+ if (bytes === null) return void 0;
12355
+ const refuse = (problem) => {
12356
+ throw new CommsError("BAD_DATA", `${side.noun} ${where} is not valid: ${problem}`, { hint: side.hint });
12357
+ };
12358
+ let text;
12359
+ try {
12360
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
12361
+ } catch {
12362
+ return refuse("it is not UTF-8 text");
12363
+ }
12364
+ let raw;
12365
+ try {
12366
+ raw = JSON.parse(text.startsWith("") ? text.slice(1) : text);
12367
+ } catch {
12368
+ return refuse("it is not valid JSON");
12369
+ }
12370
+ const parsed = side.schema.safeParse(raw);
12371
+ if (!parsed.success) return refuse(parsed.error.issues.slice(0, 6).map((issue) => {
12372
+ const at = issue.path.length > 0 ? issue.path.map(String).join(".") : "the file";
12373
+ return issue.code === "unrecognized_keys" ? `${at}: ${issue.keys.map((key) => `"${shownText(key, 40)}"`).join(", ")} ${issue.keys.length === 1 ? "is not a key" : "are not keys"} it has` : `${at}: ${issue.message}`;
12374
+ }).join("; "));
12375
+ const document = parsed.data;
12376
+ if (document.organisation !== profile.organisation) return refuse(`it names the organisation ${shownText(document.organisation, 40)}, not ${profile.organisation}, whose profile it sits beside`);
12377
+ const problem = side.agrees?.(document, profile);
12378
+ if (problem !== void 0) return refuse(problem);
12379
+ return {
12380
+ path,
12381
+ sha256: createHash("sha256").update(bytes).digest("hex"),
12382
+ document
12383
+ };
12384
+ }
12385
+ strictObject({
12386
+ agentcomms: literal("organisation-agent-app"),
12387
+ version: literal(1),
12388
+ organisation: string(),
12389
+ slack: strictObject({
12390
+ workspace: string().regex(SLACK_WORKSPACE_ID_PATTERN, "a Slack workspace id is T followed by capital letters or digits"),
12391
+ appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits")
12392
+ })
12393
+ });
12394
+ /** The organisation profiles this configuration records — none on version 1, which cannot hold them. */
12395
+ function organisationsOf(config) {
12396
+ return hasNames(config) ? config.organisations ?? {} : {};
12397
+ }
11510
12398
  strictObject({
11511
12399
  organisation: string().refine((word) => parseOrganisation(word) !== null, "an organisation is a name’s first half"),
11512
12400
  role: _enum(["read", "send"]),
@@ -11519,6 +12407,845 @@ strictObject({
11519
12407
  sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
11520
12408
  });
11521
12409
  /**
12410
+ * An organisation's update caps (design 2026-10-16, U8): the most its members' machines may update — `max`,
12411
+ * `every-14-days` or `never` — and `background: false`, no background job. They only ever tighten: a member's level is
12412
+ * capped at `max`, and nothing a member sets goes past it.
12413
+ *
12414
+ * **They are named in a file beside the profile, not in it** (`readSideFile`): a key such as `updates` in the profile
12415
+ * would make every release from 0.13.0 to 0.19.0 refuse it. They go in `<profile>.updates.json`, recorded as the
12416
+ * organisation's `updates`.
12417
+ */
12418
+ const UPDATE_CAPS = ["every-14-days", "never"];
12419
+ /** The updates file for a profile: in the same folder, the profile's name with `.updates.json` for its `.json`. */
12420
+ function updatesPathFor(profilePath) {
12421
+ return sidePathFor(profilePath, "updates");
12422
+ }
12423
+ const updatesSchema = strictObject({
12424
+ organisation: string(),
12425
+ max: _enum(UPDATE_CAPS, { message: "max is every-14-days or never: a cap only ever makes updates less frequent" }).optional(),
12426
+ background: literal(false, { message: "background is false or left out: a cap can only turn background updates off" }).optional()
12427
+ });
12428
+ /** The updates file, held to the profile's organisation (`readSideFile`). */
12429
+ async function readUpdatesFile(path, profile) {
12430
+ const read = await readSideFile(path, profile, {
12431
+ noun: "the updates file",
12432
+ size: "an updates file",
12433
+ schema: updatesSchema,
12434
+ hint: "It names the organisation, and the caps on how its members update and nothing else: `max` (every-14-days or never) and `background` (false). Ask your organisation for its current one."
12435
+ });
12436
+ if (read === void 0) return void 0;
12437
+ const { max, background } = read.document;
12438
+ return {
12439
+ path: read.path,
12440
+ sha256: read.sha256,
12441
+ ...max === void 0 ? {} : { max },
12442
+ ...background === void 0 ? {} : { background: false }
12443
+ };
12444
+ }
12445
+ /**
12446
+ * The caps file beside a profile, read with no profile to hold it to but the organisation's word: what stands in for
12447
+ * the caps the configuration records when `config.json` cannot be read (`effectiveUpdates`, failing closed). Beside the
12448
+ * file the path names (`profileTarget`), in the same bounded, strict read, held to the same organisation. Refused as
12449
+ * `readProfileFile` refuses it; undefined when there is none.
12450
+ */
12451
+ async function readUpdatesBeside(profilePath, organisation) {
12452
+ return readUpdatesFile(updatesPathFor(profileTarget(profilePath)), { organisation });
12453
+ }
12454
+ /** The systemd units' and the Windows task's name. */
12455
+ const UPDATE_JOB_NAME = "agentcomms-update";
12456
+ Object.freeze(["update", "--background"]);
12457
+ function updateJobSupported(platform) {
12458
+ return platform === "darwin" || platform === "linux" || platform === "win32";
12459
+ }
12460
+ function updateJobMarkerPath(stateDir) {
12461
+ return join(stateDir, "update-job.json");
12462
+ }
12463
+ const isTime$1 = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
12464
+ /** The record, or null when there is none — or none this release can read, which is treated as none. */
12465
+ async function readUpdateJobMarker(stateDir) {
12466
+ let parsed;
12467
+ try {
12468
+ parsed = JSON.parse(await readFile(updateJobMarkerPath(stateDir), "utf8"));
12469
+ } catch {
12470
+ return null;
12471
+ }
12472
+ const record = parsed;
12473
+ if (typeof record !== "object" || record === null || !updateJobSupported(String(record.platform)) || typeof record.installedAt !== "string" || typeof record.label !== "string") return null;
12474
+ return {
12475
+ platform: record.platform,
12476
+ installedAt: record.installedAt,
12477
+ label: record.label,
12478
+ ...isTime$1(record.lastRun) ? { lastRun: record.lastRun } : {}
12479
+ };
12480
+ }
12481
+ function within$1(at, now, intervalMs) {
12482
+ if (at === void 0 || !isTime$1(at)) return false;
12483
+ const since = now.getTime() - Date.parse(at);
12484
+ return since >= -3e5 && since < intervalMs;
12485
+ }
12486
+ /** The job's health, from its record; null when there is none (`readUpdateJobMarker`). */
12487
+ async function updateJobHealth(stateDir, now = /* @__PURE__ */ new Date()) {
12488
+ const marker = await readUpdateJobMarker(stateDir);
12489
+ if (marker === null) return null;
12490
+ return {
12491
+ marker,
12492
+ working: within$1(marker.lastRun, now, 1728e5) || within$1(marker.installedAt, now, 1728e5),
12493
+ since: marker.lastRun ?? marker.installedAt
12494
+ };
12495
+ }
12496
+ Object.freeze(["/usr/bin", "/bin"]);
12497
+ /** A PowerShell single-quoted string: nothing inside is special but a single quote — the curly ones too — doubled. */
12498
+ function powerShellLiteral(text) {
12499
+ return `'${text.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
12500
+ }
12501
+ `${powerShellLiteral(UPDATE_JOB_NAME)}`;
12502
+ Object.freeze(["every-14-days", "never"]);
12503
+ /**
12504
+ * The daily update check, as every server and command reads it (design 2026-09-28, and 2026-10-16 for the notice that
12505
+ * replaced its stop): one file per machine, `update-check.json` in the state directory, saying when the registry was
12506
+ * last asked, the latest release it named, whether this machine's registrations were behind it, any "not now" with when
12507
+ * it runs out, and when a command last said so.
12508
+ *
12509
+ * This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
12510
+ * Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
12511
+ * imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
12512
+ * that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
12513
+ */
12514
+ /** The file, in the state directory: one per machine, shared by every server and command on it. */
12515
+ const UPDATE_CHECK_FILE = "update-check.json";
12516
+ /**
12517
+ * The switch that turns the whole thing off for one process — no check, no notice — whatever the setting says. Every
12518
+ * test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
12519
+ * real registry.
12520
+ */
12521
+ const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
12522
+ /**
12523
+ * The level for one process, whatever the machine's (design 2026-10-16, U2): `never` switches it all off as
12524
+ * `AGENT_COMMS_UPDATE_CHECK=off` does. Any other word leaves the machine's level as it is.
12525
+ */
12526
+ const UPDATES_ENV = "AGENT_COMMS_UPDATES";
12527
+ /**
12528
+ * Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
12529
+ * extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
12530
+ * and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its notice always says
12531
+ * where it is updated, whatever the channel's registrations say: restarting would start the release the plugin pins.
12532
+ */
12533
+ const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
12534
+ const DAY_MS = 864e5;
12535
+ /** How long a check is good for on `auto`: the registry is asked at most once in this long, by the whole machine. */
12536
+ const UPDATE_CHECK_INTERVAL_MS = DAY_MS;
12537
+ /** How long a check, and a notice, is good for on `every-14-days`. */
12538
+ const SPACED_INTERVAL_MS = 14 * DAY_MS;
12539
+ Object.freeze([
12540
+ 1,
12541
+ 7,
12542
+ 14
12543
+ ]);
12544
+ function readSecurityShown(value) {
12545
+ if (typeof value !== "object" || value === null) return null;
12546
+ const { version, approvals } = value;
12547
+ if (!isPlainVersion(version) || !Array.isArray(approvals)) return null;
12548
+ return {
12549
+ version,
12550
+ approvals: approvals.filter((id) => typeof id === "string" && APPROVAL_ID_PATTERN.test(id)).slice(-32)
12551
+ };
12552
+ }
12553
+ const NOTICE_OUTCOMES = [
12554
+ "installed",
12555
+ "skipped",
12556
+ "refused",
12557
+ "failed",
12558
+ "job-removed"
12559
+ ];
12560
+ function readBackgroundNotice(value) {
12561
+ if (typeof value !== "object" || value === null) return null;
12562
+ const { at, outcome, version, text } = value;
12563
+ if (!isTime(at) || !NOTICE_OUTCOMES.includes(outcome) || typeof text !== "string") return null;
12564
+ return {
12565
+ at,
12566
+ outcome,
12567
+ version: isVersion(version) ? version : null,
12568
+ text: text.slice(0, 600)
12569
+ };
12570
+ }
12571
+ const EMPTY_UPDATE_CHECK = Object.freeze({
12572
+ lastChecked: null,
12573
+ latest: null,
12574
+ behind: null,
12575
+ current: null,
12576
+ lastError: null,
12577
+ snoozedUntil: null,
12578
+ checking: null,
12579
+ commandNoticeAt: null,
12580
+ noticeAt: null,
12581
+ pausedAt: null,
12582
+ pausedUntil: null,
12583
+ release: null,
12584
+ securityAsked: null,
12585
+ securityShown: null,
12586
+ backgroundNotice: null,
12587
+ moves: null
12588
+ });
12589
+ function updateCheckPath(stateDir) {
12590
+ return join(stateDir, UPDATE_CHECK_FILE);
12591
+ }
12592
+ const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
12593
+ const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
12594
+ function readRelease(value) {
12595
+ if (typeof value !== "object" || value === null) return null;
12596
+ const { version, security, needsDecision } = value;
12597
+ if (!isPlainVersion(version)) return null;
12598
+ return {
12599
+ version,
12600
+ security: security === true,
12601
+ needsDecision: needsDecision === true
12602
+ };
12603
+ }
12604
+ function readCurrent(value) {
12605
+ if (typeof value !== "object" || value === null) return null;
12606
+ const { registered, global } = value;
12607
+ return isWords(registered) && isWords(global) ? {
12608
+ registered: [...registered],
12609
+ global: [...global]
12610
+ } : null;
12611
+ }
12612
+ /**
12613
+ * The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
12614
+ * as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
12615
+ */
12616
+ async function readUpdateCheck(stateDir) {
12617
+ let parsed;
12618
+ try {
12619
+ parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
12620
+ } catch {
12621
+ return { ...EMPTY_UPDATE_CHECK };
12622
+ }
12623
+ const held = typeof parsed === "object" && parsed !== null ? parsed : {};
12624
+ return {
12625
+ lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
12626
+ latest: isPlainVersion(held.latest) ? held.latest : null,
12627
+ behind: typeof held.behind === "boolean" ? held.behind : null,
12628
+ current: readCurrent(held.current),
12629
+ lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
12630
+ snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
12631
+ checking: isTime(held.checking) ? held.checking : null,
12632
+ commandNoticeAt: isTime(held.commandNoticeAt) ? held.commandNoticeAt : null,
12633
+ noticeAt: isTime(held.noticeAt) ? held.noticeAt : null,
12634
+ pausedAt: isTime(held.pausedAt) ? held.pausedAt : null,
12635
+ pausedUntil: isTime(held.pausedUntil) ? held.pausedUntil : null,
12636
+ release: readRelease(held.release),
12637
+ securityAsked: isPlainVersion(held.securityAsked) ? held.securityAsked : null,
12638
+ securityShown: readSecurityShown(held.securityShown),
12639
+ backgroundNotice: readBackgroundNotice(held.backgroundNotice),
12640
+ moves: typeof held.moves === "number" && Number.isInteger(held.moves) && held.moves >= 0 ? held.moves : null
12641
+ };
12642
+ }
12643
+ /**
12644
+ * Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
12645
+ * and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
12646
+ * alongside the record written; `change` returning `null` writes nothing.
12647
+ */
12648
+ async function changeUpdateCheck(stateDir, change) {
12649
+ return withFileLock(join(stateDir, ".update-check.lock"), async () => {
12650
+ const current = await readUpdateCheck(stateDir);
12651
+ const next = change(current);
12652
+ if (next === null) return {
12653
+ record: current,
12654
+ result: void 0,
12655
+ written: false
12656
+ };
12657
+ await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
12658
+ return {
12659
+ record: next.record,
12660
+ result: next.result,
12661
+ written: true
12662
+ };
12663
+ }, { timeoutMs: 2e3 });
12664
+ }
12665
+ /**
12666
+ * Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
12667
+ * minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
12668
+ * moments, and a check just claimed by one must not look due to the other.
12669
+ */
12670
+ function updateCheckDue(record, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
12671
+ return !within(record.lastChecked, now, intervalMs);
12672
+ }
12673
+ /** Whether `at` is less than `intervalMs` ago — a time from more than a few minutes in the future is not trusted. */
12674
+ function within(at, now, intervalMs) {
12675
+ if (at === null) return false;
12676
+ const since = now.getTime() - Date.parse(at);
12677
+ return since >= -CLOCK_SKEW_MS && since < intervalMs;
12678
+ }
12679
+ const CLOCK_SKEW_MS = 3e5;
12680
+ /** What turned updates off for this process, from its environment: `CI`, or a switch. Null when nothing did. */
12681
+ function updateCheckSwitchedOff(env) {
12682
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
12683
+ const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
12684
+ if (value !== void 0 && [
12685
+ "off",
12686
+ "0",
12687
+ "false",
12688
+ "no"
12689
+ ].includes(value)) return UPDATE_CHECK_ENV;
12690
+ if (env["AGENT_COMMS_UPDATES"]?.trim().toLowerCase() === "never") return UPDATES_ENV;
12691
+ return null;
12692
+ }
12693
+ /** How much a level updates: `auto` 2, `every-14-days` 1, `never` 0. */
12694
+ const rankOf = (level) => UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
12695
+ /** `profileTarget`, for a path read back from the configuration: one it cannot follow is shown as it is. */
12696
+ function safeTarget(path) {
12697
+ try {
12698
+ return profileTarget(path);
12699
+ } catch {
12700
+ return path;
12701
+ }
12702
+ }
12703
+ /**
12704
+ * The caps the organisation profiles here name (design 2026-10-16, U8): the strictest `max`, and the first profile
12705
+ * with `background: false`, each with where it comes from. A `max` word this release does not know counts as `never`,
12706
+ * the strictest it could mean: caps only ever tighten.
12707
+ */
12708
+ function organisationCaps(config) {
12709
+ let cap;
12710
+ let backgroundOff;
12711
+ for (const [organisation, record] of Object.entries(organisationsOf(config)).sort(([a], [b]) => a.localeCompare(b))) {
12712
+ const caps = record.updates;
12713
+ if (caps === void 0) continue;
12714
+ const where = {
12715
+ organisation,
12716
+ source: updatesPathFor(safeTarget(record.source.path))
12717
+ };
12718
+ if (caps.max !== void 0) {
12719
+ const level = caps.max === "every-14-days" ? "every-14-days" : "never";
12720
+ if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
12721
+ ...where,
12722
+ level
12723
+ };
12724
+ }
12725
+ if (caps.background === false && backgroundOff === void 0) backgroundOff = where;
12726
+ }
12727
+ return {
12728
+ cap,
12729
+ backgroundOff
12730
+ };
12731
+ }
12732
+ /**
12733
+ * The level in force by the configuration alone (design 2026-10-16, U2, U8): the level set, capped by its
12734
+ * organisations' caps, and the organisation that turns background updates off. What `effectiveUpdates` starts from,
12735
+ * and what a background run and the background job's setup read — neither of which runs where the environment
12736
+ * switched updates off, and neither of which counts a configuration it cannot read as anything.
12737
+ */
12738
+ function configuredUpdates(config) {
12739
+ const set = updateLevelOf(config);
12740
+ const caps = organisationCaps(config);
12741
+ const cap = caps.cap !== void 0 && rankOf(caps.cap.level) < rankOf(set) ? caps.cap : void 0;
12742
+ return {
12743
+ level: cap?.level ?? set,
12744
+ set,
12745
+ ...cap ? { cap } : {},
12746
+ ...caps.backgroundOff ? { backgroundOff: caps.backgroundOff } : {}
12747
+ };
12748
+ }
12749
+ /**
12750
+ * The caps that stand in for the configuration's when `config.json` cannot be read (U8, failing closed): for every
12751
+ * organisation the file still names — read loosely, as JSON, whatever its schema says — the caps it records, and the
12752
+ * caps file beside its profile, read again (`readUpdatesBeside`). The strictest `max` of them all, and the first
12753
+ * `background: false`. A caps file that cannot be read is passed over; what the configuration recorded of it stands.
12754
+ * A file that is not JSON at all names no organisation, and so no cap.
12755
+ */
12756
+ async function capsWithoutConfig(core) {
12757
+ let raw;
12758
+ try {
12759
+ raw = JSON.parse(await readFile(core.config.path, "utf8"));
12760
+ } catch {
12761
+ return { read: 0 };
12762
+ }
12763
+ const organisations = raw?.organisations;
12764
+ if (typeof organisations !== "object" || organisations === null) return { read: 0 };
12765
+ let cap;
12766
+ let backgroundOff;
12767
+ let read = 0;
12768
+ const take = (organisation, source, caps) => {
12769
+ read += 1;
12770
+ if (caps.max !== void 0) {
12771
+ const level = caps.max === "every-14-days" ? "every-14-days" : "never";
12772
+ if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
12773
+ organisation,
12774
+ source,
12775
+ level
12776
+ };
12777
+ }
12778
+ if (caps.background === false && backgroundOff === void 0) backgroundOff = {
12779
+ organisation,
12780
+ source
12781
+ };
12782
+ };
12783
+ for (const [organisation, record] of Object.entries(organisations).sort(([a], [b]) => a.localeCompare(b))) {
12784
+ const held = typeof record === "object" && record !== null ? record : {};
12785
+ const path = typeof held.source?.path === "string" && isAbsolute(held.source.path) ? held.source.path : void 0;
12786
+ const source = path === void 0 ? core.config.path : updatesPathFor(safeTarget(path));
12787
+ if (typeof held.updates === "object" && held.updates !== null) take(organisation, source, held.updates);
12788
+ if (path === void 0) continue;
12789
+ try {
12790
+ const file = await readUpdatesBeside(path, organisation);
12791
+ if (file !== void 0) take(organisation, file.path, file);
12792
+ } catch {}
12793
+ }
12794
+ return {
12795
+ ...cap ? { cap } : {},
12796
+ ...backgroundOff ? { backgroundOff } : {},
12797
+ read
12798
+ };
12799
+ }
12800
+ /**
12801
+ * The level in force here, read from the environment first, then the configuration and its organisations' caps.
12802
+ *
12803
+ * A configuration that cannot be read sets the default, `auto` — what is wrong with it is for the doctor and the tools
12804
+ * to say, and it must not quietly turn updates off — but it never lifts an organisation's cap: the strictest cap that
12805
+ * can still be read in its place holds (`capsWithoutConfig`), and nothing installs in the background, which reads the
12806
+ * configuration too.
12807
+ */
12808
+ async function effectiveUpdates(core, env, now = /* @__PURE__ */ new Date()) {
12809
+ let configured;
12810
+ try {
12811
+ configured = configuredUpdates(await core.config.load());
12812
+ } catch (error) {
12813
+ const { read, cap, backgroundOff } = await capsWithoutConfig(core);
12814
+ configured = {
12815
+ level: cap?.level ?? "auto",
12816
+ set: "auto",
12817
+ ...cap ? { cap } : {},
12818
+ ...backgroundOff ? { backgroundOff } : {},
12819
+ unreadableConfig: {
12820
+ reason: shownText(error instanceof Error ? error.message : String(error), 200),
12821
+ read
12822
+ }
12823
+ };
12824
+ }
12825
+ const { level, set, ...extra } = configured;
12826
+ const switched = updateCheckSwitchedOff(env);
12827
+ if (switched !== null) return {
12828
+ level: "never",
12829
+ set,
12830
+ by: switched,
12831
+ background: false,
12832
+ ...extra
12833
+ };
12834
+ if (level !== "auto" || extra.backgroundOff !== void 0 || extra.unreadableConfig !== void 0) return {
12835
+ level,
12836
+ set,
12837
+ by: "setting",
12838
+ background: false,
12839
+ ...extra
12840
+ };
12841
+ const job = await updateJobHealth(core.paths.stateDir, now);
12842
+ return {
12843
+ level,
12844
+ set,
12845
+ by: "setting",
12846
+ background: job?.working === true,
12847
+ ...extra,
12848
+ ...job !== null && !job.working ? { jobSilent: { since: job.since } } : {}
12849
+ };
12850
+ }
12851
+ /** Whether a check may run at all, how often, and if not, what turned it off: the environment, then the level. */
12852
+ async function updateCheckEnabled(core, env) {
12853
+ const updates = await effectiveUpdates(core, env);
12854
+ if (updates.level === "never") return {
12855
+ on: false,
12856
+ by: updates.by
12857
+ };
12858
+ return {
12859
+ on: true,
12860
+ intervalMs: updates.level === "every-14-days" ? SPACED_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS
12861
+ };
12862
+ }
12863
+ /**
12864
+ * When a pause ends, while one holds at `now` — or null. A pause holds from when it was asked until its end, and only
12865
+ * while that end is at most 14 days after it was asked: a clock gone back to before it was asked, or an end written by
12866
+ * hand further off, holds nothing, so no pause ever lasts longer than 14 days from when it was asked.
12867
+ */
12868
+ function pauseEnd(record, now) {
12869
+ if (record.pausedAt === null || record.pausedUntil === null) return null;
12870
+ const asked = Date.parse(record.pausedAt);
12871
+ const ends = Date.parse(record.pausedUntil);
12872
+ if (ends - asked > 12099e5) return null;
12873
+ if (now.getTime() < asked - CLOCK_SKEW_MS || now.getTime() >= ends) return null;
12874
+ return new Date(ends);
12875
+ }
12876
+ /**
12877
+ * The security fix this record says is out, for a process running `running`: the latest release, when its manifest
12878
+ * declares one and `running` is behind it. Null otherwise.
12879
+ */
12880
+ function securityRelease(record, running) {
12881
+ const latest = countedLatest(record);
12882
+ if (latest === null || record.release?.version !== latest || !record.release.security) return null;
12883
+ return isVersion(running) && isBehind(running, latest) ? latest : null;
12884
+ }
12885
+ /**
12886
+ * The person answered the security line (design 2026-10-16, U7): the send or post `approvalId` was approved, or claimed
12887
+ * to be sent. When its preview carried the line, that release counts as asked from now on, and the next preview is as
12888
+ * it always was. An approval no preview carried it on answers nothing. Called by the approval store
12889
+ * (`ApprovalStoreOptions.answered`); it never throws, and a send never waits long on it.
12890
+ */
12891
+ async function securityAnswered(stateDir, approvalId) {
12892
+ const carried = (record) => record.securityShown?.approvals.includes(approvalId) === true;
12893
+ try {
12894
+ if (!carried(await readUpdateCheck(stateDir))) return;
12895
+ await changeUpdateCheck(stateDir, (record) => {
12896
+ if (!carried(record) || record.securityShown === null || record.securityShown === void 0) return null;
12897
+ return { record: {
12898
+ ...record,
12899
+ securityAsked: record.securityShown.version,
12900
+ securityShown: null
12901
+ } };
12902
+ });
12903
+ } catch {}
12904
+ }
12905
+ /** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
12906
+ function localStamp(at) {
12907
+ const two = (value) => String(value).padStart(2, "0");
12908
+ return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
12909
+ }
12910
+ /**
12911
+ * The latest release as the check counts it: a plain `MAJOR.MINOR.PATCH` — 0.8.0-rc.1 is never an update anybody is
12912
+ * told of, and 0.8.1+anything is not a release at all. Null when the file names none.
12913
+ */
12914
+ function countedLatest(record) {
12915
+ return isPlainVersion(record.latest) ? record.latest : null;
12916
+ }
12917
+ /**
12918
+ * What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
12919
+ *
12920
+ * `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
12921
+ * registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
12922
+ * registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
12923
+ * configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
12924
+ * code, and a notice that said to restart would give advice that cannot work.
12925
+ */
12926
+ function updateVerdict(record, running, where) {
12927
+ const latest = countedLatest(record);
12928
+ if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
12929
+ return {
12930
+ kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
12931
+ running,
12932
+ latest
12933
+ };
12934
+ }
12935
+ function openCore(options = {}) {
12936
+ const { paths, pathOverrides } = resolvePathIdentity(options);
12937
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
12938
+ const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
12939
+ caller: options.caller,
12940
+ paths,
12941
+ platform: options.platform,
12942
+ env: options.env
12943
+ });
12944
+ const config = new ConfigStore(paths.configDir, { handoffs });
12945
+ const audit = new AuditLog(paths.stateDir, now);
12946
+ let cached = null;
12947
+ return {
12948
+ paths,
12949
+ pathOverrides,
12950
+ config,
12951
+ states: new InboxStateStore(paths.stateDir),
12952
+ approvals: new ApprovalStore(paths.stateDir, {
12953
+ now,
12954
+ handoffs,
12955
+ loadConfig: () => config.load(),
12956
+ audit,
12957
+ answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
12958
+ }),
12959
+ ledger: new SendLedger(paths.stateDir, now),
12960
+ plans: new PlanStore(paths.stateDir, now),
12961
+ taint: new TaintStore(paths.stateDir, now),
12962
+ audit,
12963
+ handoffs,
12964
+ async secrets(kind) {
12965
+ const chosen = kind ?? secretsStoreOf(await config.load());
12966
+ if (cached?.kind === chosen) return cached.store;
12967
+ const store = await openSecretStore(chosen, {
12968
+ secretsDir: paths.secretsDir,
12969
+ namespace: keychainNamespace(paths.configDir),
12970
+ handoffs
12971
+ });
12972
+ cached = {
12973
+ kind: chosen,
12974
+ store
12975
+ };
12976
+ return store;
12977
+ }
12978
+ };
12979
+ }
12980
+ /**
12981
+ * A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
12982
+ * whole — and `NaN` for anything else.
12983
+ *
12984
+ * `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
12985
+ * empty string as 0. Each of those is a number nobody typed, so none of them is read as one here. A value too large to
12986
+ * hold exactly is not one either: it would be used as some other number.
12987
+ */
12988
+ function readWholeNumber(raw) {
12989
+ const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
12990
+ return Number.isSafeInteger(value) ? value : NaN;
12991
+ }
12992
+ /**
12993
+ * A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
12994
+ * names the option and its range.
12995
+ *
12996
+ * One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
12997
+ * operations those commands share with their tools, so a number the command refuses is refused by the tool in the
12998
+ * same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
12999
+ */
13000
+ function wholeNumber(raw, rule) {
13001
+ if (raw === void 0) return void 0;
13002
+ const value = readWholeNumber(raw);
13003
+ if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
13004
+ const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
13005
+ const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
13006
+ throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
13007
+ }
13008
+ return value;
13009
+ }
13010
+ /**
13011
+ * The machine's one update lock (design 2026-10-16 U5; plan, Review focus 1 and 3): a background run, a person's
13012
+ * update (`comms_update`, `agentcomms update`), a rollback and a person's prune (`comms_server_prune`, `mcp prune`)
13013
+ * each hold it for as long as they install, switch or prune, so none of them ever overlaps another — two of them would
13014
+ * switch the same pointers and prune runtimes the other had just installed, or was still checking.
13015
+ *
13016
+ * An install can take minutes, so the holder renews it, and a lock left by a process that died is taken over once it
13017
+ * has gone unrenewed for ten minutes. Nobody waits for it long: a background run that finds it held stops and runs
13018
+ * again the next day, and a person's update is refused at once — before an approval is prepared, where it can be — so
13019
+ * no conversation sits behind an install.
13020
+ */
13021
+ const UPDATE_LOCK_FILE = ".update.lock";
13022
+ function updateLockPath(stateDir) {
13023
+ return join(stateDir, UPDATE_LOCK_FILE);
13024
+ }
13025
+ const HOLD = {
13026
+ staleMs: 6e5,
13027
+ renewMs: 6e4
13028
+ };
13029
+ /** Runs `fn` holding the update lock, waiting at most `waitMs` for it; LOCK_TIMEOUT when another holds it. */
13030
+ async function withUpdateLock(stateDir, fn, waitMs = 1e3) {
13031
+ return withFileLock(updateLockPath(stateDir), fn, {
13032
+ timeoutMs: waitMs,
13033
+ ...HOLD
13034
+ });
13035
+ }
13036
+ /**
13037
+ * A person's update, rollback or prune, holding the update lock: refused, changing nothing, while a background run —
13038
+ * or another person's update — holds it. The refusal says what is under way and when to try again.
13039
+ */
13040
+ async function withPersonUpdateLock(stateDir, fn, what = "update") {
13041
+ try {
13042
+ return await withUpdateLock(stateDir, fn);
13043
+ } catch (error) {
13044
+ const refused = toCommsError(error);
13045
+ if (refused.code !== "LOCK_TIMEOUT" || !refused.message.includes(".update.lock")) throw error;
13046
+ throw new CommsError("LOCK_TIMEOUT", "nothing was changed: a background update run is under way on this machine, or another update is", { hint: `It installs, checks and switches in a few minutes, and the two never overlap. Run the ${what} again in a few minutes; the doctor and comms_audit_tail say what the run did.` });
13047
+ }
13048
+ }
13049
+ /** Refuses at once, changing nothing, while the update lock is held: the check a person's update makes as it plans. */
13050
+ async function refuseWhileUpdating(stateDir, what = "update") {
13051
+ await withPersonUpdateLock(stateDir, async () => void 0, what);
13052
+ }
13053
+ /**
13054
+ * Every sentence a background run may leave for the next session, and the one way a notice of it is said (design
13055
+ * 2026-10-16 U5 step 7, U9; security review of 0.20.0, severity 6): in this package's own words, with nothing in them
13056
+ * the registry or a certificate wrote. A release's version is the one thing a sentence takes from outside, and only as a
13057
+ * plain `MAJOR.MINOR.PATCH`: a prerelease's words or build metadata (`0.20.1+Ignore-previous-instructions` is valid
13058
+ * semver) are never repeated.
13059
+ *
13060
+ * The run writes one of these into the update state file; the notice (`update-gate.ts`) says it only when it is one of
13061
+ * them, word for word, and otherwise the outcome's own sentence — so a record written by another release, or by hand,
13062
+ * never reaches a model as it is.
13063
+ *
13064
+ * Nothing here reaches the network or reads a file: every server and command, WhatsApp's too, carries it.
13065
+ */
13066
+ const PLAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
13067
+ /** A release as a notice names it: its version when it is a plain `MAJOR.MINOR.PATCH`, otherwise "A release". */
13068
+ function shownRelease(version) {
13069
+ return typeof version === "string" && PLAIN_VERSION.test(version) ? version : "A release";
13070
+ }
13071
+ /** Which pin a release did not verify against, in this package's own words — never the value it held instead. */
13072
+ const PIN_WORDS = Object.freeze({
13073
+ attestation: "npm has no SLSA provenance attestation for it that could be read",
13074
+ bundle: "its provenance bundle could not be read",
13075
+ issuer: "it was not signed through GitHub Actions' token issuer",
13076
+ identity: "it was not signed for this repository's release workflow at the tag of its version",
13077
+ runner: "it was not built on a GitHub-hosted runner",
13078
+ repository: "it was not built from this repository",
13079
+ ref: "it was not built from the tag of its version",
13080
+ subject: "what its provenance signs is not the tarball the registry serves for it",
13081
+ signature: "Sigstore did not verify its signature, certificate or transparency-log entry"
13082
+ });
13083
+ const PIN_FIELDS = Object.keys(PIN_WORDS);
13084
+ /** What went wrong, in fixed words. */
13085
+ const FAILED_WORDS = Object.freeze({
13086
+ install: "one of its packages could not be installed",
13087
+ tarball: "what npm installed is not the tarball whose provenance was verified",
13088
+ selfCheck: "one of its packages failed its self-check",
13089
+ switch: "a launcher could not be switched, so every launcher was put back",
13090
+ inUse: "an unfinished runtime of it is in the way"
13091
+ });
13092
+ const RESTART = "restart each MCP client that starts these servers (in Claude Code, /mcp and then Reconnect) to use it: a server already running keeps the code it started with";
13093
+ /** The sentences, by what happened. */
13094
+ const BACKGROUND_WORDS = Object.freeze({
13095
+ installed: (version) => `${shownRelease(version)} is installed; ${RESTART}.`,
13096
+ major: (version) => `${shownRelease(version)} is a new major release: it is never installed in the background. Update when it suits you — comms_update shows each step first.`,
13097
+ decision: (version) => `${shownRelease(version)} needs a decision from you — it, or a release before it, says so — so it was not installed in the background. comms_update shows each step first; its release notes say what it changes.`,
13098
+ downgrade: (version) => `The registry names ${shownRelease(version)} as the latest release, older than what runs here: nothing was changed in the background.`,
13099
+ waits: (version) => `${shownRelease(version)} waits for you: it was not installed in the background. comms_update shows each step first.`,
13100
+ unverified: (version, field) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow — ${PIN_WORDS[field]}. Nothing was changed.`,
13101
+ refused: (version) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow. Nothing was changed.`,
13102
+ failed: (version, what) => `${shownRelease(version)} was not installed in the background: ${FAILED_WORDS[what]}. Nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
13103
+ stopped: (version) => `${shownRelease(version)} was not installed in the background, and nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
13104
+ partlySwitched: (version) => `${shownRelease(version)} was not installed in the background: a launcher could not be switched, and not every launcher could be put back, so some here start ${shownRelease(version)} from their next start and some the release before. comms_audit_tail says which.`,
13105
+ heldUnreadable: "No update is installed in the background while update-held.json in the state directory cannot be read: it says which releases were rolled back. Delete it to hold none.",
13106
+ jobRemoved: "An organisation profile on this machine turned background updates off, so the background update job was removed. Updates are still checked daily, and a notice says when one is out."
13107
+ });
13108
+ /** Every sentence a run may leave for `outcome` and `version`, the outcome's own first. */
13109
+ function sentencesFor(outcome, version) {
13110
+ const words = BACKGROUND_WORDS;
13111
+ switch (outcome) {
13112
+ case "installed": return [words.installed(version ?? "")];
13113
+ case "skipped": return [
13114
+ words.waits(version),
13115
+ words.major(version ?? ""),
13116
+ words.decision(version ?? ""),
13117
+ words.downgrade(version ?? "")
13118
+ ];
13119
+ case "refused": return [words.refused(version), ...PIN_FIELDS.map((field) => words.unverified(version, field))];
13120
+ case "failed": return [
13121
+ words.stopped(version),
13122
+ ...Object.keys(FAILED_WORDS).map((what) => words.failed(version, what)),
13123
+ words.partlySwitched(version),
13124
+ words.heldUnreadable
13125
+ ];
13126
+ case "job-removed": return [words.jobRemoved];
13127
+ }
13128
+ }
13129
+ /**
13130
+ * What a notice says of the last background run: the run's own sentence when it is one of these, word for word, for
13131
+ * its outcome and release — and otherwise the outcome's own. Never the record's text as it is.
13132
+ */
13133
+ function backgroundNoticeLine(notice) {
13134
+ const sentences = sentencesFor(notice.outcome, notice.version);
13135
+ return sentences.includes(notice.text) ? notice.text : sentences[0];
13136
+ }
13137
+ /** The SHA-512 in a subresource-integrity string, as hex; null when it holds none. */
13138
+ function sha512Hex(integrity) {
13139
+ for (const part of integrity.trim().split(/\s+/)) {
13140
+ if (!part.startsWith("sha512-")) continue;
13141
+ const bytes = Buffer.from(part.slice(7).split("?")[0] ?? "", "base64");
13142
+ if (bytes.length === 64) return bytes.toString("hex");
13143
+ }
13144
+ return null;
13145
+ }
13146
+ /** The tarball integrity the registry gives for one version, or null when it gives none. */
13147
+ function releaseIntegrity(packument, version) {
13148
+ const integrity = packument.versions?.[version]?.dist?.integrity;
13149
+ return typeof integrity === "string" && integrity.trim() !== "" ? integrity : null;
13150
+ }
13151
+ /** What a release that verified was built by, in one phrase: the preview's and the dry run's words. */
13152
+ const BUILT_BY = "this repository's release workflow (.github/workflows/release.yml) at the tag of its version, on a GitHub-hosted runner, as its Sigstore provenance shows";
13153
+ /** The scope every package of this suite is published under, and so attested for. */
13154
+ const SUITE = "@agentcomms/";
13155
+ /**
13156
+ * Why the runtime at `runtime` is not what was verified, or null when it is (security review of 0.20.0, severity 5).
13157
+ *
13158
+ * Every package of this suite npm installed in it — `packageName` itself, and any of ours it depends on at any depth,
13159
+ * as Gmail depends on core — has to be, by the runtime's own lockfile, a tarball whose provenance verified: the one
13160
+ * whose SHA-512 is `sha512` for `packageName`, and for each other either one already verified (`known`, by
13161
+ * `package@version`, the SHA-512 it signs) or one `verify` verifies now, against the integrity npm recorded. npm checks
13162
+ * each tarball it downloads against the integrity the registry gives it then, and records that; a registry that gave
13163
+ * another one to the install than to the check, or a runtime made some other way, is caught here.
13164
+ *
13165
+ * The packages of others it holds are checked by npm alone, against the registry's integrity: no pin says who builds
13166
+ * them (SECURITY.md says so). No package's install script ran (`installManagedRuntime`).
13167
+ */
13168
+ async function runtimeIntegrityProblem(runtime, packageName, sha512, options = {}) {
13169
+ let lock;
13170
+ try {
13171
+ lock = JSON.parse(await readFile(join(runtime, "package-lock.json"), "utf8"));
13172
+ } catch {
13173
+ lock = null;
13174
+ }
13175
+ const own = `node_modules/${packageName}`;
13176
+ const integrity = lock?.packages?.[own]?.integrity;
13177
+ if (typeof integrity !== "string") return `what is in ${runtime} cannot be shown to be the tarball whose provenance was verified: its lockfile records no integrity for ${packageName}`;
13178
+ if (sha512Hex(integrity) !== sha512) return `what is in ${runtime} is not the tarball whose provenance was verified: its lockfile records another integrity for ${packageName}`;
13179
+ const known = options.known ?? /* @__PURE__ */ new Map();
13180
+ for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
13181
+ if (key === own) continue;
13182
+ const name = key.slice(key.lastIndexOf("node_modules/") + 13);
13183
+ if (!key.includes("node_modules/") || !name.startsWith(SUITE)) continue;
13184
+ const version = entry?.version;
13185
+ const recorded = entry?.integrity;
13186
+ if (!isPlainVersion(version) || typeof recorded !== "string" || sha512Hex(recorded) === null) return `${name}, which ${packageName} in ${runtime} depends on, cannot be shown to be a verified tarball: its lockfile records no plain release version or no SHA-512 integrity for it`;
13187
+ const hex = sha512Hex(recorded);
13188
+ if (known.get(`${name}@${version}`) === hex) continue;
13189
+ if (!options.verify) return `${name}@${version}, which ${packageName} in ${runtime} depends on, cannot be verified here`;
13190
+ const verdict = await options.verify({
13191
+ packageName: name,
13192
+ version,
13193
+ integrity: recorded
13194
+ });
13195
+ if (!verdict.ok) return `${name}@${version}, which ${packageName} in ${runtime} depends on, is not a tarball built by this repository's release workflow — ${PIN_WORDS[verdict.field]}`;
13196
+ known.set(`${name}@${version}`, hex);
13197
+ }
13198
+ return null;
13199
+ }
13200
+ /**
13201
+ * The refusal of a whole update or install for one release that did not verify: which, and which pin — never what it
13202
+ * held. `hint` replaces the general one where there is more to say: where it can be verified, for a process that
13203
+ * cannot.
13204
+ */
13205
+ function unverifiedRelease(packageName, version, why, pin, act = "update", hint) {
13206
+ const which = act === "update" ? "An update installs" : "An install fetches";
13207
+ const plain = isPlainVersion(version);
13208
+ return new CommsError("BAD_DATA", `nothing was installed or changed: ${plain ? `${packageName}@${version}` : `${packageName} at a version that is not a plain release`} could not be verified as built by this repository's release workflow — ${why}`, {
13209
+ hint: hint ?? `${which} only a release whose Sigstore provenance shows it was built by this repository's release workflow at the tag of its version, on a GitHub-hosted runner, and nothing skips that check. Nothing was prepared to approve. A release published minutes ago can be without its attestation for a while: try again later. If it still does not verify, do not install it some other way; report it as SECURITY.md says.`,
13210
+ details: {
13211
+ package: packageName,
13212
+ ...plain ? { version } : {},
13213
+ pin
13214
+ }
13215
+ });
13216
+ }
13217
+ /**
13218
+ * Verifies every release in `wanted` — its tarball's integrity as the registry gives it, and npm's attestation for it,
13219
+ * held to the pins the background run holds a release to (design 2026-10-16 U5 step 3). The first that does not verify
13220
+ * refuses the whole update or install (`unverifiedRelease`); what verified is returned by package, for the preview
13221
+ * and for the integrity check after install. Reads; changes nothing. With no registry or no verifier, the first
13222
+ * release is refused before anything is asked.
13223
+ */
13224
+ async function verifyReleaseSet(sources, wanted, act = "update", noVerifierHint) {
13225
+ const verified = /* @__PURE__ */ new Map();
13226
+ if (wanted.size === 0) return verified;
13227
+ const { registry, verify, why } = sources;
13228
+ for (const [packageName, version] of [...wanted].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
13229
+ if (!isPlainVersion(version)) throw unverifiedRelease(packageName, version, "it is not a plain release (MAJOR.MINOR.PATCH)", "version", act);
13230
+ if (registry === null || verify === null) throw unverifiedRelease(packageName, version, why, "verifier", act, noVerifierHint);
13231
+ let integrity;
13232
+ try {
13233
+ integrity = releaseIntegrity((await registry(packageName)).packument, version);
13234
+ } catch (error) {
13235
+ throw new CommsError("PROVIDER_UNAVAILABLE", `nothing was installed or changed: the registry's document of ${packageName} could not be read to verify ${version}: ${error instanceof Error ? error.message : String(error)}`, { hint: "Try again once the registry can be reached." });
13236
+ }
13237
+ if (integrity === null) throw unverifiedRelease(packageName, version, "the registry gives no tarball integrity for it", "subject", act);
13238
+ const verdict = await verify({
13239
+ packageName,
13240
+ version,
13241
+ integrity
13242
+ });
13243
+ if (!verdict.ok) throw unverifiedRelease(packageName, version, PIN_WORDS[verdict.field], verdict.field, act);
13244
+ verified.set(packageName, verdict.provenance);
13245
+ }
13246
+ return verified;
13247
+ }
13248
+ /**
11522
13249
  * Registering, pruning and listing every declared channel or service MCP server, from the core.
11523
13250
  *
11524
13251
  * `agentcomms mcp install` registers core or a declared service; `comms_server_install` registers any declared
@@ -11682,6 +13409,14 @@ function installOptions(request) {
11682
13409
  };
11683
13410
  }
11684
13411
  /**
13412
+ * The install receipt a fetched runtime keeps in its folder: which release, whether its provenance was verified, and
13413
+ * by whom it was installed — core, which verifies, or a channel's own command, which cannot (owner's decision,
13414
+ * 2026-10-17). Doctor and a person can read it; nothing else depends on it.
13415
+ */
13416
+ const INSTALL_RECEIPT = ".agentcomms-install.json";
13417
+ /** A sentence's first letter in capitals: core's command, said after a full stop. */
13418
+ const capitalised = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
13419
+ /**
11685
13420
  * Registering one declared server with one client, as a change.
11686
13421
  *
11687
13422
  * The effects are what a person is agreeing to, one sentence each, and everything that decides what the server may
@@ -11714,6 +13449,101 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11714
13449
  const launcher = request.launcher ?? "managed";
11715
13450
  const productNow = async () => own ?? await serverProduct(request.channel, request.launcher);
11716
13451
  let planned;
13452
+ const sources = () => ({
13453
+ registry: deps.registry ?? null,
13454
+ verify: deps.verifyRelease ?? null,
13455
+ why: deps.verifyRelease === void 0 ? "nothing here can verify provenance: run the install from core's own command or server" : "this install was handed a verifier and no registry"
13456
+ });
13457
+ /**
13458
+ * Core's own command for this same install, located — the one that verifies — said by `say`; `instead` where no
13459
+ * command can be named.
13460
+ */
13461
+ const coreWords = () => {
13462
+ const words = [
13463
+ "mcp",
13464
+ "install",
13465
+ "--server",
13466
+ request.channel,
13467
+ "--client",
13468
+ request.client
13469
+ ];
13470
+ if (request.name !== void 0) words.push("--name", request.name);
13471
+ const pin = pinOf(request);
13472
+ const option = pinOption(request.channel);
13473
+ if (pin !== void 0 && option !== void 0 && option !== "readOnly") words.push(`--${option}`, pin);
13474
+ if (request.readOnly) words.push("--read-only");
13475
+ if (request.force) words.push("--force");
13476
+ return words;
13477
+ };
13478
+ const inCore = (say, instead) => {
13479
+ try {
13480
+ return handoffSentence(requireHandoffs(core, deps.platform).core(coreWords()), say, { instead });
13481
+ } catch {
13482
+ return instead;
13483
+ }
13484
+ };
13485
+ /** Where a process that cannot verify — a channel's own, with no Sigstore — sends the person: core, which can. */
13486
+ const verifyInCore = () => inCore((command) => `Core verifies a release's provenance before it installs it: run ${command} in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.`, "Core verifies a release's provenance before it installs it: run core's `mcp install --server` in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.");
13487
+ /** Where the release a channel's own command installs unverified is checked: core's command, located. */
13488
+ const checkedInCore = () => inCore((command) => `core's ${command} checks it before it installs it`, "core's `mcp install --server` checks it before it installs it");
13489
+ /** The receipt a runtime this install fetched keeps beside it (`INSTALL_RECEIPT`). */
13490
+ const writeReceipt = (runtime, packageName, version, verified, by) => writeFileAtomic(join(runtime, INSTALL_RECEIPT), `${JSON.stringify({
13491
+ package: packageName,
13492
+ version,
13493
+ verified,
13494
+ by,
13495
+ at: (/* @__PURE__ */ new Date()).toISOString()
13496
+ }, null, 2)}\n`);
13497
+ const verifiedRuntime = async (done) => {
13498
+ const { dataDir } = core.paths;
13499
+ const { packageName, version } = done.product;
13500
+ const runtime = managedRuntimeDir(dataDir, packageName, version);
13501
+ const there = await reusableRuntime(dataDir, packageName, version);
13502
+ if (done.verified === void 0) {
13503
+ if (there !== null) return void 0;
13504
+ if (done.unverifiedOwn) {
13505
+ await installManagedRuntime(context, done.product, version).catch(async (error) => {
13506
+ throw await remedyLocated(error, core);
13507
+ });
13508
+ await writeReceipt(runtime, packageName, version, false, `${label}’s own command`);
13509
+ return {
13510
+ package: packageName,
13511
+ version,
13512
+ verified: false,
13513
+ note: `${packageName}@${version} was installed without its provenance verified: this command cannot check provenance, so it installed the release it is running itself. ${capitalised(checkedInCore())}.`
13514
+ };
13515
+ }
13516
+ throw new CommsError("CONFIG", `this registration changed between being planned and being applied: ${runtime} was on this machine when it was planned, and is not now, so it would be fetched from npm without being verified. So nothing was installed or registered`, { hint: "Run the same install again: it is planned afresh from what is there now, verifies the release it would fetch, and asks again." });
13517
+ }
13518
+ if (there === null) await installManagedRuntime(context, done.product, version).catch(async (error) => {
13519
+ throw await remedyLocated(error, core);
13520
+ });
13521
+ const { sha512 } = done.verified;
13522
+ const problem = await runtimeIntegrityProblem(runtime, packageName, sha512, {
13523
+ verify: sources().verify,
13524
+ known: /* @__PURE__ */ new Map([[`${packageName}@${version}`, sha512]])
13525
+ });
13526
+ if (problem === null) {
13527
+ await writeReceipt(runtime, packageName, version, true, "core");
13528
+ return {
13529
+ package: packageName,
13530
+ version,
13531
+ verified: true
13532
+ };
13533
+ }
13534
+ if (there === null) await rm(runtime, {
13535
+ recursive: true,
13536
+ force: true
13537
+ }).catch(() => void 0);
13538
+ const general = "An install registers only a runtime whose packages of this suite were all built by this repository's release workflow, as their Sigstore provenance shows. If it happens again, do not install it some other way; report it as SECURITY.md says.";
13539
+ throw new CommsError("BAD_DATA", `nothing was registered: ${problem}${there === null ? ", so it was removed" : ""}. The launcher was not pointed at it`, {
13540
+ hint: there === null ? general : `To fetch a verified one, remove ${runtime} — whatever starts it stops until it is installed again — and run this install again: it fetches ${packageName}@${version} from npm and verifies it before anything is registered. ${general}`,
13541
+ details: {
13542
+ package: packageName,
13543
+ version
13544
+ }
13545
+ });
13546
+ };
11717
13547
  return {
11718
13548
  plan: async (config) => {
11719
13549
  checkPin(config, request);
@@ -11723,9 +13553,21 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11723
13553
  throw await remedyLocated(error, core);
11724
13554
  });
11725
13555
  const { target, previous, effective, kept, command } = preflight;
13556
+ const managedApply = request.print !== true && launcher === "managed";
13557
+ const fetches = managedApply && await reusableRuntime(core.paths.dataDir, product.packageName, version) === null;
13558
+ const checksDisk = managedApply && !fetches && deps.verifyRelease !== void 0;
13559
+ const from = sources();
13560
+ const running = deps.running ?? (own === void 0 ? void 0 : {
13561
+ packageName: own.packageName,
13562
+ version: own.version
13563
+ });
13564
+ const unverifiedOwn = fetches && deps.verifyRelease === void 0 && running !== void 0 && running.packageName === product.packageName && running.version === version;
13565
+ const verified = fetches && !unverifiedOwn || checksDisk ? (await verifyReleaseSet(from, /* @__PURE__ */ new Map([[product.packageName, version]]), "install", from.registry === null || from.verify === null ? verifyInCore() : void 0)).get(product.packageName) : void 0;
11726
13566
  planned = {
11727
13567
  product,
11728
- install: plannedInstall(preflight)
13568
+ install: plannedInstall(preflight),
13569
+ verified,
13570
+ unverifiedOwn
11729
13571
  };
11730
13572
  const effects = [];
11731
13573
  if (target.writes) {
@@ -11742,7 +13584,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11742
13584
  if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
11743
13585
  else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
11744
13586
  }
11745
- if (request.print !== true && launcher === "managed" && await reusableRuntime(core.paths.dataDir, facts.packageName, version) === null) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
13587
+ if (fetches) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
13588
+ if (request.print !== true && launcher === "managed") effects.push(launcherSwitchEffect(core.paths.dataDir, facts.packageName, label, version));
11746
13589
  if (request.print !== true && launcher === "managed" && managedShimsSupported(context)) {
11747
13590
  const path = managedShimPath({
11748
13591
  env,
@@ -11751,6 +13594,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11751
13594
  }, facts.binary);
11752
13595
  effects.push(`writes or repoints the owned CLI shim at ${path}; a foreign file there is left unchanged`);
11753
13596
  }
13597
+ if (verified !== void 0 && fetches) effects.push(`${verified.package}@${verified.version} was verified before this preview: built by ${BUILT_BY}`);
13598
+ else if (unverifiedOwn) effects.push(`${product.packageName}@${version} will not be verified: this command cannot check provenance, so it installs the release it is running itself; core's \`${coreWords().join(" ")}\` checks it before it installs it`);
11754
13599
  return {
11755
13600
  before: config,
11756
13601
  after: config,
@@ -11760,18 +13605,30 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11760
13605
  },
11761
13606
  apply: async () => {
11762
13607
  if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
13608
+ const provenance = request.print !== true && launcher === "managed" ? await verifiedRuntime(planned) : void 0;
11763
13609
  const installed = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
11764
13610
  throw await remedyLocated(error, core);
11765
13611
  });
11766
13612
  const result = request.print === true ? installed : await withManagedShim(context, planned.product, installed);
11767
13613
  return {
11768
13614
  ...result,
13615
+ ...provenance?.note === void 0 ? {} : { warnings: [...result.warnings, provenance.note] },
13616
+ ...provenance === void 0 ? {} : { provenance },
11769
13617
  restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
11770
13618
  };
11771
13619
  }
11772
13620
  };
11773
13621
  }
11774
13622
  /**
13623
+ * What a managed install or update does to the product's stable launcher, as the preview says it (design 2026-10-16,
13624
+ * U6): it points it at the release's runtime, and every registration of the product that starts it — in any client —
13625
+ * runs that release from its next start.
13626
+ */
13627
+ function launcherSwitchEffect(dataDir, packageName, label, version) {
13628
+ const { launcher } = launcherPaths(dataDir, launcherName(packageName));
13629
+ return `points the stable launcher ${launcher} at ${managedRuntimeDir(dataDir, packageName, version)}: every ${label} registration on this machine that starts it runs ${version} from its next start`;
13630
+ }
13631
+ /**
11775
13632
  * A managed install's CLI shim (CUE-406), once the registration's outcome is known: the command the locator finds in
11776
13633
  * the runtime the entry starts, written where nothing is or over this installation's own unchanged shim, and never
11777
13634
  * over anything else. A shim that cannot be written leaves the registration as it went, and says how to repair both.
@@ -11779,8 +13636,6 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11779
13636
  */
11780
13637
  async function withManagedShim(context, product, installed) {
11781
13638
  if (installed.launcher !== "managed") return installed;
11782
- const cliEntry = installed.entry.args[0];
11783
- if (cliEntry === void 0) throw new CommsError("UNEXPECTED", "the managed entry has no CLI path");
11784
13639
  const shimContext = {
11785
13640
  env: context.env,
11786
13641
  dataDir: context.core.paths.dataDir,
@@ -11794,6 +13649,11 @@ async function withManagedShim(context, product, installed) {
11794
13649
  let shim;
11795
13650
  let shimFailure;
11796
13651
  try {
13652
+ const name = launcherName(product.packageName);
13653
+ const { launcher } = launcherPaths(context.core.paths.dataDir, name);
13654
+ const pointer = await readPointer(context.core.paths.dataDir, name);
13655
+ if (pointer === void 0) throw new CommsError("CONFIG", `the stable launcher ${launcher} has no pointer, so it starts nothing yet`);
13656
+ const cliEntry = launcherCli(pointer.current, name);
11797
13657
  const located = locateCliCommand({
11798
13658
  caller: {
11799
13659
  url: pathToFileURL(cliEntry).href,
@@ -11818,8 +13678,8 @@ async function withManagedShim(context, product, installed) {
11818
13678
  if (!located.ok) throw new CommsError("CONFIG", `the managed CLI command could not be located: ${located.message}`);
11819
13679
  shim = await (context.writeShim ?? writeManagedShim)(shimContext, {
11820
13680
  binary: product.binary,
11821
- command: located.command.words,
11822
- entry: located.command.entry
13681
+ command: [launcher],
13682
+ entry: launcher
11823
13683
  });
11824
13684
  } catch (error) {
11825
13685
  shimFailure = error instanceof Error ? error.message : String(error);
@@ -11855,311 +13715,181 @@ async function remedyLocated(error, core) {
11855
13715
  return new CommsError(error.code, error.message, {
11856
13716
  hint: handoffSentence(handoff, remedy.say),
11857
13717
  ...error.details === void 0 ? {} : { details: error.details },
11858
- ...error.cause === void 0 ? {} : { cause: error.cause }
11859
- });
11860
- }
11861
- /**
11862
- * Removing a declared server's unused managed runtimes, as a change.
11863
- *
11864
- * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
11865
- * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
11866
- * then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
11867
- *
11868
- * `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
11869
- * release of the command that was run; checked to be that channel's, as for an install.
11870
- */
11871
- function serverPruneChange(core, env, request, own) {
11872
- if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
11873
- const context = {
11874
- env,
11875
- core
11876
- };
11877
- const { packageName, version } = ownProduct(request.channel, own) ?? {
11878
- packageName: serverFacts(request.channel).packageName,
11879
- version: "0.19.0"
11880
- };
11881
- const product = {
11882
- packageName,
11883
- version
11884
- };
11885
- const label = serverLabel(request.channel);
11886
- const base = {
11887
- includePrinted: request.includePrinted === true,
11888
- ...request.processes ? { processes: request.processes } : {}
11889
- };
11890
- let approved = [];
11891
- return {
11892
- plan: async (config) => {
11893
- if (request.dryRun) return {
11894
- before: config,
11895
- after: config,
11896
- summary: `Show which ${label} runtimes are unused`
11897
- };
11898
- const preview = await pruneManagedRuntimes(context, product, {
11899
- ...base,
11900
- dryRun: true
11901
- });
11902
- approved = preview.removed.map((item) => item.path);
11903
- return {
11904
- before: config,
11905
- after: config,
11906
- effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
11907
- summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
11908
- };
11909
- },
11910
- apply: () => pruneManagedRuntimes(context, product, {
11911
- ...base,
11912
- dryRun: request.dryRun === true,
11913
- ...request.dryRun ? {} : { only: approved }
11914
- })
11915
- };
11916
- }
11917
- /**
11918
- * The daily update check, as every server and command reads it (design 2026-09-28): one file per machine,
11919
- * `update-check.json` in the state directory, saying when the registry was last asked, the latest release it named,
11920
- * whether this machine's registrations were behind it, and any "not now" with when it runs out.
11921
- *
11922
- * This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
11923
- * Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
11924
- * imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
11925
- * that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
11926
- */
11927
- /** The file, in the state directory: one per machine, shared by every server and command on it. */
11928
- const UPDATE_CHECK_FILE = "update-check.json";
11929
- /**
11930
- * The switch that turns the whole thing off for one process — no check, no stop — whatever the setting says. Every
11931
- * test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
11932
- * real registry.
11933
- */
11934
- const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
11935
- /**
11936
- * Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
11937
- * extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
11938
- * and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its stop always says
11939
- * "update", whatever the channel's registrations say: restarting would start the release the plugin pins.
11940
- */
11941
- const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
11942
- /** What the stop says first, word for word: the owner's words. */
11943
- const UPDATE_FIRST = "Hang on a minute, there's an update. Let's update first.";
11944
- const EMPTY_UPDATE_CHECK = Object.freeze({
11945
- lastChecked: null,
11946
- latest: null,
11947
- behind: null,
11948
- current: null,
11949
- lastError: null,
11950
- snoozedUntil: null,
11951
- checking: null
11952
- });
11953
- function updateCheckPath(stateDir) {
11954
- return join(stateDir, UPDATE_CHECK_FILE);
11955
- }
11956
- const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
11957
- const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
11958
- function readCurrent(value) {
11959
- if (typeof value !== "object" || value === null) return null;
11960
- const { registered, global } = value;
11961
- return isWords(registered) && isWords(global) ? {
11962
- registered: [...registered],
11963
- global: [...global]
11964
- } : null;
11965
- }
11966
- /**
11967
- * The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
11968
- * as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
11969
- */
11970
- async function readUpdateCheck(stateDir) {
11971
- let parsed;
11972
- try {
11973
- parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
11974
- } catch {
11975
- return { ...EMPTY_UPDATE_CHECK };
11976
- }
11977
- const held = typeof parsed === "object" && parsed !== null ? parsed : {};
11978
- return {
11979
- lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
11980
- latest: isVersion(held.latest) ? held.latest : null,
11981
- behind: typeof held.behind === "boolean" ? held.behind : null,
11982
- current: readCurrent(held.current),
11983
- lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
11984
- snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
11985
- checking: isTime(held.checking) ? held.checking : null
11986
- };
11987
- }
11988
- /**
11989
- * Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
11990
- * and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
11991
- * alongside the record written; `change` returning `null` writes nothing.
11992
- */
11993
- async function changeUpdateCheck(stateDir, change) {
11994
- return withFileLock(join(stateDir, ".update-check.lock"), async () => {
11995
- const current = await readUpdateCheck(stateDir);
11996
- const next = change(current);
11997
- if (next === null) return {
11998
- record: current,
11999
- result: void 0,
12000
- written: false
12001
- };
12002
- await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
12003
- return {
12004
- record: next.record,
12005
- result: next.result,
12006
- written: true
12007
- };
12008
- }, { timeoutMs: 2e3 });
12009
- }
12010
- /**
12011
- * Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
12012
- * minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
12013
- * moments, and a check just claimed by one must not look due to the other.
12014
- */
12015
- function updateCheckDue(record, now) {
12016
- if (record.lastChecked === null) return true;
12017
- const since = now.getTime() - Date.parse(record.lastChecked);
12018
- return !(since >= -CLOCK_SKEW_MS && since < 864e5);
12019
- }
12020
- const CLOCK_SKEW_MS = 3e5;
12021
- /** What turned the check off for this process, from its environment: `CI`, or the switch. Null when neither did. */
12022
- function updateCheckSwitchedOff(env) {
12023
- if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
12024
- const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
12025
- if (value !== void 0 && [
12026
- "off",
12027
- "0",
12028
- "false",
12029
- "no"
12030
- ].includes(value)) return UPDATE_CHECK_ENV;
12031
- return null;
12032
- }
12033
- /** Whether a check may run at all, and if not, what turned it off: the environment first, then the machine's setting. */
12034
- async function updateCheckEnabled(core, env) {
12035
- const switched = updateCheckSwitchedOff(env);
12036
- if (switched !== null) return {
12037
- on: false,
12038
- by: switched
12039
- };
12040
- return await machineSetting(core) === "off" ? {
12041
- on: false,
12042
- by: "setting"
12043
- } : { on: true };
12044
- }
12045
- /**
12046
- * The machine's setting, from `config.json`. A configuration that cannot be read counts as `on`, the default: what is
12047
- * wrong with it is for the doctor and the tools to say, and it must not quietly turn this off.
12048
- */
12049
- async function machineSetting(core) {
12050
- try {
12051
- return updateCheckSetting(await core.config.load());
12052
- } catch {
12053
- return "on";
12054
- }
12055
- }
12056
- /** The start of the next local day: when a "not now" runs out. */
12057
- function nextLocalMidnight(now) {
12058
- return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
12059
- }
12060
- /** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
12061
- function localStamp(at) {
12062
- const two = (value) => String(value).padStart(2, "0");
12063
- return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
12064
- }
12065
- /** Whether a person's "not now" still holds. */
12066
- function updateSnoozed(record, now) {
12067
- return record.snoozedUntil !== null && now.getTime() < Date.parse(record.snoozedUntil);
12068
- }
12069
- /**
12070
- * The latest release as the check counts it: a version, and not a prerelease — 0.8.0-rc.1 is never an update anybody
12071
- * is stopped for. Null when the file names none.
12072
- */
12073
- function countedLatest(record) {
12074
- return record.latest !== null && isVersion(record.latest) && !isPrerelease(record.latest) ? record.latest : null;
13718
+ ...error.cause === void 0 ? {} : { cause: error.cause }
13719
+ });
12075
13720
  }
12076
13721
  /**
12077
- * What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
13722
+ * Removing a declared server's unused managed runtimes, as a change.
12078
13723
  *
12079
- * `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
12080
- * registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
12081
- * registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
12082
- * configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
12083
- * code, and a reply that said to restart would stop every call with advice that cannot work.
13724
+ * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
13725
+ * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
13726
+ * then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
13727
+ *
13728
+ * A removal never runs beside an update (`update-lock.ts`): a background run installs a release and checks it before
13729
+ * any pointer names it, and a prune beside it would find that runtime unused. So it is refused at once while an update
13730
+ * holds the lock — before an approval is prepared — and the removal itself holds the lock, refused in turn when an
13731
+ * update has started since. A refusal stops nothing else.
13732
+ *
13733
+ * `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
13734
+ * release of the command that was run; checked to be that channel's, as for an install.
12084
13735
  */
12085
- function updateVerdict(record, running, where) {
12086
- const latest = countedLatest(record);
12087
- if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
13736
+ function serverPruneChange(core, env, request, own) {
13737
+ if (request.channel === void 0 && own === void 0) return everyServerPruneChange(core, env, request);
13738
+ if (request.channel === void 0 || !isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
13739
+ const context = {
13740
+ env,
13741
+ core
13742
+ };
13743
+ const { packageName, version } = ownProduct(request.channel, own) ?? {
13744
+ packageName: serverFacts(request.channel).packageName,
13745
+ version: "0.21.0"
13746
+ };
13747
+ const product = {
13748
+ packageName,
13749
+ version
13750
+ };
13751
+ const label = serverLabel(request.channel);
13752
+ const base = {
13753
+ includePrinted: request.includePrinted === true,
13754
+ ...request.processes ? { processes: request.processes } : {}
13755
+ };
13756
+ let approved = [];
12088
13757
  return {
12089
- kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
12090
- running,
12091
- latest
13758
+ plan: async (config) => {
13759
+ if (request.dryRun) return {
13760
+ before: config,
13761
+ after: config,
13762
+ summary: `Show which ${label} runtimes are unused`
13763
+ };
13764
+ await refuseWhileUpdating(core.paths.stateDir, "prune");
13765
+ const preview = await pruneManagedRuntimes(context, product, {
13766
+ ...base,
13767
+ dryRun: true
13768
+ });
13769
+ approved = preview.removed.map((item) => item.path);
13770
+ return {
13771
+ before: config,
13772
+ after: config,
13773
+ effects: pruneEffects(label, preview),
13774
+ summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
13775
+ };
13776
+ },
13777
+ apply: () => request.dryRun ? pruneManagedRuntimes(context, product, {
13778
+ ...base,
13779
+ dryRun: true
13780
+ }) : withPersonUpdateLock(core.paths.stateDir, () => pruneManagedRuntimes(context, product, {
13781
+ ...base,
13782
+ dryRun: false,
13783
+ only: approved
13784
+ }), "prune")
12092
13785
  };
12093
13786
  }
13787
+ /** What a removal of runtimes does, in the preview's words: each runtime, then each owned CLI shim. */
13788
+ function pruneEffects(label, preview) {
13789
+ return [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)];
13790
+ }
12094
13791
  /**
12095
- * The update that stops this process now, from the file as it is — or null: switched off, snoozed, or nothing newer.
12096
- * Reads two files and asks nobody.
13792
+ * Every managed server's unused runtimes, as one change (0.21): what an update leaves behind is every server's, and
13793
+ * `--server` for each was a step a person had to know to take. The servers are the channel registry's (`SERVERS`),
13794
+ * channels and declared services alike, never a list written here; each is pruned exactly as `--server` prunes it —
13795
+ * this release's runtime, one any client config or printed entry names, one a running process uses, and the runtime
13796
+ * its stable launcher starts and the one it keeps for a rollback all stay, each said why.
12097
13797
  *
12098
- * "Restart" is decided per channel, from its registrations, and a server a plugin or an extension started is not one
12099
- * of them (`UPDATE_STARTED_BY_ENV`): beside a registration the update moved, it was told to restart every day, and
12100
- * restarting started the release the plugin pins. It is told to update.
13798
+ * A dry run is free. Otherwise the plan is every server's dry run, shown grouped by server, and the approval is bound to
13799
+ * exactly that list; the removal is then restricted to it, so nothing that became unused after it was shown goes. A
13800
+ * server whose prune was stopped — a pointer or a config that cannot be read — is named in the preview, beside what the
13801
+ * others remove, and in the result's `refusedServers`. The running processes are listed again for each server, just
13802
+ * before it is decided, as the prune of one server lists them: a listing taken at the start of the pass would not see
13803
+ * a process started from a runtime while the servers before it were pruned. And, as for one server, a removal never
13804
+ * runs beside an update.
12101
13805
  */
12102
- async function pendingUpdate(options) {
12103
- if (!(await updateCheckEnabled(options.core, options.env)).on) return null;
12104
- const record = await readUpdateCheck(options.core.paths.stateDir);
12105
- if (updateSnoozed(record, (options.now ?? (() => /* @__PURE__ */ new Date()))())) return null;
12106
- const verdict = updateVerdict(record, options.running, options);
12107
- const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
12108
- if (verdict?.kind === "restart" && options.surface === "server" && startedBy) return {
12109
- ...verdict,
12110
- kind: "update"
13806
+ function everyServerPruneChange(core, env, request) {
13807
+ const context = {
13808
+ env,
13809
+ core
12111
13810
  };
12112
- return verdict;
12113
- }
12114
- function updateCommands(core, platform) {
12115
- const handoffs = requireHandoffs(core, platform);
12116
- return {
12117
- update: handoffs.core(["update"]),
12118
- later: handoffs.core(["update", "--later"])
13811
+ const servers = SERVERS.map((server) => ({
13812
+ server,
13813
+ label: serverLabel(server),
13814
+ product: {
13815
+ packageName: serverFacts(server).packageName,
13816
+ version: VERSION
13817
+ }
13818
+ }));
13819
+ const pruneEach = async (options) => {
13820
+ const parts = [];
13821
+ for (const { server, label, product } of servers) {
13822
+ const result = await pruneManagedRuntimes(context, product, {
13823
+ includePrinted: request.includePrinted === true,
13824
+ processes: request.processes ?? runningCommandLines,
13825
+ ...options
13826
+ });
13827
+ parts.push({
13828
+ server,
13829
+ label,
13830
+ ...result
13831
+ });
13832
+ }
13833
+ return parts;
12119
13834
  };
12120
- }
12121
- /** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
12122
- function updateCommandSaid(commands, which, say) {
12123
- return handoffSentence(commands[which], say);
12124
- }
12125
- function updateWaysOf(commands) {
13835
+ /** Each server whose prune was stopped, and why. */
13836
+ const refusedOf = (parts) => parts.flatMap(({ server, label, refused }) => refused === void 0 ? [] : [{
13837
+ server,
13838
+ label,
13839
+ refused
13840
+ }]);
13841
+ let approved = [];
12126
13842
  return {
12127
- update: {
12128
- tool: "comms_update",
12129
- command: commands.update
13843
+ plan: async (config) => {
13844
+ if (request.dryRun) return {
13845
+ before: config,
13846
+ after: config,
13847
+ summary: "Show which runtimes of every managed server are unused"
13848
+ };
13849
+ await refuseWhileUpdating(core.paths.stateDir, "prune");
13850
+ const previews = await pruneEach({ dryRun: true });
13851
+ approved = previews.flatMap((part) => part.removed.map((item) => item.path));
13852
+ const counts = previews.filter((part) => part.removed.length > 0).map((part) => `${part.removed.length} ${part.label}`);
13853
+ const removals = previews.flatMap((part) => pruneEffects(part.label, part));
13854
+ const unchecked = refusedOf(previews).map(({ label, refused }) => `does not check the ${label} runtimes, and removes none of them: ${refused}`);
13855
+ return {
13856
+ before: config,
13857
+ after: config,
13858
+ effects: removals.length > 0 ? [...removals, ...unchecked] : [],
13859
+ summary: `Remove ${approved.length} unused runtime${approved.length === 1 ? "" : "s"}${counts.length > 0 ? `: ${counts.join(", ")}` : ""}`
13860
+ };
12130
13861
  },
12131
- later: {
12132
- tool: "comms_update",
12133
- arguments: { later: true },
12134
- command: commands.later
13862
+ apply: async () => {
13863
+ const dryRun = request.dryRun === true;
13864
+ const parts = dryRun ? await pruneEach({ dryRun }) : await withPersonUpdateLock(core.paths.stateDir, () => pruneEach({
13865
+ dryRun,
13866
+ only: approved
13867
+ }), "prune");
13868
+ const looked = parts.filter((part) => part.removed.length + part.kept.length > 0);
13869
+ const refused = looked.length > 0 && looked.every((part) => part.refused !== void 0);
13870
+ const refusedServers = refusedOf(parts);
13871
+ return {
13872
+ runtimeDir: join(core.paths.dataDir, "runtime"),
13873
+ dryRun,
13874
+ removed: parts.flatMap((part) => part.removed.map((item) => ({
13875
+ ...item,
13876
+ server: part.server
13877
+ }))),
13878
+ kept: parts.flatMap((part) => part.kept.map((item) => ({
13879
+ ...item,
13880
+ server: part.server
13881
+ }))),
13882
+ shims: {
13883
+ removed: parts.flatMap((part) => part.shims.removed),
13884
+ kept: parts.flatMap((part) => part.shims.kept)
13885
+ },
13886
+ ...refused ? { refused: [...new Set(looked.map((part) => part.refused))].join("; ") } : {},
13887
+ servers: parts,
13888
+ ...refusedServers.length > 0 ? { refusedServers } : {}
13889
+ };
12135
13890
  }
12136
13891
  };
12137
13892
  }
12138
- /** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
12139
- function labelOf(channel) {
12140
- return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
12141
- }
12142
- /**
12143
- * What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
12144
- * two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
12145
- */
12146
- function updateStopMessage(pending, where, commands) {
12147
- const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
12148
- const didNotRun = `Nothing was done: ${where.tool} did not run.`;
12149
- const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. ${updateCommandSaid(commands, "later", (command) => `At a terminal: ${command}.`)}`;
12150
- if (pending.kind === "restart") return [
12151
- "Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
12152
- `This is ${server}, ${pending.running}; ${pending.latest} is installed on this machine — every registration of this server names it — and a client starts it only once it is restarted. ${didNotRun}`,
12153
- `Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
12154
- ].join("\n");
12155
- return [
12156
- UPDATE_FIRST,
12157
- `This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
12158
- "Ask the person which they want:",
12159
- `- Update now: call comms_update on the core server. It shows every step and asks before it changes anything; restart the client after. ${updateCommandSaid(commands, "update", (command) => `At a terminal: ${command}.`)} A server comms_update does not find registered here — a plugin's, an extension's — is updated where it was installed.`,
12160
- `- ${later}`
12161
- ].join("\n");
12162
- }
12163
13893
  /**
12164
13894
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
12165
13895
  *
@@ -12174,6 +13904,11 @@ function updateStopMessage(pending, where, commands) {
12174
13904
  * The check reads and asks nobody. The update is a change whose effects are every step, one sentence each, so the
12175
13905
  * approval is bound to exactly that list — the versions in it included: a release published between the question and
12176
13906
  * the yes is a different change, and the claim is refused. Nothing behind, nothing is prepared.
13907
+ *
13908
+ * A registration that starts the stable launcher (design 2026-10-16, U6) is not registered again: the update installs
13909
+ * the latest runtime and switches the launcher's pointer, and no client's configuration is touched. A managed
13910
+ * registration from before the launcher names a versioned runtime; the update registers it again on the launcher —
13911
+ * the one update that edits it — even when it is already at the latest release.
12177
13912
  */
12178
13913
  /**
12179
13914
  * The packages this suite publishes, whose latest releases an update reads: every declared server owner's, from the
@@ -12185,149 +13920,253 @@ const SERVER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server
12185
13920
  Object.freeze([.../* @__PURE__ */ new Set([...OWNER_PACKAGES, ...SERVER_PACKAGES])].sort());
12186
13921
  serverFacts("core").packageName;
12187
13922
  /**
12188
- * "Not now": no server or command on this machine stops for the update until the next local midnight.
12189
- *
12190
- * `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
12191
- * for a newer release is a different change, and refused.
12192
- */
12193
- function updateLaterChange(core, options = {}) {
12194
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
12195
- let planned = null;
12196
- return {
12197
- plan: async (config) => {
12198
- const record = await readUpdateCheck(core.paths.stateDir);
12199
- const until = nextLocalMidnight(now());
12200
- const latest = countedLatest(record);
12201
- const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
12202
- planned = {
12203
- until,
12204
- latest,
12205
- changed
12206
- };
12207
- const what = latest === null ? "the daily update check" : `the update to ${latest}`;
12208
- return {
12209
- before: config,
12210
- after: config,
12211
- 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`] : [],
12212
- summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
12213
- };
12214
- },
12215
- apply: async () => {
12216
- if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
12217
- const { until, latest, changed } = planned;
12218
- if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
12219
- ...record,
12220
- snoozedUntil: until.toISOString()
12221
- } }));
12222
- return {
12223
- snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
12224
- latest,
12225
- changed
12226
- };
12227
- }
12228
- };
12229
- }
12230
- Object.freeze({
12231
- kind: "send",
12232
- lookup: true
12233
- });
12234
- Object.freeze({ kind: "change" });
12235
- Object.freeze({
12236
- kind: "download",
12237
- argument: "choiceId"
12238
- });
12239
- /**
12240
- * Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
12241
- * every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
12242
- * call takes where the surface says which. Only then does the call go past the stop.
12243
- *
12244
- * The key alone is not a claim. An empty `approvalId`, or an id nobody prepared, claims nothing, and let past it
12245
- * would run the tool's first-call path with an update out — a preview, or a tightening applied at once. Each tool
12246
- * refuses an approval it does not know anyway; the stop must not be what an unknown one walks around. The store
12247
- * refuses anything that is not an approval id before it looks, so that is not checked twice.
13923
+ * The update notice, where every server and every command meets it (design 2026-10-16, U1). It replaces the stop of
13924
+ * the daily update check (design 2026-09-28): nothing is refused for being behind any more. A server's first tool
13925
+ * result of its process carries one line beside the result; a command prints the same line once on stderr, at most
13926
+ * once a day for the machine, and runs.
12248
13927
  *
12249
- * Nor is every id the store holds. Records stay in it after they are used, revoked or expired, and one of those let
12250
- * any call past the stop for as long as it was kept: an old "not now" the person turned down walked `agentcomms
12251
- * channels` straight through. Such a record claims nothing any more — there is nothing left of it for the person to
12252
- * lose by waiting — except to a look-up (`lookup`), which only reads it.
12253
- *
12254
- * What is not checked here is that the approval was prepared for this very call. The store binds a change to its
12255
- * digest, and a send to its draft, which only the tool can compute; it does, when it claims the approval, and refuses
12256
- * any other. A change that needs no approval claims none, and refuses one it is handed (`gatedChange`), so an approval
12257
- * prepared for another change gets a call past the stop only to that refusal.
13928
+ * Reader only, like `update-state.ts`: what asks the registry is handed in — `refresh` to a server's notice, `check` to
13929
+ * a command's — by every package but WhatsApp, which has no network code and hands in nothing.
12258
13930
  */
12259
- async function claimsApproval(core, id, claim = {}) {
12260
- if (typeof id !== "string") return false;
12261
- const seen = await core.approvals.inspect(id, claim.kind === void 0 ? {} : { kind: claim.kind }).catch(() => null);
12262
- if (seen === null) return false;
12263
- const { stored, outcome } = seen;
12264
- if (claim.lookup === true) return stored.form === "v2" || stored.form === "legacy";
12265
- return stored.form === "v2" && (outcome.state === "pending" || outcome.claimable);
12266
- }
13931
+ /** The product, as a notice names it: never a command's name, which a sentence would read as one to run (CUE-403). */
13932
+ const PRODUCT = "agent-communications";
12267
13933
  /**
12268
- * A server's gate. Before a tool runs: if a newer release is out, this machine has not put it off today, the check is
12269
- * on — the setting, `CI` and `AGENT_COMMS_UPDATE_CHECK` — the tool is not exempt, and the call claims no approval the
12270
- * person already gave, the tool does not run and the call is answered with the stop.
13934
+ * A server's notice. After a tool answers: if a newer release is out, the machine's level says so (`decideNotice`),
13935
+ * and the tool is not exempt, the result carries one line, and no later result of this process carries it again.
13936
+ * Nothing is ever refused, and nothing waits for the check: the file is read as it is.
12271
13937
  *
12272
- * The file is read as it is: the check itself never delays a call. A call carrying the id of an approval this machine
12273
- * holds goes ahead — the person said yes to exactly it, perhaps moments before the check landed — and the stop applies
12274
- * from the next new request (§2, as agreed).
13938
+ * At most once per process, on the first result there is something to say on: a server started on a day the check is
13939
+ * due learns of the release a moment after its first call, and says so on the next.
12275
13940
  */
12276
- function updateToolGate(options) {
13941
+ function updateToolNotice(options) {
12277
13942
  const exempt = new Set(options.exempt);
12278
13943
  let running = null;
12279
- return async (tool, args) => {
12280
- if (updateCheckSwitchedOff(options.env) !== null) return null;
13944
+ let given = false;
13945
+ let turn = Promise.resolve();
13946
+ return (tool) => {
13947
+ if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
12281
13948
  if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
12282
13949
  running = null;
12283
13950
  });
12284
- if (exempt.has(tool)) return null;
12285
- const pending = await pendingUpdate({
12286
- ...options,
12287
- surface: "server"
13951
+ if (given || exempt.has(tool)) return Promise.resolve(null);
13952
+ const decided = turn.then(async () => {
13953
+ if (given) return null;
13954
+ const decision = await decideNotice(options, {
13955
+ channel: options.channel,
13956
+ surface: "server"
13957
+ });
13958
+ if (decision === null) return null;
13959
+ given = true;
13960
+ return serverDecisionLine(decision, options.env);
12288
13961
  });
12289
- if (pending === null) return null;
12290
- const claim = options.approvals?.[tool];
12291
- if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
12292
- return stoppedCall(pending, {
12293
- server: options.server,
12294
- channel: options.channel,
12295
- tool
12296
- }, updateCommands(options.core));
13962
+ turn = decided.catch(() => null);
13963
+ return decided.catch(() => null);
12297
13964
  };
12298
13965
  }
13966
+ /** How long a command's notice is good for, by level: a day, or 14 days on `every-14-days`. */
13967
+ function spacingOf(updates, surface) {
13968
+ if (updates.level === "every-14-days") return SPACED_INTERVAL_MS;
13969
+ return surface === "command" ? COMMAND_NOTICE_INTERVAL_MS : null;
13970
+ }
13971
+ const majorOf = (version) => Number(version.split(".")[0]);
12299
13972
  /**
12300
- * The answer to a stopped call. Its text opens with the owner's sentence, word for word, and says the rest in prose:
12301
- * a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
12302
- * `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
13973
+ * What the record means for this process's notice now — or null, with nothing written — decided and recorded under the
13974
+ * file's lock, so two processes deciding together say it once where the level says once (design 2026-10-16, U2, U3,
13975
+ * U7):
13976
+ *
13977
+ * - switched off for the process — `CI`, `AGENT_COMMS_UPDATE_CHECK=off`, `AGENT_COMMS_UPDATES=never` — nothing;
13978
+ * - a security fix (the latest release's manifest says so) is said on `every-14-days`, on `never`, and on `auto` while
13979
+ * its background job is not set up, whatever the pause or the 14-day spacing: the job installs one within a day;
13980
+ * - otherwise nothing on `never`, nothing during a pause, and on `auto` with its job set up only what needs the person:
13981
+ * a restart, a major release, a release needing a decision;
13982
+ * - `every-14-days` says it at most once in 14 days for the machine, and a command at most once a day.
13983
+ *
13984
+ * Beside it, by the same rules of level and pause: what the last background run left (U5 step 7), said by the first
13985
+ * process it applies to and then cleared from the file — "installed" applies only to a process behind what was
13986
+ * installed — and the registrations still naming a versioned runtime (U6), spaced as the notice is.
12303
13987
  */
12304
- function stoppedCall(pending, where, commands) {
12305
- const message = updateStopMessage(pending, where, commands);
12306
- const ways = updateWaysOf(commands);
12307
- const leaving = (value) => JSON.parse(JSON.stringify(value));
12308
- return {
12309
- isError: true,
12310
- content: [{
12311
- type: "text",
12312
- text: message
12313
- }],
12314
- structuredContent: leaving({ error: {
12315
- code: "UPDATE_REQUIRED",
12316
- message,
12317
- hint: pending.kind === "restart" ? "Restart the MCP client, or put the stop off until midnight with comms_update `later: true` once the person agrees." : "Update with comms_update (the person approves its preview), or put it off until midnight with comms_update `later: true` once the person agrees.",
12318
- details: {
12319
- tool: where.tool,
12320
- server: where.server,
12321
- running: pending.running,
12322
- latest: pending.latest,
12323
- installed: pending.kind === "restart",
12324
- update: ways.update,
12325
- later: ways.later
13988
+ async function decideNotice(options, where) {
13989
+ if (updateCheckSwitchedOff(options.env) !== null) return null;
13990
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
13991
+ const updates = await effectiveUpdates(options.core, options.env, now());
13992
+ const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
13993
+ const offerJob = updates.level === "auto" && !updates.background && await jobCanBeSetUp(options.core, updates);
13994
+ const spacing = spacingOf(updates, where.surface);
13995
+ const reach = updates.background ? await jobReach(options.core, options.env, where.channel) : null;
13996
+ /** What the level and the spacing let this process say now, or null: decided once as read, then under the lock. */
13997
+ const due = (record, at) => {
13998
+ let notice = noticeOf(record, options.running, where, updates, at, reach);
13999
+ const last = where.surface === "command" && updates.level !== "every-14-days" ? record.commandNoticeAt : record.noticeAt;
14000
+ const spaced = spacing !== null && within(last, at, spacing);
14001
+ if (notice !== null) {
14002
+ if (!notice.security) {
14003
+ if (spaced) notice = null;
14004
+ } else if (where.surface === "command" && within(record.commandNoticeAt, at, 864e5)) notice = null;
14005
+ }
14006
+ const quiet = updates.level === "never" || pauseEnd(record, at) !== null;
14007
+ const background = quiet ? null : backgroundFor(record.backgroundNotice, options.running);
14008
+ const moves = quiet || spaced ? 0 : record.moves ?? 0;
14009
+ const jobSilent = quiet || spaced ? null : updates.jobSilent?.since ?? null;
14010
+ if (notice === null && background === null && moves === 0 && jobSilent === null) return null;
14011
+ return {
14012
+ notice,
14013
+ background,
14014
+ moves,
14015
+ jobSilent,
14016
+ offerJob: offerJob && (notice !== null || jobSilent !== null)
14017
+ };
14018
+ };
14019
+ if (due(await readUpdateCheck(options.core.paths.stateDir), now()) === null) return null;
14020
+ return (await changeUpdateCheck(options.core.paths.stateDir, (record) => {
14021
+ const at = now();
14022
+ const decision = due(record, at);
14023
+ if (decision === null) return null;
14024
+ const stamp = at.toISOString();
14025
+ const spaces = decision.notice !== null || decision.moves > 0 || (decision.jobSilent ?? null) !== null;
14026
+ const written = {
14027
+ ...record,
14028
+ ...spaces ? { noticeAt: stamp } : {},
14029
+ ...spaces && where.surface === "command" ? { commandNoticeAt: stamp } : {},
14030
+ ...decision.background !== null ? { backgroundNotice: null } : {}
14031
+ };
14032
+ const { notice } = decision;
14033
+ const kind = notice?.kind === "restart" && where.surface === "server" && startedBy ? "update" : notice?.kind;
14034
+ return {
14035
+ record: written,
14036
+ result: {
14037
+ ...decision,
14038
+ notice: notice === null || kind === void 0 ? null : {
14039
+ ...notice,
14040
+ kind
14041
+ }
12326
14042
  }
12327
- } })
14043
+ };
14044
+ })).result ?? null;
14045
+ }
14046
+ /**
14047
+ * What a background run left, when it is this process's to say: an install only to a process running a release before
14048
+ * it — one already running it has nothing to restart into — and anything else, a refusal, a skip, a removed job, to
14049
+ * any process.
14050
+ */
14051
+ function backgroundFor(left, running) {
14052
+ if (left === null || left === void 0) return null;
14053
+ if (left.outcome !== "installed") return left;
14054
+ return left.version !== null && isVersion(running) && isBehind(running, left.version) ? left : null;
14055
+ }
14056
+ /**
14057
+ * Whether the background job could be set up here, so offering it is not offering a refusal (design 2026-10-16 U4):
14058
+ * a platform it is written for, no organisation turning background updates off, and the stable core launcher it runs.
14059
+ */
14060
+ async function jobCanBeSetUp(core, updates) {
14061
+ if (updates.backgroundOff !== void 0 || !updateJobSupported(process.platform)) return false;
14062
+ try {
14063
+ return (await stat(launcherPaths(core.paths.dataDir, "core").launcher)).isFile();
14064
+ } catch {
14065
+ return false;
14066
+ }
14067
+ }
14068
+ /** Whether the job reaches a process of `name`'s launcher (`JobReach`); null where it does not, or it cannot be read. */
14069
+ async function jobReach(core, env, name) {
14070
+ const launched = env[LAUNCHER_ENV]?.trim();
14071
+ if (name === void 0 || launched !== name) return null;
14072
+ try {
14073
+ const pointer = await readPointer(core.paths.dataDir, name);
14074
+ if (pointer === void 0) return null;
14075
+ const folder = basename(pointer.current);
14076
+ const version = folder.endsWith(`-${name}`) ? folder.slice(0, -(name.length + 1)) : "";
14077
+ return { pointed: isPlainVersion(version) ? version : null };
14078
+ } catch {
14079
+ return null;
14080
+ }
14081
+ }
14082
+ /**
14083
+ * Whether the job covers a security fix for this process: it reaches it (`jobReach`), it installs the fix on its own —
14084
+ * not a new major, nor one needing a decision, which it leaves for the person — and its launcher does not start the
14085
+ * fix yet, so the job installs it within a day. Once it does, the process still runs the release before: it is told.
14086
+ */
14087
+ function coveredByJob(reach, fix, record, running) {
14088
+ if (reach === null) return false;
14089
+ const from = reach.pointed ?? running;
14090
+ if (!isVersion(from) || majorOf(fix) > majorOf(from) || record.release?.needsDecision === true) return false;
14091
+ return !(reach.pointed !== null && !isBehind(reach.pointed, fix));
14092
+ }
14093
+ /** The notice the record makes for this process, before any spacing: see `decideNotice`. */
14094
+ function noticeOf(record, running, where, updates, at, reach) {
14095
+ const pending = updateVerdict(record, running, where);
14096
+ if (pending === null) return null;
14097
+ const background = updates.background && reach !== null;
14098
+ const fix = securityRelease(record, running);
14099
+ const security = fix !== null && !(background && coveredByJob(reach, fix, record, running));
14100
+ const pausedUntil = pauseEnd(record, at);
14101
+ const facts = record.release?.version === pending.latest ? record.release : null;
14102
+ const notice = {
14103
+ ...pending,
14104
+ security,
14105
+ major: background && isVersion(running) && majorOf(pending.latest) > majorOf(running),
14106
+ needsDecision: background && facts?.needsDecision === true,
14107
+ pausedUntil
12328
14108
  };
14109
+ if (security) return notice;
14110
+ if (updates.level === "never" || pausedUntil !== null) return null;
14111
+ if (background && pending.kind !== "restart" && !notice.major && !notice.needsDecision) return null;
14112
+ return notice;
14113
+ }
14114
+ /** Who started a server from a release it pins itself, in words: the plugin's, the extension's. */
14115
+ function startedByWords(startedBy) {
14116
+ if (startedBy === "claude-code-plugin") return "the Claude Code plugin";
14117
+ if (startedBy === "gemini-extension") return "the Gemini extension";
14118
+ return "a plugin or an extension";
14119
+ }
14120
+ /**
14121
+ * The server's line. "Installed" only on the check's positive finding that restarting starts `latest`; a server a
14122
+ * plugin or an extension started is updated where it was installed, which `comms_update` never moves.
14123
+ */
14124
+ function serverNoticeLine(notice, env = {}) {
14125
+ const paused = pausedWords(notice);
14126
+ if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; restart the client (in Claude Code, /mcp → Reconnect) to use it.${paused}`;
14127
+ const startedBy = env[UPDATE_STARTED_BY_ENV]?.trim();
14128
+ if (startedBy) return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; this server came with ${startedByWords(startedBy)}, and is updated there ${notice.security ? "soon" : "when it suits you"}.${paused}`;
14129
+ return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; say "update" ${notice.security ? "soon" : "when it suits you"}.${paused}`;
14130
+ }
14131
+ /**
14132
+ * The line for a background job that has not run in 48 hours (U4, U7): as though there were none, every notice and the
14133
+ * security line are said again, and this says why, and where to look.
14134
+ */
14135
+ function jobSilentLine(since) {
14136
+ const at = new Date(since);
14137
+ const day = Number.isFinite(at.getTime()) ? localStamp(at).slice(0, 10) : "it was set up";
14138
+ return `${PRODUCT}: background updates have not run since ${day}; check Login Items (macOS) or run doctor.`;
14139
+ }
14140
+ /** How many registrations, in words: "one registration", "2 registrations". */
14141
+ const registrations = (count) => count === 1 ? "one registration" : `${count} registrations`;
14142
+ /**
14143
+ * A server's one line, from everything `decideNotice` decided: the background run's word first, then the release this
14144
+ * server is behind — unless the run's word is about that same release — then the move to the stable launcher, and the
14145
+ * offer of the background job.
14146
+ */
14147
+ function serverDecisionLine(decision, env = {}) {
14148
+ const { notice, background, moves } = decision;
14149
+ const parts = [];
14150
+ if (background !== null) parts.push(backgroundNoticeLine(background));
14151
+ if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(serverNoticeLine(notice, env));
14152
+ if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
14153
+ if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; one approved update (say "update") moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`);
14154
+ if (decision.offerJob) parts.push("To have releases install in the background instead, comms_update with backgroundJob \"on\" sets up a daily job: its preview shows the job, when it runs and how to remove it, and sets nothing up without your yes.");
14155
+ return parts.join(" ");
12329
14156
  }
12330
- /** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
14157
+ /** What is particular about the release, after "is out": a security fix, a major release, a decision it needs. */
14158
+ function whatItIs(notice) {
14159
+ if (notice.security) return ", and fixes a security problem";
14160
+ if (notice.major) return ", a major release, which waits for you";
14161
+ if (notice.needsDecision) return ", and needs a decision from you before it installs";
14162
+ return "";
14163
+ }
14164
+ /** Every notice during a pause says when it ends (U3): only a security fix is said then. */
14165
+ function pausedWords(notice) {
14166
+ if (!notice.pausedUntil) return "";
14167
+ return ` Updates are paused until ${localStamp(notice.pausedUntil)}; a security fix is not held.`;
14168
+ }
14169
+ /** The commands that never print the notice, by their first word; `mcp` alone runs a server, which has its own. */
12331
14170
  const EXEMPT_COMMANDS = Object.freeze([
12332
14171
  "update",
12333
14172
  "doctor",
@@ -12337,201 +14176,105 @@ const EXEMPT_COMMANDS = Object.freeze([
12337
14176
  "help"
12338
14177
  ]);
12339
14178
  /**
12340
- * Whether a command is left alone by the update gate: `update` in every form, `doctor`, `paths`, `approve`,
12341
- * `approvals`, help, and `mcp` alone — the server, which gates every call itself. `mcp install` and `mcp prune` are
12342
- * not: they register and prune at this command's release. `also` adds a channel's own — WhatsApp's `status`, its
12343
- * doctor. `--help` and `--version` never reach a command.
14179
+ * Whether a command is left alone by the update notice: `update` in every form, `doctor`, `paths`, `approve`,
14180
+ * `approvals`, help, and `mcp` alone — the server, which gives its own notice. `mcp install` and `mcp prune` are not.
14181
+ * `also` adds a channel's own — WhatsApp's `status`, its doctor. `--help` and `--version` never reach a command.
12344
14182
  */
12345
- function exemptFromUpdateGate(path, also = []) {
14183
+ function exemptFromUpdateNotice(path, also = []) {
12346
14184
  const [first] = path;
12347
14185
  if (first === void 0) return true;
12348
14186
  if (EXEMPT_COMMANDS.includes(first) || also.includes(first)) return true;
12349
14187
  return first === "mcp" && path.length === 1;
12350
14188
  }
12351
- /** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateGate` reads. */
14189
+ /** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateNotice` reads. */
12352
14190
  function commandPathOf(command) {
12353
14191
  const path = [];
12354
14192
  for (let at = command; at?.parent; at = at.parent) path.unshift(at.name());
12355
14193
  return path;
12356
14194
  }
14195
+ /** How often a command says it, for the whole machine: once a day. */
14196
+ const COMMAND_NOTICE_INTERVAL_MS = 864e5;
12357
14197
  /**
12358
- * The approvals a Commander command claims: its `--approval`, the `--mcp-approval` Gmail's `setup` carries beside it,
12359
- * the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
12360
- * cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
12361
- * What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
12362
- * by the time a `preAction` hook runs.
12363
- */
12364
- function approvalsOf(command) {
12365
- const options = command.opts();
12366
- const named = (command.registeredArguments ?? []).flatMap((argument, index) => argument.name() === "approvalId" ? [command.processedArgs?.[index]] : []);
12367
- return [
12368
- options.approval,
12369
- options.mcpApproval,
12370
- options.choice,
12371
- ...named
12372
- ];
12373
- }
12374
- /**
12375
- * The update gate at a terminal, before a command runs. Resolves to null to run the command, or to the exit status to
12376
- * end with instead; throws `UPDATE_REQUIRED` (exit 11) where nobody can be asked.
12377
- *
12378
- * It reads the file first. If the file is a day old it asks the registry — through `check` — waiting at most about
12379
- * three seconds and going on without it after that. Then, when a newer release is out, nothing puts it off, and the
12380
- * command claims no approval the person already gave:
14198
+ * The update notice at a terminal, before a command runs. It never stops the command, and never throws.
12381
14199
  *
12382
- * - a person at a terminal (stdin and stdout a terminal, no `--json`, not `CI`, not an agent) is asked "Update now,
12383
- * later today, or cancel?". Now runs the update, with its own preview and yes, and ends asking them to run the
12384
- * command again; later records "not now" — their answer is the approval — and runs the command; cancel ends, having
12385
- * done nothing;
12386
- * - anything else — a script, cron, an agent, `--json` — gets `UPDATE_REQUIRED`, naming `agentcomms update` and
12387
- * `agentcomms update --later`, and the command does not run.
12388
- *
12389
- * An update installed and not yet run by this command — this command's package is installed globally at the latest
12390
- * release, and what is running is an older copy from somewhere else — stops it the same way, as the servers stop for
12391
- * a client not yet restarted (§3: the terminal stops as chat does). Its "now" says to run the command again from the
12392
- * installed one: there is nothing to update.
14200
+ * It reads the file first. If the check is due for the machine's level it asks the registry — through `check` — waiting
14201
+ * at most about three seconds and going on without it after that. Then, when the level says so (`decideNotice`), it
14202
+ * prints one line on stderr — never on stdout, where a result or `--json` is — and the command runs.
12393
14203
  */
12394
- async function updateGateAtTerminal(options) {
12395
- const { core, env, streams, output } = options;
14204
+ async function updateNoticeAtTerminal(options) {
14205
+ const { core, env, streams } = options;
12396
14206
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
12397
- if (!(await updateCheckEnabled(core, env)).on) return null;
12398
- if (options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now())) {
12399
- let timer;
12400
- const gaveUp = new Promise((resolve) => {
12401
- timer = setTimeout(resolve, options.waitMs ?? 3e3);
12402
- });
12403
- try {
12404
- await Promise.race([options.check().catch(() => void 0), gaveUp]);
12405
- } finally {
12406
- clearTimeout(timer);
14207
+ try {
14208
+ if (updateCheckSwitchedOff(env) !== null) return;
14209
+ const enabled = await updateCheckEnabled(core, env);
14210
+ if (enabled.on && options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now(), enabled.intervalMs)) {
14211
+ let timer;
14212
+ const gaveUp = new Promise((resolve) => {
14213
+ timer = setTimeout(resolve, options.waitMs ?? 3e3);
14214
+ });
14215
+ try {
14216
+ await Promise.race([options.check().catch(() => void 0), gaveUp]);
14217
+ } finally {
14218
+ clearTimeout(timer);
14219
+ }
12407
14220
  }
12408
- }
12409
- for (const id of options.approvals ?? []) if (await claimsApproval(core, id, options.approvalClaim)) return null;
12410
- const where = {
14221
+ const line = await commandNotice(options, now);
14222
+ if (line !== null) streams.stderr.write(`${line}\n`);
14223
+ } catch {}
14224
+ }
14225
+ /** The command's line, decided and recorded under the file's lock: see `decideNotice`. */
14226
+ async function commandNotice(options, now) {
14227
+ const decision = await decideNotice({
14228
+ ...options,
14229
+ now
14230
+ }, {
12411
14231
  channel: options.channel,
12412
14232
  surface: "command"
12413
- };
12414
- const pending = await pendingUpdate({
12415
- core,
12416
- env,
12417
- running: options.running,
12418
- now,
12419
- ...where
12420
- });
12421
- if (pending === null) return null;
12422
- const person = agentMarker(env) === null && canPrompt(env, streams, {
12423
- json: output.json,
12424
- noInput: options.noInput === true
12425
14233
  });
12426
- const commands = updateCommands(core, output.platform);
12427
- if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
12428
- const bold = (word) => paint(output.color, "bold", word);
12429
- const answer = (await askLine(streams, pending.kind === "restart" ? `${pending.latest} is installed (this is ${pending.running}). Switch ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? ` : `${pending.latest} is out (you have ${pending.running}). Update ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? `)).trim().toLowerCase();
12430
- if (answer === "now" || answer === "update") {
12431
- if (pending.kind === "restart") {
12432
- streams.stdout.write(`${pending.latest} is installed globally; this ${cliOf(options.channel)} is ${pending.running}, started from somewhere else — npx's cache, a project's own install, a checkout. Run your command again from the installed one. The command did not run.\n`);
12433
- return EXIT_CODES.UPDATE;
12434
- }
12435
- if (!options.update) {
12436
- streams.stdout.write(`This ${cliOf(options.channel)} reads only this machine and cannot fetch the update itself. ${updateCommandSaid(commands, "update", (command) => `Run ${command}, then run your command again.`)}\n`);
12437
- return EXIT_CODES.UPDATE;
12438
- }
12439
- return afterUpdate(await options.update(), options, pending);
12440
- }
12441
- if (answer === "later" || answer === "l") {
12442
- const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
12443
- channel: options.channel,
12444
- env,
12445
- output,
12446
- rerun: ["update", "--later"],
12447
- rerunOn: "core",
12448
- answered: true,
12449
- streams
12450
- });
12451
- streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
12452
- return null;
12453
- }
12454
- throw new CommsError("USAGE", "cancelled: the command did not run, and nothing was changed", { hint: bothSaid(commands, (update, later) => `Update with ${update}, or put it off until tomorrow with ${later}.`) });
14234
+ if (decision === null) return null;
14235
+ return commandDecisionLine(options.core, decision, options.channel, options.output.platform);
12455
14236
  }
12456
14237
  /**
12457
- * The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
12458
- * command most people can run, and which a sentence would read as one (CUE-403).
14238
+ * The CLI a notice names, in product words — "Gmail CLI", "core CLI" — never its binary, which is no command most
14239
+ * people can run, and which a sentence would read as one (CUE-403).
12459
14240
  */
12460
14241
  function cliOf(channel) {
12461
14242
  if (channel === "core") return "core CLI";
12462
14243
  return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
12463
14244
  }
12464
14245
  /**
12465
- * The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
12466
- * — the update installed, and an older copy running — says to run the command from the installed one.
14246
+ * The command's line: the update, as core's own command located from what is printing — or why there is none here —
14247
+ * or, when this command's package is installed globally at `latest` and an older copy is running, to run that one.
12467
14248
  */
12468
- function updateRequired(pending, cli, commands) {
12469
- const ways = updateWaysOf(commands);
12470
- const details = {
12471
- running: pending.running,
12472
- latest: pending.latest,
12473
- installed: pending.kind === "restart",
12474
- update: ways.update,
12475
- later: ways.later
12476
- };
12477
- if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${cli} is ${pending.running}: ${handoffSentence(commands.later, (later) => `run the command again from the installed one, or ${later} to put it off until tomorrow`)}`, {
12478
- hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
12479
- details
12480
- });
12481
- return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): ${bothSaid(commands, (update, later) => `run ${update} first, or ${later} to put it off until tomorrow`)}`, {
12482
- hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
12483
- details
12484
- });
12485
- }
12486
- /** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
12487
- function bothSaid(commands, say) {
12488
- return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
14249
+ function commandNoticeLine(core, notice, channel, platform) {
14250
+ const paused = pausedWords(notice);
14251
+ if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; this ${cliOf(channel)} is ${notice.running}, started from somewhere else — npx's cache, a project's own install, a checkout: run it from the installed one.${paused}`;
14252
+ const update = requireHandoffs(core, platform).core(["update"]);
14253
+ const when = notice.security ? "soon" : "when it suits you";
14254
+ return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)} (this is ${notice.running}); ${handoffSentence(update, (command) => `run ${command} ${when}.`)}${paused}`;
12489
14255
  }
12490
14256
  /**
12491
- * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
12492
- * the update did everything and this command's release is now installed where it runs from — its global package at
12493
- * the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
12494
- * script that goes on after `cmd && next` must not take it for the command's success.
14257
+ * A command's one line, from everything `decideNotice` decided, as `serverDecisionLine` puts it together — every
14258
+ * command named located for this installation, or why there is none here.
12495
14259
  */
12496
- async function afterUpdate(outcome, options, before) {
12497
- const { streams } = options;
12498
- const cli = cliOf(options.channel);
12499
- const notRun = "The command did not run";
12500
- const commands = updateCommands(options.core, options.output.platform);
12501
- if (outcome === "failed") {
12502
- streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
12503
- return EXIT_CODES.UNAVAILABLE;
12504
- }
12505
- const after = updateVerdict(await readUpdateCheck(options.core.paths.stateDir), options.running, {
12506
- channel: options.channel,
12507
- surface: "command"
12508
- });
12509
- if (after === null || after.kind === "restart") {
12510
- if (outcome === "updated") {
12511
- streams.stdout.write("Updated. Run your command again.\n");
12512
- return EXIT_CODES.OK;
12513
- }
12514
- streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
12515
- return EXIT_CODES.UPDATE;
12516
- }
12517
- streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. The update moves what this machine registers and has installed globally — what it found is said above — and this ${cli}, ${before.running}, is started from somewhere else: npx's cache, a project's own install, a checkout. ${handoffSentence(commands.later, (later) => `Run it from ${before.latest}, or put this off until tomorrow with ${later}.`)}\n`);
12518
- return EXIT_CODES.UPDATE;
12519
- }
12520
- async function askLine(streams, question) {
12521
- const { createInterface } = await import("node:readline/promises");
12522
- const rl = createInterface({
12523
- input: streams.stdin,
12524
- output: streams.stderr
12525
- });
12526
- try {
12527
- return await rl.question(question);
12528
- } finally {
12529
- rl.close();
12530
- }
14260
+ function commandDecisionLine(core, decision, channel, platform) {
14261
+ const { notice, background, moves } = decision;
14262
+ const handoffs = requireHandoffs(core, platform);
14263
+ const parts = [];
14264
+ if (background !== null) parts.push(backgroundNoticeLine(background));
14265
+ if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(commandNoticeLine(core, notice, channel, platform));
14266
+ if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
14267
+ if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; ${handoffSentence(handoffs.core(["update"]), (update) => `one approved update, ${update}, moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`)}`);
14268
+ if (decision.offerJob) parts.push(handoffSentence(handoffs.core([
14269
+ "update",
14270
+ "--background-job",
14271
+ "on"
14272
+ ]), (command) => `To have releases install in the background instead, run ${command}: it shows the job, when it runs and how to remove it, and sets nothing up without your yes.`));
14273
+ return parts.join(" ");
12531
14274
  }
12532
14275
  channelServer("core").packageName;
12533
14276
  //#endregion
12534
- //#region ../core/dist/tool-arguments-BkdTZEb8.mjs
14277
+ //#region ../core/dist/tool-arguments-C8yi7cpo.mjs
12535
14278
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
12536
14279
  const TARGET = "draft-2020-12";
12537
14280
  /**
@@ -12543,13 +14286,14 @@ const TARGET = "draft-2020-12";
12543
14286
  * `refuse` is handed a `USAGE` `CommsError` and returns the tool result, so the refusal is in the same envelope as
12544
14287
  * every other refusal that server makes.
12545
14288
  *
12546
- * `gate` is asked next, with the checked arguments, before the handler: the daily update check's stop (design
12547
- * 2026-09-28), which every server builds with `updateToolGate`. Here because this is the one place every tool of every
12548
- * server already passes through, so no tool can be registered without it; given to the wrapper rather than built
12549
- * in, because what it asks the registry with is each server's own — and WhatsApp's is nothing at all. A call it
12550
- * answers reaches nothing, as a refused one does.
14289
+ * `notice` is asked once the handler has answered: the update notice (design 2026-10-16, U1), which every server builds
14290
+ * with `updateToolNotice`. Here because this is the one place every tool of every server already passes through, so no
14291
+ * tool can be registered without it; given to the wrapper rather than built in, because what it asks the registry
14292
+ * with is each server's own — and WhatsApp's is nothing at all. It never stops a call: a line it gives is carried
14293
+ * beside the result (`withUpdateNotice`), and a tool's output schema is published with room for it
14294
+ * (`withUpdateField`), so a client that checks a result against its schema takes the one that carries it.
12551
14295
  */
12552
- function strictToolArguments(server, refuse, gate, retired = {}) {
14296
+ function strictToolArguments(server, refuse, notice, retired = {}) {
12553
14297
  const target = server;
12554
14298
  if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
12555
14299
  if (target[APPLIED]) return;
@@ -12560,15 +14304,21 @@ function strictToolArguments(server, refuse, gate, retired = {}) {
12560
14304
  const declared = config.inputSchema !== void 0;
12561
14305
  const schema = strictSchemaOf(name, config.inputSchema);
12562
14306
  const published = schema["~standard"].jsonSchema.input({ target: TARGET });
12563
- return register(name, {
14307
+ const output = notice && config.outputSchema !== void 0 ? withUpdateField(config.outputSchema) : null;
14308
+ const room = config.outputSchema === void 0 || output !== null;
14309
+ const registered = output === null ? config : {
12564
14310
  ...config,
14311
+ outputSchema: output
14312
+ };
14313
+ return register(name, {
14314
+ ...registered,
12565
14315
  inputSchema: passThrough(schema)
12566
14316
  }, async (input, context) => {
12567
14317
  const parsed = await schema.safeParseAsync(input ?? {});
12568
14318
  if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
12569
- const stopped = gate ? await gate(name, parsed.data) : null;
12570
- if (stopped !== null) return stopped;
12571
- return declared ? handler(parsed.data, context) : handler(context);
14319
+ const result = await (declared ? handler(parsed.data, context) : handler(context));
14320
+ const line = notice ? await notice(name).catch(() => null) : null;
14321
+ return line === null ? result : withUpdateNotice(result, line, room);
12572
14322
  });
12573
14323
  };
12574
14324
  Object.defineProperty(target, APPLIED, { value: true });
@@ -12608,6 +14358,83 @@ function passThrough(schema) {
12608
14358
  jsonSchema: schema["~standard"].jsonSchema
12609
14359
  } };
12610
14360
  }
14361
+ /** The field a result carries the update notice in, as every output schema publishes it. */
14362
+ const UPDATE_FIELD = "update";
14363
+ const UPDATE_PROPERTY = {
14364
+ type: "string",
14365
+ description: "Once per server process at most: a newer agent-communications release is out, or installed and waiting for a restart. Pass it on to the person; nothing was refused."
14366
+ };
14367
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
14368
+ /**
14369
+ * A result with the notice beside it: a text block of its own after the result's — a client that shows the model only
14370
+ * text reads it there — and, where the structured content is an object with room for it, its `update` field, which is
14371
+ * all Claude Code and Codex show. Nothing of the result is changed or taken away.
14372
+ */
14373
+ function withUpdateNotice(result, line, room = true) {
14374
+ if (!isPlainObject(result)) return result;
14375
+ const content = Array.isArray(result.content) ? result.content : [];
14376
+ const next = {
14377
+ ...result,
14378
+ content: [...content, {
14379
+ type: "text",
14380
+ text: line
14381
+ }]
14382
+ };
14383
+ const structured = result.structuredContent;
14384
+ if (room && isPlainObject(structured) && !Object.hasOwn(structured, UPDATE_FIELD)) next.structuredContent = {
14385
+ ...structured,
14386
+ [UPDATE_FIELD]: line
14387
+ };
14388
+ return next;
14389
+ }
14390
+ /** A JSON Schema with the `update` field among its properties — each side of a union — or null with no room for it. */
14391
+ function publishedWithUpdate(json) {
14392
+ const alternatives = json.anyOf ?? json.oneOf;
14393
+ if (Array.isArray(alternatives) && json.properties === void 0) {
14394
+ const each = alternatives.map(publishedWithUpdate);
14395
+ if (each.some((alternative) => alternative === null)) return null;
14396
+ return {
14397
+ ...json,
14398
+ ...json.anyOf ? { anyOf: each } : { oneOf: each }
14399
+ };
14400
+ }
14401
+ if (!(Array.isArray(json.type) ? json.type : [json.type]).includes("object") || json.properties === void 0) return null;
14402
+ if (Object.hasOwn(json.properties, UPDATE_FIELD)) return null;
14403
+ return {
14404
+ ...json,
14405
+ properties: {
14406
+ ...json.properties,
14407
+ [UPDATE_FIELD]: UPDATE_PROPERTY
14408
+ }
14409
+ };
14410
+ }
14411
+ /**
14412
+ * A tool's output schema, published with room for the `update` field: the same schema, with that one property added
14413
+ * — a zod object publishes `additionalProperties: false`, and a client that checks a result against it would refuse
14414
+ * the one carrying the notice — and checked as it always was, without the field. Null when the schema has no room for
14415
+ * it: one that declares `update` itself, or is not an object; that tool's notice is a text block alone.
14416
+ */
14417
+ function withUpdateField(outputSchema) {
14418
+ const standard = (isPlainObject(outputSchema) && !("~standard" in outputSchema) && Object.values(outputSchema).every(isZodSchema) ? object(outputSchema) : outputSchema)?.["~standard"];
14419
+ if (typeof standard?.validate !== "function" || standard.jsonSchema === void 0) return null;
14420
+ const { jsonSchema } = standard;
14421
+ const input = publishedWithUpdate(jsonSchema.input({ target: TARGET }));
14422
+ const output = publishedWithUpdate(jsonSchema.output({ target: TARGET }));
14423
+ if (input === null || output === null) return null;
14424
+ return { "~standard": {
14425
+ version: 1,
14426
+ vendor: "agentcomms",
14427
+ validate: (value) => {
14428
+ if (!isPlainObject(value) || !Object.hasOwn(value, UPDATE_FIELD)) return standard.validate(value);
14429
+ const { [UPDATE_FIELD]: _notice, ...rest } = value;
14430
+ return standard.validate(rest);
14431
+ },
14432
+ jsonSchema: {
14433
+ input: () => input,
14434
+ output: () => output
14435
+ }
14436
+ } };
14437
+ }
12611
14438
  /** A USAGE refusal naming what was wrong and what the tool takes. */
12612
14439
  function refusal(tool, published, input, issues, retired) {
12613
14440
  const properties = published.properties ?? {};
@@ -16240,4 +18067,4 @@ async function syncAccount(context, request) {
16240
18067
  });
16241
18068
  }
16242
18069
  //#endregion
16243
- export { exemptFromUpdateGate as $, requireAccount as A, PATH_OPTIONS as B, chatKindOf as C, writeResult as Ct, text as D, numeric as E, VERSION$1 as Et, expandSource as F, approveAndWaitSentence as G, absoluteSearchPath as H, strictToolArguments as I, childEnvironment as J, approveChangeAtTerminal as K, CHANNEL_SERVERS as L, storeIdentity as M, checkStorePath as N, PLATFORM as O, defaultStorePath as P, escapeForDisplay as Q, CommsError as R, chatRefOf as S, withFileLock as St, kindOf as T, PACKAGE_NAME as Tt, agentMarker as U, UNTRUSTED_NOTICE as V, approvalsOf as W, colorEnabled as X, cliHandoffs as Y, commandPathOf as Z, WhatsAppContext as _, toCommsError as _t, CHAT_KINDS as a, lookupName as at, requireSupportedNode as b, updateToolGate as bt, searchMessages as c, openCore as ct, composeDraft as d, renderInstall as dt, gatedChangeAtTerminal as et, draftMessage as f, renderPrune as ft, visibilityUnitKey as g, serverPruneChange as gt, statusAuthor as h, serverInstallChange as ht, probeStore as i, isCommsError as it, requireNamedConfig as j, newWhatsAppAccount as k, Presenter as l, paint as lt, listKey as m, runCommand as mt, withCheckedCopy as n, handoffText as nt, listChats as o, nameAvailable as ot, Visibility as p, resolvePaths as pt, canPrompt as q, whatsappStatus as r, installExitStatus as rt, readChat as s, nameShapeProblem as st, syncAccount as t, handoffSentence as tt, innerText as u, pathOverridesFromCliOptions as ut, WhatsAppIndex as v, truncateDisplay as vt, coreDataToIso as w, CALLER as wt, inspectSchema as x, windowsSystemProgram as xt, rebuildIndex as y, updateGateAtTerminal as yt, EXIT_CODES as z };
18070
+ export { gatedChangeAtTerminal as $, requireAccount as A, PATH_OPTIONS as B, chatKindOf as C, CALLER as Ct, text as D, numeric as E, expandSource as F, approveChangeAtTerminal as G, absoluteSearchPath as H, strictToolArguments as I, cliHandoffs as J, canPrompt as K, CHANNEL_SERVERS as L, storeIdentity as M, checkStorePath as N, PLATFORM as O, defaultStorePath as P, exemptFromUpdateNotice as Q, CommsError as R, chatRefOf as S, writeResult as St, kindOf as T, VERSION$2 as Tt, agentMarker as U, UNTRUSTED_NOTICE as V, approveAndWaitSentence as W, commandPathOf as X, colorEnabled as Y, escapeForDisplay as Z, WhatsAppContext as _, truncateDisplay as _t, CHAT_KINDS as a, nameAvailable as at, requireSupportedNode as b, windowsSystemProgram as bt, searchMessages as c, paint as ct, composeDraft as d, renderPrune as dt, handoffSentence as et, draftMessage as f, resolvePaths as ft, visibilityUnitKey as g, toCommsError as gt, statusAuthor as h, serverPruneChange as ht, probeStore as i, lookupName as it, requireNamedConfig as j, newWhatsAppAccount as k, Presenter as l, pathOverridesFromCliOptions as lt, listKey as m, serverInstallChange as mt, withCheckedCopy as n, installExitStatus as nt, listChats as o, nameShapeProblem as ot, Visibility as p, runCommand as pt, childEnvironment as q, whatsappStatus as r, isCommsError as rt, readChat as s, openCore as st, syncAccount as t, handoffText as tt, innerText as u, renderInstall as ut, WhatsAppIndex as v, updateNoticeAtTerminal as vt, coreDataToIso as w, PACKAGE_NAME as wt, inspectSchema as x, withFileLock as xt, rebuildIndex as y, updateToolNotice as yt, EXIT_CODES as z };