@agentcomms/whatsapp 0.18.2 → 0.20.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.18.2";
14
+ const VERSION$2 = "0.20.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-ev9Jxi1R.mjs
31
+ //#region ../core/dist/update-check-N_-uYKZQ.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,11 +106,6 @@ 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,
114
- retryable: false,
115
- summary: "a newer release is out: update first, or put it off until tomorrow"
116
- },
117
109
  USAGE: {
118
110
  exit: EXIT_CODES.USAGE,
119
111
  retryable: false,
@@ -903,6 +895,203 @@ var TaintStore = class {
903
895
  return sortedOrigins([...originEntry(sidecar), ...sidecar !== void 0 && isNewer(entry.at, sidecar.at) ? ["read"] : []]);
904
896
  }
905
897
  };
898
+ /**
899
+ * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
900
+ * and the preview renderer (which makes them visible), so the two can never disagree.
901
+ */
902
+ /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
903
+ const INVISIBLE_RANGES = [
904
+ [173, 173],
905
+ [847, 847],
906
+ [1564, 1564],
907
+ [4447, 4448],
908
+ [6068, 6069],
909
+ [6155, 6159],
910
+ [8203, 8207],
911
+ [8232, 8233],
912
+ [8234, 8238],
913
+ [8288, 8292],
914
+ [8294, 8303],
915
+ [12644, 12644],
916
+ [65024, 65039],
917
+ [65279, 65279],
918
+ [65440, 65440],
919
+ [917504, 917631]
920
+ ];
921
+ function isInvisible(codePoint) {
922
+ for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
923
+ return false;
924
+ }
925
+ /**
926
+ * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
927
+ * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
928
+ */
929
+ function isControl(codePoint) {
930
+ if (codePoint === 9 || codePoint === 10) return false;
931
+ return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
932
+ }
933
+ function isDangerous(codePoint) {
934
+ return isControl(codePoint) || isInvisible(codePoint);
935
+ }
936
+ /**
937
+ * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
938
+ * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
939
+ *
940
+ * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
941
+ * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
942
+ * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
943
+ */
944
+ function stripInvisible(text) {
945
+ let removed = 0;
946
+ let out = "";
947
+ for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
948
+ else out += char;
949
+ return {
950
+ text: out,
951
+ removed
952
+ };
953
+ }
954
+ /**
955
+ * Labelling what an agent sends (design 2026-10-15-agent-label): the phrase `<name> (<word>)`, who a message appears to
956
+ * come from, and which setting changes loosen that. Pure: nothing here reads the configuration or talks to a channel,
957
+ * so every channel decides the same way from the same settings.
958
+ */
959
+ /** L2: the words a label may use, exactly as written. Adding one is a code change with its own review. */
960
+ const AGENT_LABEL_WORDS = Object.freeze([
961
+ "agent",
962
+ "assistant",
963
+ "twin",
964
+ "minion",
965
+ "bot",
966
+ "fake",
967
+ "AI"
968
+ ]);
969
+ /** L4: the kinds of act a rule may name. Slack posts, replies, reacts, edits and shares files; mail sends and replies. */
970
+ const ACT_KINDS = Object.freeze([
971
+ "post",
972
+ "reply",
973
+ "reaction",
974
+ "edit",
975
+ "file",
976
+ "send"
977
+ ]);
978
+ /**
979
+ * How an `agent` act looks in one Slack workspace (the owner's decision, 2026-10-17): `app`, the default, posts as the
980
+ * app where S2 says it can, with Slack's APP tag, and labelled as the person elsewhere; `labelled` posts every one as
981
+ * the person, labelled as S3's fallback is, even where the app could post. Slack's APP tag cannot be hidden or renamed,
982
+ * so this is the person's choice between that tag and words in the message.
983
+ */
984
+ const SLACK_STYLES = Object.freeze(["app", "labelled"]);
985
+ /** L2's characters a name may not hold: parentheses would fake a second label, the rest break an address header. */
986
+ const FORBIDDEN_NAME_CHARACTERS = /[()<>"@,;:\\]/u;
987
+ function isAgentLabelWord(value) {
988
+ return typeof value === "string" && AGENT_LABEL_WORDS.includes(value);
989
+ }
990
+ function isSlackStyle(value) {
991
+ return typeof value === "string" && SLACK_STYLES.includes(value);
992
+ }
993
+ /** A Slack workspace's style, by its account id: `app` unless `labelled` is recorded for it. */
994
+ function slackStyleOf(settings, accountId) {
995
+ const styles = settings?.slackStyles;
996
+ return styles !== void 0 && Object.hasOwn(styles, accountId) && styles[accountId] === "labelled" ? "labelled" : "app";
997
+ }
998
+ /** Why a name cannot be labelled, or undefined when it can. */
999
+ function nameProblem(name) {
1000
+ if (name.length === 0) return "the name is empty";
1001
+ if (name.trim() !== name) return "the name starts or ends with a space";
1002
+ if (FORBIDDEN_NAME_CHARACTERS.test(name)) return "the name holds a parenthesis or one of <>\"@,;:\\";
1003
+ for (const char of name) if (isDangerous(char.codePointAt(0) ?? 0)) return "the name holds a hidden or control character";
1004
+ }
1005
+ const ruleKey = (rule) => JSON.stringify([
1006
+ rule.account,
1007
+ rule.accountId ?? null,
1008
+ rule.conversation ?? null,
1009
+ rule.kinds === void 0 ? null : [...rule.kinds].sort(),
1010
+ rule.as
1011
+ ]);
1012
+ /**
1013
+ * L5: the changes that need the person's confirmation — anything that could leave a message unlabelled or change how
1014
+ * the label looks. Adding an `agent` rule or removing a `me` rule tightens, and is not listed. A Slack workspace moved
1015
+ * from `app` to `labelled` trades Slack's own APP tag, which no one can fake, for words in the message, so it waits;
1016
+ * moved back to `app`, it tightens. Its values are each moved workspace's style, before and after.
1017
+ */
1018
+ function classifyAgentLabelChange(before, after) {
1019
+ const changes = [];
1020
+ const wordBefore = before?.word ?? "agent";
1021
+ const wordAfter = after?.word ?? "agent";
1022
+ if (wordBefore !== wordAfter) changes.push({
1023
+ path: "agentLabel.word",
1024
+ was: wordBefore,
1025
+ now: wordAfter
1026
+ });
1027
+ if ((before?.name ?? null) !== (after?.name ?? null)) changes.push({
1028
+ path: "agentLabel.name",
1029
+ was: before?.name,
1030
+ now: after?.name
1031
+ });
1032
+ const rulesBefore = new Map((before?.rules ?? []).map((rule) => [ruleKey(rule), rule]));
1033
+ const rulesAfter = new Map((after?.rules ?? []).map((rule) => [ruleKey(rule), rule]));
1034
+ const meAdded = [...rulesAfter].some(([key, rule]) => rule.as === "me" && !rulesBefore.has(key));
1035
+ const agentRemoved = [...rulesBefore].some(([key, rule]) => rule.as === "agent" && !rulesAfter.has(key));
1036
+ if (meAdded || agentRemoved) changes.push({
1037
+ path: "agentLabel.rules",
1038
+ was: before?.rules ?? [],
1039
+ now: after?.rules ?? []
1040
+ });
1041
+ if (JSON.stringify(sorted(before?.slackIcons)) !== JSON.stringify(sorted(after?.slackIcons))) changes.push({
1042
+ path: "agentLabel.slackIcons",
1043
+ was: before?.slackIcons ?? {},
1044
+ now: after?.slackIcons ?? {}
1045
+ });
1046
+ const styled = [.../* @__PURE__ */ new Set([...Object.keys(before?.slackStyles ?? {}), ...Object.keys(after?.slackStyles ?? {})])].filter((id) => slackStyleOf(before, id) !== slackStyleOf(after, id)).sort();
1047
+ if (styled.some((id) => slackStyleOf(after, id) === "labelled")) changes.push({
1048
+ path: "agentLabel.slackStyles",
1049
+ was: Object.fromEntries(styled.map((id) => [id, slackStyleOf(before, id)])),
1050
+ now: Object.fromEntries(styled.map((id) => [id, slackStyleOf(after, id)]))
1051
+ });
1052
+ return changes;
1053
+ }
1054
+ function sorted(record) {
1055
+ return Object.entries(record ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1056
+ }
1057
+ const EMOJI_NAME = /^:[a-z0-9_+'-]{1,100}:$/u;
1058
+ function isRule(value) {
1059
+ if (typeof value !== "object" || value === null) return false;
1060
+ const rule = value;
1061
+ if (typeof rule.account !== "string" || rule.account.length === 0) return false;
1062
+ if (rule.as !== "agent" && rule.as !== "me") return false;
1063
+ if (rule.accountId !== void 0 && (typeof rule.accountId !== "string" || rule.accountId.length === 0)) return false;
1064
+ if (rule.conversation !== void 0 && (typeof rule.conversation !== "string" || rule.conversation.length === 0)) return false;
1065
+ if (rule.kinds !== void 0 && !(Array.isArray(rule.kinds) && rule.kinds.length > 0 && rule.kinds.every((kind) => ACT_KINDS.includes(kind)))) return false;
1066
+ return true;
1067
+ }
1068
+ /**
1069
+ * The stored `agentLabel`, read so that a bad value can only tighten. An unknown word reads as `agent`, an invalid
1070
+ * name as no name, and a rule list with any invalid rule is ignored whole: dropping one `agent` rule could leave a
1071
+ * broader `me` rule in charge. A malformed icon entry is dropped, falling back to the plain photo. A Slack style that is
1072
+ * neither `app` nor `labelled` is dropped too, so that workspace is `app`: the stronger disclosure.
1073
+ */
1074
+ function readAgentLabelSettings(raw) {
1075
+ if (typeof raw !== "object" || raw === null) return {};
1076
+ const stored = raw;
1077
+ const settings = {};
1078
+ if (isAgentLabelWord(stored.word)) settings.word = stored.word;
1079
+ if (typeof stored.name === "string" && nameProblem(stored.name) === void 0) settings.name = stored.name;
1080
+ if (Array.isArray(stored.rules) && stored.rules.every(isRule)) settings.rules = stored.rules.map((rule) => ({ ...rule }));
1081
+ if (typeof stored.slackIcons === "object" && stored.slackIcons !== null) {
1082
+ const icons = Object.entries(stored.slackIcons).filter((entry) => typeof entry[1] === "string" && EMOJI_NAME.test(entry[1]));
1083
+ if (icons.length > 0) settings.slackIcons = Object.fromEntries(icons);
1084
+ }
1085
+ if (typeof stored.slackStyles === "object" && stored.slackStyles !== null && !Array.isArray(stored.slackStyles)) {
1086
+ const styles = Object.entries(stored.slackStyles).filter((entry) => isSlackStyle(entry[1]));
1087
+ if (styles.length > 0) settings.slackStyles = Object.fromEntries(styles);
1088
+ }
1089
+ return settings;
1090
+ }
1091
+ /** The person's label settings from a loaded configuration, read as `readAgentLabelSettings` reads them. */
1092
+ function agentLabelOf(config) {
1093
+ return readAgentLabelSettings(config.agentLabel);
1094
+ }
906
1095
  /** Every first-party channel, the core first: what core knows about each without installing it. */
907
1096
  const CHANNEL_SNAPSHOT = [
908
1097
  {
@@ -1072,7 +1261,11 @@ const CHANNEL_SNAPSHOT = [
1072
1261
  word: "slack",
1073
1262
  can: "post to Slack"
1074
1263
  },
1075
- hosts: ["slack.com", "files.slack.com"],
1264
+ hosts: [
1265
+ "slack.com",
1266
+ "files.slack.com",
1267
+ "avatars.slack-edge.com"
1268
+ ],
1076
1269
  approve: "agent-slack approve",
1077
1270
  skills: {
1078
1271
  prefix: "slack-",
@@ -2968,17 +3161,508 @@ function commandLinePackage(command, args) {
2968
3161
  const line = [command, ...withoutPathOptions(args).words].join(" ");
2969
3162
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
2970
3163
  }
3164
+ /** The scope every server package is published under; a launcher is named for the rest of the package name. */
3165
+ const SCOPE$1 = "@agentcomms";
3166
+ const LAUNCH = "launch";
3167
+ const RUNTIMES = "runtime";
3168
+ const POINTER = ".current";
3169
+ const FALLBACK = ".fallback";
3170
+ const LOCK = ".lock";
3171
+ /**
3172
+ * What a launcher hands the process it starts (re-review of 0.20.0, severity 6): its own name, so the process knows it
3173
+ * was started through its package's stable launcher — the one the background job switches — and not by npx, a
3174
+ * versioned or local registration, or a command from somewhere else. The update notice holds back only for such a
3175
+ * process what the job will install (`update-gate.ts`), and only a run of `update --background` started through the
3176
+ * core launcher, as the job starts it, records that the job ran (`update-background.ts`).
3177
+ */
3178
+ const LAUNCHER_ENV = "AGENT_COMMS_LAUNCHER";
3179
+ /** A launcher's name: one plain word, never a path — the unscoped name of a server package. */
3180
+ const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3181
+ /** A runtime's folder in `<data>/runtime`: no separator, nothing that starts with a dot. */
3182
+ const FOLDER = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
3183
+ /** The version a managed runtime's folder starts with, as `mcp prune` reads one. */
3184
+ const VERSION$1 = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
3185
+ /** The CLI inside a runtime, as words from the runtime's folder: what `managedRuntimeEntry` names. */
3186
+ function cliWords(name) {
3187
+ return [
3188
+ "node_modules",
3189
+ SCOPE$1,
3190
+ name,
3191
+ "dist",
3192
+ "cli.mjs"
3193
+ ];
3194
+ }
3195
+ function checkedName(name) {
3196
+ if (!NAME.test(name)) throw new CommsError("CONFIG", `${JSON.stringify(name)} is not a launcher name: one plain word, such as gmail`);
3197
+ return name;
3198
+ }
3199
+ /** The launcher a server package is started through: its name without the scope, `@agentcomms/gmail` → `gmail`. */
3200
+ function launcherName(packageName) {
3201
+ const [scope, rest, extra] = packageName.split("/");
3202
+ if (scope !== SCOPE$1 || rest === void 0 || extra !== void 0) throw new CommsError("CONFIG", `${packageName} is not a package a stable launcher starts`);
3203
+ return checkedName(rest);
3204
+ }
3205
+ /** Where a launcher and its pointer are: `<data>/launch/<name>` and `<data>/launch/<name>.current`. */
3206
+ function launcherPaths(dataDir, name) {
3207
+ const directory = join(dataDir, LAUNCH);
3208
+ checkedName(name);
3209
+ return {
3210
+ launcher: join(directory, name),
3211
+ pointer: join(directory, `${name}${POINTER}`)
3212
+ };
3213
+ }
3214
+ /** Where the launcher records that it fell back: `<data>/launch/<name>.fallback`. */
3215
+ function fallbackRecordPath(dataDir, name) {
3216
+ return join(dataDir, LAUNCH, `${checkedName(name)}${FALLBACK}`);
3217
+ }
3218
+ /** The CLI a runtime holds for a launcher: `<runtime>/node_modules/@agentcomms/<name>/dist/cli.mjs`. */
3219
+ function launcherCli(runtimeDir, name) {
3220
+ return join(runtimeDir, ...cliWords(checkedName(name)));
3221
+ }
3222
+ /** The version a runtime folder of this launcher is, `0.20.0` for `0.20.0-gmail`; null for any other folder. */
3223
+ function launcherRuntimeVersion(runtimeDir, name) {
3224
+ const folder = basename(runtimeDir);
3225
+ const suffix = `-${checkedName(name)}`;
3226
+ if (!folder.endsWith(suffix)) return null;
3227
+ const version = folder.slice(0, -suffix.length);
3228
+ return VERSION$1.test(version) ? version : null;
3229
+ }
3230
+ /** A runtime folder this launcher may point at: a folder of its own in `<data>/runtime`, named for it. */
3231
+ function isOwnFolder(folder, name) {
3232
+ return FOLDER.test(folder) && folder.endsWith(`-${name}`) && folder.length > name.length + 1;
3233
+ }
3234
+ /** The pointer's text: one runtime folder a line, read by the launcher with nothing but its shell's built-ins. */
3235
+ function pointerText(current, previous) {
3236
+ return [
3237
+ "# The runtime the stable launcher beside this file starts, written by agent-communications. Do not edit.",
3238
+ `current=${current}`,
3239
+ ...previous === void 0 ? [] : [`previous=${previous}`],
3240
+ ""
3241
+ ].join("\n");
3242
+ }
3243
+ /** The pointer in `text`, as runtime folders, or null when it is not one this writes. */
3244
+ function parsePointer(text, name) {
3245
+ let current;
3246
+ let previous;
3247
+ for (const line of text.split(/\r?\n/)) {
3248
+ if (line === "" || line.startsWith("#")) continue;
3249
+ const at = line.indexOf("=");
3250
+ const key = at < 0 ? line : line.slice(0, at);
3251
+ const value = at < 0 ? "" : line.slice(at + 1);
3252
+ if (key === "current" && current === void 0) current = value;
3253
+ else if (key === "previous" && previous === void 0) previous = value;
3254
+ else return null;
3255
+ }
3256
+ if (current === void 0 || !isOwnFolder(current, name)) return null;
3257
+ if (previous !== void 0 && !isOwnFolder(previous, name)) return null;
3258
+ return previous === void 0 ? { current } : {
3259
+ current,
3260
+ previous
3261
+ };
3262
+ }
3263
+ function asPaths(dataDir, folders) {
3264
+ const runtimes = join(dataDir, RUNTIMES);
3265
+ return folders.previous === void 0 ? { current: join(runtimes, folders.current) } : {
3266
+ current: join(runtimes, folders.current),
3267
+ previous: join(runtimes, folders.previous)
3268
+ };
3269
+ }
3270
+ function unreadable$1(pointer, why) {
3271
+ 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." });
3272
+ }
3273
+ /** The pointer's runtime folders, or undefined when there is no pointer; refused when it is not one this writes. */
3274
+ async function readFolders(dataDir, name) {
3275
+ const { pointer } = launcherPaths(dataDir, name);
3276
+ let text;
3277
+ try {
3278
+ text = await readFile(pointer, "utf8");
3279
+ } catch (error) {
3280
+ if (error.code === "ENOENT") return void 0;
3281
+ throw unreadable$1(pointer, error.code ?? String(error));
3282
+ }
3283
+ const folders = parsePointer(text, name);
3284
+ if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
3285
+ return folders;
3286
+ }
3287
+ /**
3288
+ * The runtime a launcher starts, and the one before it; undefined when it has no pointer yet.
3289
+ *
3290
+ * A pointer that is there and is not one this writes is refused rather than taken for none: what it names may be in
3291
+ * use, and "no pointer" would let a prune or an update act as though nothing were.
3292
+ */
3293
+ async function readPointer(dataDir, name) {
3294
+ const folders = await readFolders(dataDir, name);
3295
+ return folders === void 0 ? void 0 : asPaths(dataDir, folders);
3296
+ }
3297
+ /** `readPointer`, for a reader that cannot wait: the registration readers, which the command locator calls. */
3298
+ function readPointerSync(dataDir, name) {
3299
+ const { pointer } = launcherPaths(dataDir, name);
3300
+ let text;
3301
+ try {
3302
+ text = readFileSync(pointer, "utf8");
3303
+ } catch (error) {
3304
+ if (error.code === "ENOENT") return void 0;
3305
+ throw unreadable$1(pointer, error.code ?? String(error));
3306
+ }
3307
+ const folders = parsePointer(text, name);
3308
+ if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
3309
+ return asPaths(dataDir, folders);
3310
+ }
3311
+ /** Whether the CLI a runtime holds for this launcher is a file that can be read: whether the launcher can start it. */
3312
+ async function launcherCanStart(runtimeDir, name) {
3313
+ const cli = launcherCli(runtimeDir, name);
3314
+ try {
3315
+ if (!(await stat(cli)).isFile()) return false;
3316
+ await access(cli, constants$1.R_OK);
3317
+ return true;
3318
+ } catch {
3319
+ return false;
3320
+ }
3321
+ }
3322
+ /** `launcherCanStart`, for a reader that cannot wait. */
3323
+ function launcherCanStartSync(runtimeDir, name) {
3324
+ const cli = launcherCli(runtimeDir, name);
3325
+ try {
3326
+ if (!statSync(cli).isFile()) return false;
3327
+ accessSync(cli, constants$1.R_OK);
3328
+ return true;
3329
+ } catch {
3330
+ return false;
3331
+ }
3332
+ }
3333
+ /** The folder of a runtime this launcher may be switched to, or why not. Nothing outside `<data>/runtime` is one. */
3334
+ async function switchableFolder(dataDir, name, runtimeDir) {
3335
+ const folder = basename(runtimeDir);
3336
+ 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`);
3337
+ 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." });
3338
+ return folder;
3339
+ }
3340
+ /** One change of a pointer at a time, across every process of this installation. */
3341
+ function withPointerLock(dataDir, name, change) {
3342
+ return withFileLock(join(dataDir, LAUNCH, `${checkedName(name)}${LOCK}`), change, {
3343
+ timeoutMs: 1e4,
3344
+ staleMs: 3e4
3345
+ });
3346
+ }
3347
+ /**
3348
+ * Points the launcher at `runtimeDir`, and returns the pointer it replaced (undefined when there was none).
3349
+ *
3350
+ * `switchPointer` without the answer; exported for an install or an update that may have to put it back
3351
+ * (`restorePointer`) and must know exactly what it was.
3352
+ */
3353
+ async function exchangePointer(dataDir, name, runtimeDir) {
3354
+ const folder = await switchableFolder(dataDir, name, runtimeDir);
3355
+ return withPointerLock(dataDir, name, async () => {
3356
+ const before = await readFolders(dataDir, name).catch(() => void 0);
3357
+ if (before?.current === folder) return asPaths(dataDir, before);
3358
+ await writeFileAtomic(launcherPaths(dataDir, name).pointer, pointerText(folder, before?.current));
3359
+ return before === void 0 ? void 0 : asPaths(dataDir, before);
3360
+ });
3361
+ }
3362
+ /**
3363
+ * Puts back the pointer a switch replaced (`exchangePointer`'s answer; undefined removes the pointer), only while the
3364
+ * pointer still names `expected` — what that switch pointed it at. Anything else means something switched it since,
3365
+ * and that newer pointer stays. Says whether it put it back.
3366
+ */
3367
+ async function restorePointer(dataDir, name, before, expected) {
3368
+ return withPointerLock(dataDir, name, async () => {
3369
+ const now = await readFolders(dataDir, name).catch(() => void 0);
3370
+ if (now === void 0 || now.current !== basename(expected)) return false;
3371
+ const { pointer } = launcherPaths(dataDir, name);
3372
+ if (before === void 0) await rm(pointer, { force: true });
3373
+ else await writeFileAtomic(pointer, pointerText(basename(before.current), before.previous === void 0 ? void 0 : basename(before.previous)));
3374
+ return true;
3375
+ });
3376
+ }
3377
+ function posixWord$1(word) {
3378
+ return `'${word.replaceAll("'", `'"'"'`)}'`;
3379
+ }
3380
+ /** The launcher for macOS and Linux: the shell's built-ins only, so nothing is looked for on PATH. */
3381
+ function posixSource(dataDir, name, node) {
3382
+ const runtimes = join(dataDir, RUNTIMES);
3383
+ const { pointer } = launcherPaths(dataDir, name);
3384
+ const record = fallbackRecordPath(dataDir, name);
3385
+ return `#!/bin/sh
3386
+ # The stable launcher of agent-communications for ${name}, format 1. Written by managed installs and updates; do not edit.
3387
+ # It starts the runtime the pointer beside it names, with the Node below. When that runtime's CLI is not there to
3388
+ # start, it starts the previous runtime instead, once, and records that beside the pointer. It never falls back from a
3389
+ # runtime that started, and never writes to stdout: an MCP client speaks there.
3390
+ node=${posixWord$1(node)}
3391
+ runtimes=${posixWord$1(runtimes)}
3392
+ pointer=${posixWord$1(pointer)}
3393
+ record=${posixWord$1(record)}
3394
+ cli=${posixWord$1(cliWords(name).join("/"))}
3395
+ suffix=${posixWord$1(`-${name}`)}
3396
+ ${LAUNCHER_ENV}=${posixWord$1(name)}
3397
+ export ${LAUNCHER_ENV}
3398
+ current=
3399
+ previous=
3400
+ if [ -f "$pointer" ] && [ -r "$pointer" ]; then
3401
+ while IFS= read -r line || [ -n "$line" ]; do
3402
+ case $line in
3403
+ current=*) [ -z "$current" ] && current=\${line#current=} ;;
3404
+ previous=*) [ -z "$previous" ] && previous=\${line#previous=} ;;
3405
+ esac
3406
+ done < "$pointer"
3407
+ fi
3408
+ startable() {
3409
+ case $1 in
3410
+ ''|.*|*[!0-9A-Za-z.+_-]*) return 1 ;;
3411
+ esac
3412
+ case $1 in
3413
+ *"$suffix") ;;
3414
+ *) return 1 ;;
3415
+ esac
3416
+ [ "$1" != "$suffix" ] && [ -f "$runtimes/$1/$cli" ] && [ -r "$runtimes/$1/$cli" ]
3417
+ }
3418
+ if startable "$current"; then
3419
+ exec "$node" "$runtimes/$current/$cli" "$@"
3420
+ fi
3421
+ if [ "$previous" != "$current" ] && startable "$previous"; then
3422
+ { printf 'from=%s\\n' "$current"; printf 'to=%s\\n' "$previous"; } 2>/dev/null > "$record"
3423
+ exec "$node" "$runtimes/$previous/$cli" "$@"
3424
+ fi
3425
+ 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
3426
+ exit 78
3427
+ `;
3428
+ }
3429
+ /**
3430
+ * The launcher for Windows: a CommonJS script Node runs — the registration starts the Node, and this is its first word —
3431
+ * which runs the runtime's CLI in the same process, so nothing re-parses the words and no second process is left behind
3432
+ * when the client stops the server. `argv[1]` is set to that CLI, as though Node had been started on it.
3433
+ */
3434
+ function windowsSource(dataDir, name, node) {
3435
+ const runtimes = join(dataDir, RUNTIMES);
3436
+ const { pointer } = launcherPaths(dataDir, name);
3437
+ const record = fallbackRecordPath(dataDir, name);
3438
+ const json = JSON.stringify;
3439
+ return `// The stable launcher of agent-communications for ${name}, format 1, for Windows. Written by managed installs and
3440
+ // updates; do not edit. Started by ${json(node)} with this file as its first word: it starts the runtime the pointer
3441
+ // beside it names, in this process. When that runtime's CLI is not there to start, it starts the previous runtime
3442
+ // instead, once, and records that beside the pointer. It never falls back from a runtime that started.
3443
+ 'use strict';
3444
+ const fs = require('node:fs');
3445
+ const path = require('node:path');
3446
+ const { pathToFileURL } = require('node:url');
3447
+ const runtimes = ${json(runtimes)};
3448
+ const pointer = ${json(pointer)};
3449
+ const record = ${json(record)};
3450
+ const cli = ${json(cliWords(name))};
3451
+ const suffix = ${json(`-${name}`)};
3452
+ let current = '';
3453
+ let previous = '';
3454
+ try {
3455
+ for (const line of fs.readFileSync(pointer, 'utf8').split(/\\r?\\n/)) {
3456
+ if (line.startsWith('current=') && current === '') current = line.slice(8);
3457
+ else if (line.startsWith('previous=') && previous === '') previous = line.slice(9);
3458
+ }
3459
+ } catch {}
3460
+ function startable(folder) {
3461
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(folder) || !folder.endsWith(suffix) || folder === suffix) return null;
3462
+ const entry = path.join(runtimes, folder, ...cli);
3463
+ try {
3464
+ if (!fs.statSync(entry).isFile()) return null;
3465
+ fs.accessSync(entry, fs.constants.R_OK);
3466
+ return entry;
3467
+ } catch {
3468
+ return null;
3469
+ }
3470
+ }
3471
+ let entry = startable(current);
3472
+ if (entry === null && previous !== current) {
3473
+ entry = startable(previous);
3474
+ if (entry !== null) {
3475
+ try {
3476
+ const temp = record + '.' + process.pid + '.tmp';
3477
+ fs.writeFileSync(temp, 'from=' + current + '\\nto=' + previous + '\\n');
3478
+ fs.renameSync(temp, record);
3479
+ } catch {}
3480
+ }
3481
+ }
3482
+ if (entry === null) {
3483
+ 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');
3484
+ process.exit(78);
3485
+ }
3486
+ process.argv[1] = entry;
3487
+ process.env.${LAUNCHER_ENV} = ${json(name)};
3488
+ import(pathToFileURL(entry).href).catch((error) => {
3489
+ process.exitCode = 1;
3490
+ console.error(error);
3491
+ });
3492
+ `;
3493
+ }
3494
+ /**
3495
+ * Writes the launcher `<data>/launch/<name>`: a temporary file, then a rename, so a launcher starting meanwhile runs
3496
+ * the old one or the new, whole. It starts the runtime its pointer names with `node` — given as the registration and
3497
+ * the shim name it, a link left a link (CUE-492) — and falls back once to `previous` when that runtime cannot start
3498
+ * (see the top of this module).
3499
+ *
3500
+ * POSIX: a shell script, executable by its owner alone. Windows (`platform`): a script for Node, with a `package.json`
3501
+ * beside it saying it is CommonJS, whatever a folder above the data directory says; the registration starts `node`.
3502
+ */
3503
+ async function writeLauncher(dataDir, name, node, options = {}) {
3504
+ const platform = options.platform ?? process.platform;
3505
+ const { launcher } = launcherPaths(dataDir, name);
3506
+ 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)}`);
3507
+ const data = resolve(dataDir);
3508
+ if (platform === "win32") {
3509
+ await writeFileAtomic(join(dirname(launcher), "package.json"), `${JSON.stringify({ type: "commonjs" })}\n`);
3510
+ await writeFileAtomic(launcher, windowsSource(data, name, node));
3511
+ return;
3512
+ }
3513
+ await writeFileAtomic(launcher, posixSource(data, name, node), 448);
3514
+ }
3515
+ /** Whether the launcher file is there. */
3516
+ async function launcherExists(dataDir, name) {
3517
+ try {
3518
+ return (await stat(launcherPaths(dataDir, name).launcher)).isFile();
3519
+ } catch {
3520
+ return false;
3521
+ }
3522
+ }
3523
+ /** The `--data-dir` a registration pins, in either form, before any `--`. */
3524
+ function dataDirPin(args) {
3525
+ for (let index = 0; index < args.length; index += 1) {
3526
+ const word = args[index];
3527
+ if (word === "--") return void 0;
3528
+ if (word === "--data-dir") return args[index + 1] === "--" ? void 0 : args[index + 1] || void 0;
3529
+ if (word.startsWith("--data-dir=")) return word.slice(11) || void 0;
3530
+ }
3531
+ }
3532
+ /** A path as words to compare: separators made one, a trailing one dropped, without case on Windows. */
3533
+ function comparable(path, platform) {
3534
+ const plain = path.replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/$/, "");
3535
+ return platform === "win32" ? plain.toLowerCase() : plain;
3536
+ }
3537
+ /**
3538
+ * The stable launcher a registered entry starts, read from its own words, or null when it starts none.
3539
+ *
3540
+ * Ours only when the launcher is in the very data directory the entry itself pins with `--data-dir` — the program on
3541
+ * POSIX, the first word after Node on Windows (`platform`) — named like one: a program called `launch/gmail` anywhere
3542
+ * else is somebody else's. Nothing is read from disk.
3543
+ */
3544
+ function registeredLauncher(server, platform) {
3545
+ const dataDir = dataDirPin(server.args);
3546
+ if (dataDir === void 0) return null;
3547
+ const directory = `${comparable(dataDir, platform)}/${LAUNCH}`;
3548
+ for (const candidate of [server.command, server.args[0]]) {
3549
+ if (candidate === void 0) continue;
3550
+ const words = comparable(candidate, platform);
3551
+ if (!words.startsWith(`${directory}/`)) continue;
3552
+ const name = candidate.split(/[\\/]+/).at(-1) ?? "";
3553
+ if (words !== `${directory}/${comparable(name, platform)}` || !NAME.test(name)) continue;
3554
+ return {
3555
+ dataDir,
3556
+ name,
3557
+ launcher: candidate
3558
+ };
3559
+ }
3560
+ return null;
3561
+ }
3562
+ /**
3563
+ * The programs this package starts, named so that no folder it happens to be in can stand in for them.
3564
+ *
3565
+ * A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
3566
+ * so does every program that looks a name up with `SearchPath` or `CreateProcess`. The current folder is where a
3567
+ * command was run or a server was started — and, since a download asks where to save, one of the three places a
3568
+ * stranger's file may be saved into. A `reg.exe` or `rundll32.exe` saved there would then be what runs the next time
3569
+ * this package reads the registry or opens a browser. So a Windows program is named by its full path, under the Windows
3570
+ * folder, and every process started on Windows is told, by `NoDefaultCurrentDirectoryInExePath`, not to look in its
3571
+ * own current folder for the programs it starts in turn.
3572
+ */
3573
+ /** An environment variable by name, whatever its case: Windows's are case-blind, an object passed in is not. */
3574
+ function envValue(env, name) {
3575
+ const wanted = name.toLowerCase();
3576
+ for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === wanted && value) return value;
3577
+ }
3578
+ /**
3579
+ * The Windows folder: `%SystemRoot%` (or `%windir%`) when it names a folder on a drive, and `C:\Windows` otherwise.
3580
+ * A value that is not a drive's absolute path — relative, a share, a device path — is not taken: it would be looked up
3581
+ * against the current folder, or on another machine, which is what naming the program in full is to avoid.
3582
+ */
3583
+ function windowsFolder(env = process.env) {
3584
+ for (const name of ["SystemRoot", "windir"]) {
3585
+ const value = envValue(env, name);
3586
+ if (value !== void 0 && /^[A-Za-z]:[\\/]/.test(value)) return path.win32.resolve(value);
3587
+ }
3588
+ return "C:\\Windows";
3589
+ }
3590
+ /** A program of Windows's own by its full path: `%SystemRoot%\System32\<name>`, never a bare name to look up. */
3591
+ function windowsSystemProgram(name, env = process.env) {
3592
+ return path.win32.join(windowsFolder(env), "System32", name);
3593
+ }
3594
+ /**
3595
+ * The environment a child process is started with: `env` as it is, and on Windows also
3596
+ * `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
3597
+ * program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
3598
+ * when `PATH` says to.
3599
+ */
3600
+ function childEnvironment(env = process.env, platform = process.platform) {
3601
+ if (platform !== "win32") return env;
3602
+ return {
3603
+ ...env,
3604
+ NoDefaultCurrentDirectoryInExePath: "1"
3605
+ };
3606
+ }
3607
+ /**
3608
+ * The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
3609
+ * entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
3610
+ * found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
3611
+ */
3612
+ function absoluteSearchPath(entries, platform = process.platform) {
3613
+ return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
3614
+ }
3615
+ /** This Node's real file, or its path as reported when that cannot be resolved. */
3616
+ function realFile(path) {
3617
+ try {
3618
+ return realpathSync(path);
3619
+ } catch {
3620
+ return path;
3621
+ }
3622
+ }
3623
+ /** Two real paths are one file: compared without case on Windows, whose paths are. */
3624
+ function sameRealPath(a, b, platform) {
3625
+ return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
3626
+ }
3627
+ /** The names `node` is found by in one folder of PATH, as a shell finds it: with each PATHEXT extension on Windows. */
3628
+ function nodeNames(env, platform) {
3629
+ if (platform !== "win32") return ["node"];
3630
+ return (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean).map((extension) => `node${extension.toLowerCase()}`);
3631
+ }
3632
+ /**
3633
+ * The path a command names this Node by: the first `node` on `env`'s PATH that is the same real file as
3634
+ * `process.execPath`, as PATH spells it; otherwise `process.execPath`. With no `env`, nothing is looked for.
3635
+ *
3636
+ * Read when a command is located, as everything else in it is (D6): a link removed or repointed later is what the
3637
+ * command then meets. Only absolute folders of PATH are read, as the registrations' own search reads them.
3638
+ */
3639
+ function nodeProgram(env, platform = process.platform) {
3640
+ const own = process.execPath;
3641
+ if (env === void 0) return own;
3642
+ const real = realFile(own);
3643
+ const search = env.PATH ?? env.Path ?? "";
3644
+ for (const directory of absoluteSearchPath(search.split(delimiter), platform)) for (const name of nodeNames(env, platform)) {
3645
+ const candidate = join(directory, name);
3646
+ try {
3647
+ if (!statSync(candidate).isFile()) continue;
3648
+ accessSync(candidate, constants$1.X_OK);
3649
+ if (!sameRealPath(realpathSync(candidate), real, platform)) continue;
3650
+ return sameRealPath(candidate, own, platform) ? own : candidate;
3651
+ } catch {}
3652
+ }
3653
+ return own;
3654
+ }
2971
3655
  const RECEIPT_VERSION = 1;
2972
3656
  const RECEIPT_FILE = "managed-shims.json";
2973
3657
  const MUTATION_LOCK_FILE = "managed-shims.lock";
2974
3658
  const POSIX_SHIM_MODE = 493;
2975
3659
  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.";
2976
- function platformOf(context) {
3660
+ function platformOf$1(context) {
2977
3661
  return context.platform ?? process.platform;
2978
3662
  }
2979
3663
  /** Why the selected platform has no managed shim in this release. */
2980
3664
  function managedShimUnsupportedNotice(context = {}) {
2981
- 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.";
3665
+ 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.";
2982
3666
  }
2983
3667
  /** The per-user directory managed installs put their command shims in. */
2984
3668
  function managedShimDirectory(context) {
@@ -2987,7 +3671,7 @@ function managedShimDirectory(context) {
2987
3671
  }
2988
3672
  /** Managed CLI shims are POSIX-only until Windows has an argument-forwarding form that does not re-parse input. */
2989
3673
  function managedShimsSupported(context = {}) {
2990
- const platform = platformOf(context);
3674
+ const platform = platformOf$1(context);
2991
3675
  return platform === "darwin" || platform === "linux";
2992
3676
  }
2993
3677
  /** A managed shim's name: one plain command word, never a path, so joining it to the directory stays inside it. */
@@ -3171,7 +3855,7 @@ function normalPath(path, platform) {
3171
3855
  return platform === "win32" ? trimmed.toLowerCase() : trimmed;
3172
3856
  }
3173
3857
  function directoryOnPath(context, directory) {
3174
- const platform = platformOf(context);
3858
+ const platform = platformOf$1(context);
3175
3859
  const value = context.env.PATH ?? context.env.Path ?? "";
3176
3860
  const separator = platform === "win32" ? ";" : ":";
3177
3861
  return value.split(separator).filter(Boolean).some((part) => normalPath(part, platform) === normalPath(directory, platform));
@@ -3305,59 +3989,6 @@ async function removeManagedShimsForRuntimes(context, runtimes, options = {}) {
3305
3989
  };
3306
3990
  });
3307
3991
  }
3308
- /**
3309
- * The programs this package starts, named so that no folder it happens to be in can stand in for them.
3310
- *
3311
- * A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
3312
- * so does every program that looks a name up with `SearchPath` or `CreateProcess`. The current folder is where a
3313
- * command was run or a server was started — and, since a download asks where to save, one of the three places a
3314
- * stranger's file may be saved into. A `reg.exe` or `rundll32.exe` saved there would then be what runs the next time
3315
- * this package reads the registry or opens a browser. So a Windows program is named by its full path, under the Windows
3316
- * folder, and every process started on Windows is told, by `NoDefaultCurrentDirectoryInExePath`, not to look in its
3317
- * own current folder for the programs it starts in turn.
3318
- */
3319
- /** An environment variable by name, whatever its case: Windows's are case-blind, an object passed in is not. */
3320
- function envValue(env, name) {
3321
- const wanted = name.toLowerCase();
3322
- for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === wanted && value) return value;
3323
- }
3324
- /**
3325
- * The Windows folder: `%SystemRoot%` (or `%windir%`) when it names a folder on a drive, and `C:\Windows` otherwise.
3326
- * A value that is not a drive's absolute path — relative, a share, a device path — is not taken: it would be looked up
3327
- * against the current folder, or on another machine, which is what naming the program in full is to avoid.
3328
- */
3329
- function windowsFolder(env = process.env) {
3330
- for (const name of ["SystemRoot", "windir"]) {
3331
- const value = envValue(env, name);
3332
- if (value !== void 0 && /^[A-Za-z]:[\\/]/.test(value)) return path.win32.resolve(value);
3333
- }
3334
- return "C:\\Windows";
3335
- }
3336
- /** A program of Windows's own by its full path: `%SystemRoot%\System32\<name>`, never a bare name to look up. */
3337
- function windowsSystemProgram(name, env = process.env) {
3338
- return path.win32.join(windowsFolder(env), "System32", name);
3339
- }
3340
- /**
3341
- * The environment a child process is started with: `env` as it is, and on Windows also
3342
- * `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
3343
- * program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
3344
- * when `PATH` says to.
3345
- */
3346
- function childEnvironment(env = process.env, platform = process.platform) {
3347
- if (platform !== "win32") return env;
3348
- return {
3349
- ...env,
3350
- NoDefaultCurrentDirectoryInExePath: "1"
3351
- };
3352
- }
3353
- /**
3354
- * The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
3355
- * entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
3356
- * found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
3357
- */
3358
- function absoluteSearchPath(entries, platform = process.platform) {
3359
- return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
3360
- }
3361
3992
  /** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
3362
3993
  const PIN_OPTIONS = Object.freeze([
3363
3994
  "account",
@@ -3539,6 +4170,8 @@ function pinnedVersion(argument, product, platform) {
3539
4170
  */
3540
4171
  function isProductServer(server, product, platform) {
3541
4172
  const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
4173
+ const stable = registeredLauncher(server, platform);
4174
+ if (stable !== null) return fold(stable.name) === fold(unscoped(product.packageName));
3542
4175
  const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
3543
4176
  if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
3544
4177
  if (server.packageName) {
@@ -3606,12 +4239,14 @@ async function installManagedRuntime(context, product, version) {
3606
4239
  private: true
3607
4240
  }, null, 2)}\n`);
3608
4241
  const npmCli = await findNpmCli();
4242
+ const prefix = await realpath(root);
3609
4243
  await run(context.env, process.execPath, [
3610
4244
  npmCli,
3611
4245
  "install",
3612
4246
  "--prefix",
3613
- root,
4247
+ prefix,
3614
4248
  "--save-exact",
4249
+ "--ignore-scripts",
3615
4250
  "--no-audit",
3616
4251
  "--no-fund",
3617
4252
  `${product.packageName}@${version}`
@@ -3700,40 +4335,49 @@ async function buildEntry(context, product, options, apply, resolved) {
3700
4335
  runtimeMissing: false
3701
4336
  };
3702
4337
  }
4338
+ const { dataDir } = context.core.paths;
4339
+ const entry = {
4340
+ ...managedEntryShape(context, product, node, [
4341
+ ...pathArgs,
4342
+ "mcp",
4343
+ ...serverArgs
4344
+ ]),
4345
+ env
4346
+ };
3703
4347
  if (!apply) {
3704
- const { dataDir } = context.core.paths;
4348
+ const name = launcherName(product.packageName);
3705
4349
  const ready = await reusableRuntime(dataDir, product.packageName, product.version);
4350
+ const pointer = await readPointer(dataDir, name).catch(() => void 0);
3706
4351
  return {
3707
- entry: {
3708
- command: node,
3709
- args: [
3710
- ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
3711
- ...pathArgs,
3712
- "mcp",
3713
- ...serverArgs
3714
- ],
3715
- env
3716
- },
4352
+ entry,
3717
4353
  launcher,
3718
- runtimeMissing: ready === null
4354
+ runtimeMissing: !(ready !== null && await launcherExists(dataDir, name) && pointer?.current === managedRuntimeDir(dataDir, product.packageName, product.version))
3719
4355
  };
3720
4356
  }
4357
+ await installManagedRuntime(context, product, product.version);
3721
4358
  return {
3722
- entry: {
3723
- command: node,
3724
- args: [
3725
- await installManagedRuntime(context, product, product.version),
3726
- ...pathArgs,
3727
- "mcp",
3728
- ...serverArgs
3729
- ],
3730
- env
3731
- },
4359
+ entry,
3732
4360
  launcher,
3733
4361
  runtimeMissing: false
3734
4362
  };
3735
4363
  }
3736
4364
  /**
4365
+ * Points this product's stable launcher at the runtime of the release being installed — every registration of it on
4366
+ * this machine starts that from its next start — and writes the launcher afresh, with the Node the preview named.
4367
+ * Returns how to put the pointer back (`restorePointer`) if what comes after does not land.
4368
+ */
4369
+ async function pointLauncher(context, product, node) {
4370
+ const { dataDir } = context.core.paths;
4371
+ const name = launcherName(product.packageName);
4372
+ const runtime = managedRuntimeDir(dataDir, product.packageName, product.version);
4373
+ await writeLauncher(dataDir, name, node, { platform: platformOf(context) });
4374
+ return {
4375
+ name,
4376
+ runtime,
4377
+ before: await exchangePointer(dataDir, name, runtime)
4378
+ };
4379
+ }
4380
+ /**
3737
4381
  * The calling package's own CLI entry: `src/cli.ts` from source, `dist/cli.mjs` when bundled.
3738
4382
  *
3739
4383
  * `moduleUrl` comes from the product, not from this file. While this code lived inside the Gmail package,
@@ -4072,8 +4716,9 @@ async function preflightInstall(context, product, options) {
4072
4716
  if (reported) previous = claimName(product, options, name, [reported], context.platform);
4073
4717
  }
4074
4718
  const { options: effective, kept } = keepNarrowing(product, options, previous);
4075
- const node = await resolveNode(context.env);
4076
- const command = (options.launcher ?? "managed") === "npx" ? await resolveNpx(context.env, node) : node;
4719
+ const launcher = options.launcher ?? "managed";
4720
+ const node = launcher === "managed" ? nodeProgram(context.env, platformOf(context)) : await resolveNode(context.env);
4721
+ const command = launcher === "npx" ? await resolveNpx(context.env, node) : launcher === "managed" ? managedEntryShape(context, product, node, []).command : node;
4077
4722
  return {
4078
4723
  scan,
4079
4724
  target,
@@ -4084,6 +4729,27 @@ async function preflightInstall(context, product, options) {
4084
4729
  command
4085
4730
  };
4086
4731
  }
4732
+ function platformOf(context) {
4733
+ return context.platform ?? process.platform;
4734
+ }
4735
+ /** A product's stable launcher in this installation's data directory: `<data>/launch/<name>`. */
4736
+ function productLauncher(dataDir, product) {
4737
+ return launcherPaths(dataDir, launcherName(product.packageName)).launcher;
4738
+ }
4739
+ /**
4740
+ * A managed entry: the stable launcher with `words`. On POSIX it is the program; on Windows Node is, with the launcher
4741
+ * as its first word, since a client starts no script there without a shell (see `launcher.ts`).
4742
+ */
4743
+ function managedEntryShape(context, product, node, words) {
4744
+ const launcher = productLauncher(context.core.paths.dataDir, product);
4745
+ return platformOf(context) === "win32" ? {
4746
+ command: node,
4747
+ args: [launcher, ...words]
4748
+ } : {
4749
+ command: launcher,
4750
+ args: [...words]
4751
+ };
4752
+ }
4087
4753
  /**
4088
4754
  * The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
4089
4755
  *
@@ -4237,109 +4903,116 @@ async function mcpInstall(context, product, options, planned) {
4237
4903
  let method = "printed";
4238
4904
  let applied = false;
4239
4905
  let notApplied;
4240
- if (cliName && cliPath && writes) {
4241
- const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
4242
- const args = options.client === "claude-code" ? [
4243
- "mcp",
4244
- "add-json",
4245
- name,
4246
- JSON.stringify(entry),
4247
- "--scope",
4248
- "user"
4249
- ] : [
4250
- "mcp",
4251
- "add",
4252
- name,
4253
- ...codexEnv(entry.env),
4254
- "--",
4255
- entry.command,
4256
- ...entry.args
4257
- ];
4258
- const replacing = previous.find((server) => server.client === options.client);
4259
- if (replacing) {
4260
- const removal = options.client === "claude-code" ? [
4906
+ const pointed = launcher === "managed" && apply ? await pointLauncher(context, product, preflight.node) : null;
4907
+ const unpoint = async () => pointed !== null && await restorePointer(context.core.paths.dataDir, pointed.name, pointed.before, pointed.runtime).catch(() => false);
4908
+ try {
4909
+ if (cliName && cliPath && writes) {
4910
+ const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
4911
+ const args = options.client === "claude-code" ? [
4261
4912
  "mcp",
4262
- "remove",
4913
+ "add-json",
4263
4914
  name,
4915
+ JSON.stringify(entry),
4264
4916
  "--scope",
4265
4917
  "user"
4266
4918
  ] : [
4267
4919
  "mcp",
4268
- "remove",
4269
- name
4920
+ "add",
4921
+ name,
4922
+ ...codexEnv(entry.env),
4923
+ "--",
4924
+ entry.command,
4925
+ ...entry.args
4270
4926
  ];
4271
- try {
4272
- await run(context.env, cliPath, removal);
4273
- } catch (error) {
4274
- const message = error instanceof CommsError ? error.message : String(error);
4275
- if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4276
- }
4277
- try {
4278
- await run(context.env, cliPath, args);
4279
- } catch (error) {
4280
- const restore = options.client === "claude-code" ? [
4927
+ const replacing = previous.find((server) => server.client === options.client);
4928
+ if (replacing) {
4929
+ const removal = options.client === "claude-code" ? [
4281
4930
  "mcp",
4282
- "add-json",
4931
+ "remove",
4283
4932
  name,
4284
- JSON.stringify({
4285
- command: replacing.command,
4286
- args: replacing.args,
4287
- ...replacing.env ? { env: replacing.env } : {}
4288
- }),
4289
4933
  "--scope",
4290
4934
  "user"
4291
4935
  ] : [
4292
4936
  "mcp",
4293
- "add",
4294
- name,
4295
- ...codexEnv(replacing.env),
4296
- "--",
4297
- replacing.command,
4298
- ...replacing.args
4937
+ "remove",
4938
+ name
4299
4939
  ];
4300
- let restored = true;
4301
4940
  try {
4302
- await run(context.env, cliPath, restore);
4303
- } catch {
4304
- restored = false;
4941
+ await run(context.env, cliPath, removal);
4942
+ } catch (error) {
4943
+ const message = error instanceof CommsError ? error.message : String(error);
4944
+ if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4945
+ }
4946
+ try {
4947
+ await run(context.env, cliPath, args);
4948
+ } catch (error) {
4949
+ const restore = options.client === "claude-code" ? [
4950
+ "mcp",
4951
+ "add-json",
4952
+ name,
4953
+ JSON.stringify({
4954
+ command: replacing.command,
4955
+ args: replacing.args,
4956
+ ...replacing.env ? { env: replacing.env } : {}
4957
+ }),
4958
+ "--scope",
4959
+ "user"
4960
+ ] : [
4961
+ "mcp",
4962
+ "add",
4963
+ name,
4964
+ ...codexEnv(replacing.env),
4965
+ "--",
4966
+ replacing.command,
4967
+ ...replacing.args
4968
+ ];
4969
+ let restored = true;
4970
+ try {
4971
+ await run(context.env, cliPath, restore);
4972
+ } catch {
4973
+ restored = false;
4974
+ }
4975
+ if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
4976
+ hint: "Check the client is not running, then try again.",
4977
+ cause: error
4978
+ });
4979
+ throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
4980
+ binary: product.binary,
4981
+ words: installWords(product, effective, name, []),
4982
+ say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
4983
+ }, { cause: error });
4305
4984
  }
4306
- if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
4307
- hint: "Check the client is not running, then try again.",
4985
+ } else try {
4986
+ await run(context.env, cliPath, args);
4987
+ } catch (error) {
4988
+ const message = error instanceof CommsError ? error.message : String(error);
4989
+ if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
4990
+ hint: `Look at it with ${clientCommandHint([
4991
+ cliName,
4992
+ "mcp",
4993
+ "get",
4994
+ name
4995
+ ], 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([
4996
+ cliName,
4997
+ "mcp",
4998
+ "remove",
4999
+ name
5000
+ ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
4308
5001
  cause: error
4309
5002
  });
4310
- throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
4311
- binary: product.binary,
4312
- words: installWords(product, effective, name, []),
4313
- say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
4314
- }, { cause: error });
5003
+ throw error;
4315
5004
  }
4316
- } else try {
4317
- await run(context.env, cliPath, args);
4318
- } catch (error) {
4319
- const message = error instanceof CommsError ? error.message : String(error);
4320
- if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
4321
- hint: `Look at it with ${clientCommandHint([
4322
- cliName,
4323
- "mcp",
4324
- "get",
4325
- name
4326
- ], 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([
4327
- cliName,
4328
- "mcp",
4329
- "remove",
4330
- name
4331
- ], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
4332
- cause: error
4333
- });
4334
- throw error;
5005
+ method = "cli";
5006
+ applied = true;
5007
+ } else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
5008
+ else if (configPath && writes) {
5009
+ await mergeIntoJsonConfig(configPath, options.client, name, entry);
5010
+ method = "file";
5011
+ applied = true;
4335
5012
  }
4336
- method = "cli";
4337
- applied = true;
4338
- } else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
4339
- else if (configPath && writes) {
4340
- await mergeIntoJsonConfig(configPath, options.client, name, entry);
4341
- method = "file";
4342
- applied = true;
5013
+ } catch (error) {
5014
+ await unpoint();
5015
+ throw error;
4343
5016
  }
4344
5017
  if (record && !applied) try {
4345
5018
  await appendPrivateLine(ledger, JSON.stringify(record));
@@ -4351,12 +5024,13 @@ async function mcpInstall(context, product, options, planned) {
4351
5024
  let verification = "skipped";
4352
5025
  let verifyDetail;
4353
5026
  if (!options.noVerify) {
4354
- if (runtimeMissing) verifyDetail = "the managed runtime is not installed, and --print installs nothing";
5027
+ if (runtimeMissing) verifyDetail = "the stable launcher does not start this release's managed runtime yet, and --print installs nothing";
4355
5028
  else {
4356
5029
  const check = await verifyEntry(entry, product);
4357
5030
  verified = check.ok;
4358
5031
  verification = check.ok ? "passed" : "failed";
4359
5032
  verifyDetail = check.detail;
5033
+ 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`;
4360
5034
  }
4361
5035
  }
4362
5036
  return {
@@ -4595,11 +5269,25 @@ async function pruneManagedRuntimes(context, product, options = {}) {
4595
5269
  const registered = scan.servers;
4596
5270
  const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
4597
5271
  const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
5272
+ const name = launcherName(product.packageName);
5273
+ let pointer;
5274
+ try {
5275
+ pointer = await readPointer(context.core.paths.dataDir, name);
5276
+ } catch {
5277
+ const at = launcherPaths(context.core.paths.dataDir, name).pointer;
5278
+ return refuse(`${at} could not be read, so none of these can be shown not to be what the stable launcher starts`);
5279
+ }
5280
+ const pointed = (aliases) => {
5281
+ const named = (runtime) => runtime !== void 0 && aliases.some((alias) => resolve(alias) === resolve(runtime));
5282
+ if (named(pointer?.current)) return "the stable launcher starts it";
5283
+ if (named(pointer?.previous)) return "the stable launcher's previous runtime: what it falls back to, and what a rollback goes back to";
5284
+ return null;
5285
+ };
4598
5286
  const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
4599
5287
  for (const { path, version, aliases } of candidates) {
4600
5288
  const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
4601
5289
  const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
4602
- 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;
5290
+ const reason = path === current ? "this release" : pointed(aliases) ?? (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);
4603
5291
  if (reason) {
4604
5292
  result.kept.push({
4605
5293
  path,
@@ -4945,62 +5633,6 @@ function connectMailboxWords() {
4945
5633
  */
4946
5634
  const CONVERSION_HOOKS = Symbol("agentcomms.configConversionHooks");
4947
5635
  /**
4948
- * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
4949
- * and the preview renderer (which makes them visible), so the two can never disagree.
4950
- */
4951
- /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
4952
- const INVISIBLE_RANGES = [
4953
- [173, 173],
4954
- [847, 847],
4955
- [1564, 1564],
4956
- [4447, 4448],
4957
- [6068, 6069],
4958
- [6155, 6159],
4959
- [8203, 8207],
4960
- [8232, 8233],
4961
- [8234, 8238],
4962
- [8288, 8292],
4963
- [8294, 8303],
4964
- [12644, 12644],
4965
- [65024, 65039],
4966
- [65279, 65279],
4967
- [65440, 65440],
4968
- [917504, 917631]
4969
- ];
4970
- function isInvisible(codePoint) {
4971
- for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
4972
- return false;
4973
- }
4974
- /**
4975
- * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
4976
- * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
4977
- */
4978
- function isControl(codePoint) {
4979
- if (codePoint === 9 || codePoint === 10) return false;
4980
- return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
4981
- }
4982
- function isDangerous(codePoint) {
4983
- return isControl(codePoint) || isInvisible(codePoint);
4984
- }
4985
- /**
4986
- * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
4987
- * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
4988
- *
4989
- * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
4990
- * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
4991
- * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
4992
- */
4993
- function stripInvisible(text) {
4994
- let removed = 0;
4995
- let out = "";
4996
- for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
4997
- else out += char;
4998
- return {
4999
- text: out,
5000
- removed
5001
- };
5002
- }
5003
- /**
5004
5636
  * Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
5005
5637
  * drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
5006
5638
  * current when it is read, and `C:outgoing` is relative to that drive's current folder; either would move with the
@@ -5151,6 +5783,19 @@ const READABLE_CONFIG_VERSIONS = [
5151
5783
  ];
5152
5784
  const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
5153
5785
  const ALIAS_MESSAGE = "names must be 1–32 lowercase letters, digits or hyphens, starting with a letter or digit";
5786
+ /**
5787
+ * How this machine updates (design 2026-10-16, U2), from the most updating to the least: `auto` — the default — checks
5788
+ * daily and installs in the background once its job is set up; `every-14-days` checks at most once every 14 days and
5789
+ * installs only with the person's yes; `never` asks the registry nothing.
5790
+ */
5791
+ const UPDATE_LEVELS = [
5792
+ "auto",
5793
+ "every-14-days",
5794
+ "never"
5795
+ ];
5796
+ function isUpdateLevel(value) {
5797
+ return typeof value === "string" && UPDATE_LEVELS.includes(value);
5798
+ }
5154
5799
  /** Which naming a configuration's body has: 1 — plain words — or 2 — `organisation/platform`. */
5155
5800
  function namingOf(config) {
5156
5801
  return config.version === 3 ? config.naming : config.version;
@@ -5261,7 +5906,8 @@ const defaultsSchema = looseObject({
5261
5906
  downloadsDir: string().optional(),
5262
5907
  timezone: string().default("system"),
5263
5908
  confirm: looseObject({ elicitationClients: array(string()).default([]) }).default({ elicitationClients: [] }),
5264
- updateCheck: _enum(["on", "off"]).optional()
5909
+ updateCheck: _enum(["on", "off"]).optional(),
5910
+ updates: string().min(1).optional()
5265
5911
  });
5266
5912
  const RESERVED_ALIASES = /* @__PURE__ */ new Set(["all"]);
5267
5913
  /** Within one map, a reserved alias or a duplicate id is a hard error in every version. */
@@ -5335,6 +5981,10 @@ const slackAppRecordSchema = looseObject({
5335
5981
  clientId: string().min(1),
5336
5982
  appId: string().min(1).optional()
5337
5983
  });
5984
+ const agentAppRecordSchema = looseObject({
5985
+ appId: string().min(1),
5986
+ sha256: string().min(1)
5987
+ });
5338
5988
  const organisationRecordSchema = looseObject({
5339
5989
  label: string(),
5340
5990
  source: looseObject({
@@ -5355,8 +6005,14 @@ const organisationRecordSchema = looseObject({
5355
6005
  redirectPort: number().int().min(1).max(65535),
5356
6006
  apps: looseObject({
5357
6007
  read: slackAppRecordSchema.optional(),
5358
- send: slackAppRecordSchema.optional()
6008
+ send: slackAppRecordSchema.optional(),
6009
+ agent: agentAppRecordSchema.optional()
5359
6010
  }).default({})
6011
+ }).optional(),
6012
+ updates: looseObject({
6013
+ max: string().min(1).optional(),
6014
+ background: boolean().optional(),
6015
+ sha256: string().min(1)
5360
6016
  }).optional()
5361
6017
  });
5362
6018
  const formerNameSchema = looseObject({
@@ -5538,10 +6194,30 @@ function governingChangePolicy(config, change) {
5538
6194
  };
5539
6195
  return [byId(change.target?.id), ...change.loosened.map((loosening) => byId(loosening.id))].includes("confirm") ? "confirm" : "chat";
5540
6196
  }
5541
- /** Whether this machine's daily update check is on: `defaults.updateCheck`, absent reading as `on`. */
6197
+ /** 0.18's switch, as written: `defaults.updateCheck`, absent reading as `on`. */
5542
6198
  function updateCheckSetting(config) {
5543
6199
  return config.defaults.updateCheck ?? "on";
5544
6200
  }
6201
+ /**
6202
+ * The level this machine's configuration sets (design 2026-10-16, U2): `defaults.updates`, else 0.18's switch read as a
6203
+ * level — `on` as `auto`, `off` as `never`. A word this release does not know reads as `auto`, the default.
6204
+ */
6205
+ function updateLevelOf(config) {
6206
+ const set = config.defaults.updates;
6207
+ if (set !== void 0) return isUpdateLevel(set) ? set : "auto";
6208
+ return updateCheckSetting(config) === "off" ? "never" : "auto";
6209
+ }
6210
+ /**
6211
+ * How much a level updates, for `classifyChange`: `auto` 2, `every-14-days` 1, `never` 0. A word this release does not
6212
+ * 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
6213
+ * mode is — an unknown word can cost a person a question, never skip one.
6214
+ */
6215
+ function updateRank(config, side) {
6216
+ const set = config.defaults.updates;
6217
+ const level = set === void 0 ? updateLevelOf(config) : isUpdateLevel(set) ? set : null;
6218
+ if (level === null) return side === "before" ? UPDATE_LEVELS.length - 1 : 0;
6219
+ return UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
6220
+ }
5545
6221
  /** `naming` is the body's: 2 for `organisation/platform` names (version 2, or version 3 over it), 1 for plain ones. */
5546
6222
  function describeIssues(error, naming) {
5547
6223
  return error.issues.slice(0, 5).map((issue) => {
@@ -6098,7 +6774,8 @@ function classifyChange(before, after) {
6098
6774
  if (POLICY_RANK$1[a.sendPolicy] < POLICY_RANK$1[b.sendPolicy]) loosen("defaults.sendPolicy", b.sendPolicy, a.sendPolicy);
6099
6775
  if (POLICY_RANK$1[defaultChangePolicy(after)] < POLICY_RANK$1[defaultChangePolicy(before)]) loosen("defaults.changePolicy", defaultChangePolicy(before), defaultChangePolicy(after));
6100
6776
  if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
6101
- if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
6777
+ 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));
6778
+ else if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
6102
6779
  if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
6103
6780
  const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
6104
6781
  const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
@@ -6114,11 +6791,43 @@ function classifyChange(before, after) {
6114
6791
  if (before.secrets === void 0 && after.secrets !== void 0 && after.secrets.store !== "keychain") {
6115
6792
  if (holdsSecrets(before)) loosen("secrets.store", secretsStoreOf(before), after.secrets.store);
6116
6793
  }
6794
+ for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was), labelSettingValue(loosening, loosening.now));
6117
6795
  return {
6118
6796
  loosened: changes.map((change) => change.path),
6119
6797
  changes
6120
6798
  };
6121
6799
  }
6800
+ /**
6801
+ * One label rule in words, as a preview lists it: `acme/slack in C0ACME for post/reply: me`. No comma inside, since a
6802
+ * preview joins a list of them with commas.
6803
+ */
6804
+ function agentLabelRuleText(rule) {
6805
+ const where = rule.conversation === void 0 ? "" : ` in ${rule.conversation}`;
6806
+ const kinds = rule.kinds === void 0 ? "" : ` for ${rule.kinds.join("/")}`;
6807
+ return `${rule.account}${where}${kinds}: ${rule.as}`;
6808
+ }
6809
+ /**
6810
+ * A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles as they are, the
6811
+ * rest as text.
6812
+ */
6813
+ function labelSettingValue(loosening, value) {
6814
+ if (value === void 0 || value === null) return void 0;
6815
+ if (loosening.path === "agentLabel.rules") return value.map(agentLabelRuleText);
6816
+ if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") return { ...value };
6817
+ return String(value);
6818
+ }
6819
+ /** The label settings a change can write, as the file holds them — raw, as `changedSettings` records every setting. */
6820
+ const LABEL_SETTINGS = [
6821
+ "word",
6822
+ "name",
6823
+ "rules",
6824
+ "slackIcons",
6825
+ "slackStyles"
6826
+ ];
6827
+ function storedAgentLabel(config) {
6828
+ const stored = config.agentLabel;
6829
+ return typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : {};
6830
+ }
6122
6831
  /** The settings of a mailbox, and of an account, that a change can write — the ones the classifier judges. */
6123
6832
  const INBOX_SETTINGS = [
6124
6833
  "sendPolicy",
@@ -6132,7 +6841,7 @@ const ACCOUNT_SETTINGS = [
6132
6841
  ];
6133
6842
  /**
6134
6843
  * Every setting that differs between `before` and `after`, loosened or tightened: the defaults, the secret store,
6135
- * and each mailbox's and account's own settings — including those of one added or removed.
6844
+ * the agent label's settings, and each mailbox's and account's own settings — including those of one added or removed.
6136
6845
  *
6137
6846
  * What a change approval binds, beside its loosenings. A preview shows the whole change — "sends approved by never;
6138
6847
  * changes approved by chat" — and binding only the loosenings let a claim drop the tightening a person read, since
@@ -6170,6 +6879,9 @@ function changedSettings(before, after) {
6170
6879
  const defaultsAfter = after.defaults;
6171
6880
  for (const key of [.../* @__PURE__ */ new Set([...Object.keys(defaultsBefore), ...Object.keys(defaultsAfter)])].sort()) record(`defaults.${key}`, defaultsBefore[key], defaultsAfter[key]);
6172
6881
  record("secrets.store", before.secrets?.store, after.secrets?.store);
6882
+ const labelBefore = storedAgentLabel(before);
6883
+ const labelAfter = storedAgentLabel(after);
6884
+ for (const key of LABEL_SETTINGS) record(`agentLabel.${key}`, labelBefore[key], labelAfter[key]);
6173
6885
  return changes;
6174
6886
  }
6175
6887
  /**
@@ -6973,11 +7685,15 @@ var ApprovalStore = class {
6973
7685
  #now;
6974
7686
  #handoffs;
6975
7687
  #loadConfig;
7688
+ /** `ApprovalStoreOptions.answered`, never throwing. */
7689
+ answered;
6976
7690
  constructor(stateDir, options = {}) {
6977
7691
  this.directory = join(stateDir, "approvals");
6978
7692
  this.#handoffs = options.handoffs;
6979
7693
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
6980
7694
  this.#loadConfig = options.loadConfig;
7695
+ const answered = options.answered;
7696
+ this.answered = answered === void 0 ? void 0 : (approvalId) => answered(approvalId).catch(() => void 0);
6981
7697
  registerStoreInternals(this, {
6982
7698
  directory: this.directory,
6983
7699
  now: () => this.#now(),
@@ -7566,6 +8282,7 @@ var ApprovalStore = class {
7566
8282
  }, { usedSaid: options.usedSaid });
7567
8283
  const failed = failure;
7568
8284
  if (failed) throw refusedAfter(refusalFor(done.record)(failed.code, failed.reason, done.record), done);
8285
+ if (kind === "send" && done.record.state === "approved") await this.answered?.(approvalId);
7569
8286
  return done.record;
7570
8287
  }
7571
8288
  /**
@@ -7615,6 +8332,7 @@ var ApprovalStore = class {
7615
8332
  if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
7616
8333
  const claimToken = randomBytes(16).toString("hex");
7617
8334
  await this.#markClaimed(done, claimToken);
8335
+ await this.answered?.(approvalId);
7618
8336
  return {
7619
8337
  record: done.record,
7620
8338
  claimToken,
@@ -9248,6 +9966,7 @@ function registrationFactsOf({ packageName, manifest }) {
9248
9966
  /** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
9249
9967
  function launcherOf(server, facts, platform) {
9250
9968
  const fold = folding(platform);
9969
+ if (stableLauncherOf(server, facts, platform) !== null) return "managed";
9251
9970
  const parts = [server.command, ...server.args];
9252
9971
  if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
9253
9972
  if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
@@ -9255,10 +9974,49 @@ function launcherOf(server, facts, platform) {
9255
9974
  if (parts.some((part) => local.test(part))) return "local";
9256
9975
  return "other";
9257
9976
  }
9258
- /** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
9977
+ /**
9978
+ * The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none.
9979
+ *
9980
+ * An entry that starts the stable launcher names no version: it runs the release its launcher's pointer names, read
9981
+ * here — null when the pointer is not there, cannot be read, or names a folder that is not a release.
9982
+ */
9259
9983
  function registrationVersion(server, facts, platform) {
9984
+ const stable = stableLauncherOf({
9985
+ command: server.command ?? "",
9986
+ args: server.args
9987
+ }, facts, platform);
9988
+ if (stable !== null) {
9989
+ const current = pointedRuntime(stable.dataDir, stable.name);
9990
+ return current === null ? null : launcherRuntimeVersion(current, stable.name);
9991
+ }
9260
9992
  return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
9261
9993
  }
9994
+ /**
9995
+ * The stable launcher of this product an entry starts, in the data directory the entry pins, or null: see
9996
+ * `registeredLauncher`. Another product's launcher is not this one's.
9997
+ */
9998
+ function stableLauncherOf(server, facts, platform) {
9999
+ const stable = registeredLauncher(server, platform);
10000
+ if (stable === null) return null;
10001
+ let own;
10002
+ try {
10003
+ own = launcherName(facts.packageName);
10004
+ } catch {
10005
+ return null;
10006
+ }
10007
+ return folding(platform)(stable.name) === folding(platform)(own) ? {
10008
+ ...stable,
10009
+ name: own
10010
+ } : null;
10011
+ }
10012
+ /** The runtime a launcher's pointer names as current, or null when it has none that can be read. */
10013
+ function pointedRuntime(dataDir, name) {
10014
+ try {
10015
+ return readPointerSync(dataDir, name)?.current ?? null;
10016
+ } catch {
10017
+ return null;
10018
+ }
10019
+ }
9262
10020
  PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
9263
10021
  /**
9264
10022
  * Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
@@ -9266,6 +10024,7 @@ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
9266
10024
  * A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
9267
10025
  */
9268
10026
  function startsProduct(server, facts, platform) {
10027
+ if (registeredLauncher(server, platform) !== null) return stableLauncherOf(server, facts, platform) !== null;
9269
10028
  const { words: args } = withoutPathOptions(server.args);
9270
10029
  const packageName = commandLinePackage(server.command, args);
9271
10030
  return isProductServer({
@@ -9291,6 +10050,24 @@ function isNpxRegistration(server, facts, platform) {
9291
10050
  * program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
9292
10051
  */
9293
10052
  function registrationFiles(server, facts, platform) {
10053
+ const { packageName } = facts;
10054
+ const stable = packageName === void 0 ? null : stableLauncherOf(server, { packageName }, platform);
10055
+ if (stable !== null) {
10056
+ let pointer;
10057
+ try {
10058
+ pointer = readPointerSync(stable.dataDir, stable.name);
10059
+ } catch {
10060
+ return {
10061
+ scripts: [],
10062
+ command: null
10063
+ };
10064
+ }
10065
+ const runtime = pointer === void 0 ? void 0 : launcherCanStartSync(pointer.current, stable.name) || pointer.previous === void 0 ? pointer.current : pointer.previous;
10066
+ return {
10067
+ scripts: runtime === void 0 ? [] : [launcherCli(runtime, stable.name)],
10068
+ command: null
10069
+ };
10070
+ }
9294
10071
  const { words } = withoutPathOptions(server.args);
9295
10072
  const sentinel = words.indexOf("--");
9296
10073
  const options = sentinel < 0 ? words : words.slice(0, sentinel);
@@ -9304,7 +10081,7 @@ function folding(platform) {
9304
10081
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
9305
10082
  }
9306
10083
  /** The package version, read from package.json at build time. */
9307
- const VERSION = "0.18.2";
10084
+ const VERSION = "0.20.0";
9308
10085
  /**
9309
10086
  * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
9310
10087
  * to decide whether the release it last heard of is newer than the one running.
@@ -9375,8 +10152,13 @@ function isBehind(version, latest) {
9375
10152
  * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
9376
10153
  * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
9377
10154
  */
9378
- function isPrerelease(version) {
9379
- return VERSION_PATTERN.exec(version)?.[4] !== void 0;
10155
+ /**
10156
+ * A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
10157
+ * `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
10158
+ * text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
10159
+ */
10160
+ function isPlainVersion(value) {
10161
+ return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
9380
10162
  }
9381
10163
  /**
9382
10164
  * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
@@ -9482,8 +10264,10 @@ const CHANNEL_PACKAGES = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manif
9482
10264
  /**
9483
10265
  * Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
9484
10266
  *
9485
- * The program is always this process's Node (`process.execPath`), which must satisfy the target's `engines.node`; no
9486
- * interpreter named anywhere else is run, or asked its version. A TypeScript entry gets `--experimental-strip-types`,
10267
+ * The program is always this process's Node, which must satisfy the target's `engines.node`; no interpreter named
10268
+ * anywhere else is run, or asked its version. It is named as PATH finds it — the first `node` on `request.env`'s PATH
10269
+ * that is the same real file as `process.execPath` — and otherwise by `process.execPath` (CUE-492): the real file only
10270
+ * identifies it, and `process.version` is that file's. A TypeScript entry gets `--experimental-strip-types`,
9487
10271
  * which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
9488
10272
  * no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
9489
10273
  * CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
@@ -9524,6 +10308,7 @@ function locateCliCommand(request, runtime = {
9524
10308
  return fail("caller", `${nearest.root} cannot be resolved`);
9525
10309
  }
9526
10310
  if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
10311
+ const program = nodeProgram(request.env);
9527
10312
  const pins = {};
9528
10313
  for (const key of request.uses) pins[key] = request.paths[key];
9529
10314
  let words;
@@ -9532,7 +10317,7 @@ function locateCliCommand(request, runtime = {
9532
10317
  } catch (error) {
9533
10318
  return fail("arguments", error instanceof Error ? error.message : String(error));
9534
10319
  }
9535
- const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
10320
+ const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform, program);
9536
10321
  const located = (built, found, direction) => Object.freeze({
9537
10322
  ok: true,
9538
10323
  command: built.command,
@@ -9672,7 +10457,7 @@ function locateCliCommand(request, runtime = {
9672
10457
  * Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
9673
10458
  * CLI or the manifest's bin for the command it declares — as a readable file inside its package.
9674
10459
  */
9675
- function buildCommand(target, found, words, runtime, callerSource, platform) {
10460
+ function buildCommand(target, found, words, runtime, callerSource, platform, program) {
9676
10461
  const engines = found.manifest.engines;
9677
10462
  const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
9678
10463
  if (typeof range !== "string") return {
@@ -9709,7 +10494,7 @@ function buildCommand(target, found, words, runtime, callerSource, platform) {
9709
10494
  return {
9710
10495
  ok: true,
9711
10496
  command: new PrintedCommand(GATE, [
9712
- process.execPath,
10497
+ program,
9713
10498
  ...flags,
9714
10499
  checked.path,
9715
10500
  ...words
@@ -10013,7 +10798,8 @@ function cliHandoffs(options) {
10013
10798
  uses: usesOf(use),
10014
10799
  paths,
10015
10800
  platform,
10016
- ...registrations === void 0 ? {} : { registrations }
10801
+ ...registrations === void 0 ? {} : { registrations },
10802
+ ...options.env === void 0 ? {} : { env: options.env }
10017
10803
  }, ...options.runtime === void 0 ? [] : [options.runtime]);
10018
10804
  return result.ok ? result.command : result;
10019
10805
  };
@@ -10546,7 +11332,13 @@ const SETTING_LABELS = {
10546
11332
  "defaults.downloadsDir": "downloads folder",
10547
11333
  "defaults.confirm.elicitationClients": "clients trusted to ask for a send approval",
10548
11334
  "defaults.updateCheck": "daily update check",
10549
- "secrets.store": "where credentials are kept"
11335
+ "defaults.updates": "how this machine updates",
11336
+ "secrets.store": "where credentials are kept",
11337
+ "agentLabel.word": "the word after your name in the agent label",
11338
+ "agentLabel.name": "the name in the agent label",
11339
+ "agentLabel.rules": "who each kind of message appears to come from",
11340
+ "agentLabel.slackIcons": "the icon of the agent’s Slack posts",
11341
+ "agentLabel.slackStyles": "how the agent’s Slack messages look"
10550
11342
  };
10551
11343
  /**
10552
11344
  * What the loosening means for the person, in their terms.
@@ -10569,8 +11361,14 @@ function meaning(field, after, scope) {
10569
11361
  case "defaults.attachDeny": return "files the removed entries protected can be attached";
10570
11362
  case "defaults.downloadsDir": return "files from other people will be saved here";
10571
11363
  case "defaults.confirm.elicitationClients": return "these clients may ask you to approve a send in their own window";
10572
- case "defaults.updateCheck": return "nothing on this machine will ask npm for a newer release, or stop until one is installed";
11364
+ case "defaults.updateCheck": return "a release before 0.20 on this machine will no longer ask npm for a newer release";
11365
+ 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";
10573
11366
  case "secrets.store": return "credentials will move out of the system keychain into files on this disk";
11367
+ case "agentLabel.word": return "what an agent sends will carry this word after your name";
11368
+ 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";
11369
+ 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";
11370
+ case "agentLabel.slackIcons": return "the app’s Slack posts will show this icon beside your name";
11371
+ 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";
10574
11372
  default: return "";
10575
11373
  }
10576
11374
  }
@@ -10712,7 +11510,7 @@ async function gatedChangeAtTerminal(core, change, options) {
10712
11510
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
10713
11511
  } else if (options.answered !== true) {
10714
11512
  streams.stdout.write(`${prepared.preview}\n\n`);
10715
- if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
11513
+ if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
10716
11514
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
10717
11515
  surface: "cli",
10718
11516
  disposition: "person"
@@ -10777,7 +11575,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
10777
11575
  }
10778
11576
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
10779
11577
  streams.stdout.write(`${prompt.preview}\n\n`);
10780
- const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
11578
+ const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
10781
11579
  if (!answer.trim()) {
10782
11580
  await revokeChange(core, approvalId, "cancelled at the terminal", {
10783
11581
  surface: "cli",
@@ -10797,7 +11595,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
10797
11595
  function cancelled() {
10798
11596
  return new CommsError("USAGE", "cancelled: nothing was changed");
10799
11597
  }
10800
- async function askLine$1(streams, question) {
11598
+ async function askLine(streams, question) {
10801
11599
  const { createInterface } = await import("node:readline/promises");
10802
11600
  const rl = createInterface({
10803
11601
  input: streams.stdin,
@@ -11163,80 +11961,6 @@ var InboxStateStore = class {
11163
11961
  });
11164
11962
  }
11165
11963
  };
11166
- function openCore(options = {}) {
11167
- const { paths, pathOverrides } = resolvePathIdentity(options);
11168
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
11169
- const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
11170
- caller: options.caller,
11171
- paths,
11172
- platform: options.platform,
11173
- env: options.env
11174
- });
11175
- const config = new ConfigStore(paths.configDir, { handoffs });
11176
- const audit = new AuditLog(paths.stateDir, now);
11177
- let cached = null;
11178
- return {
11179
- paths,
11180
- pathOverrides,
11181
- config,
11182
- states: new InboxStateStore(paths.stateDir),
11183
- approvals: new ApprovalStore(paths.stateDir, {
11184
- now,
11185
- handoffs,
11186
- loadConfig: () => config.load(),
11187
- audit
11188
- }),
11189
- ledger: new SendLedger(paths.stateDir, now),
11190
- plans: new PlanStore(paths.stateDir, now),
11191
- taint: new TaintStore(paths.stateDir, now),
11192
- audit,
11193
- handoffs,
11194
- async secrets(kind) {
11195
- const chosen = kind ?? secretsStoreOf(await config.load());
11196
- if (cached?.kind === chosen) return cached.store;
11197
- const store = await openSecretStore(chosen, {
11198
- secretsDir: paths.secretsDir,
11199
- namespace: keychainNamespace(paths.configDir),
11200
- handoffs
11201
- });
11202
- cached = {
11203
- kind: chosen,
11204
- store
11205
- };
11206
- return store;
11207
- }
11208
- };
11209
- }
11210
- /**
11211
- * A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
11212
- * whole — and `NaN` for anything else.
11213
- *
11214
- * `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
11215
- * 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
11216
- * hold exactly is not one either: it would be used as some other number.
11217
- */
11218
- function readWholeNumber(raw) {
11219
- const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
11220
- return Number.isSafeInteger(value) ? value : NaN;
11221
- }
11222
- /**
11223
- * A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
11224
- * names the option and its range.
11225
- *
11226
- * One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
11227
- * operations those commands share with their tools, so a number the command refuses is refused by the tool in the
11228
- * same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
11229
- */
11230
- function wholeNumber(raw, rule) {
11231
- if (raw === void 0) return void 0;
11232
- const value = readWholeNumber(raw);
11233
- if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
11234
- const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
11235
- const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
11236
- throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
11237
- }
11238
- return value;
11239
- }
11240
11964
  /**
11241
11965
  * A Google client id as an organisation profile has to write it (design 2026-10-02 §D2): the project number, a hyphen,
11242
11966
  * the client's own part, and Google's suffix — and nothing else.
@@ -11307,6 +12031,142 @@ strictObject({
11307
12031
  })
11308
12032
  }).optional()
11309
12033
  });
12034
+ /**
12035
+ * A string that came from a profile, as it is shown anywhere: chat-template tokens, role markers and envelope look-alikes
12036
+ * neutralised (`neutralise`), every control and invisible character made visible, tabs and line breaks flattened to a
12037
+ * space, and cut to `width`. `neutralise` keeps line feeds and tabs on purpose — they are legitimate in a message body —
12038
+ * so the flattening is what keeps a label from printing a second line of a preview.
12039
+ */
12040
+ function shownText(value, width = 120) {
12041
+ return truncateDisplay(neutralise(typeof value === "string" ? value : String(value ?? "")).text, width);
12042
+ }
12043
+ /**
12044
+ * A profile's path as it is shown — in a preview, an error, a command's output: neutralised and on one line, as every
12045
+ * other string a person did not write themselves. The path is chosen by whoever named the file — a repository, a
12046
+ * message with an attachment — and its name is text like any other.
12047
+ */
12048
+ function shownPath(path) {
12049
+ return shownText(path, 400);
12050
+ }
12051
+ /**
12052
+ * The bytes at an absolute path, in one bounded read: open once, check it is a regular file, read at most one byte more
12053
+ * than `PROFILE_MAX_BYTES` from the same handle. A path somebody typed can name a FIFO that never ends, a device, a
12054
+ * directory; none of them is a profile, and none may be how a command hangs. A symlink is followed: the path is one the
12055
+ * person chose. `unopened` says what a file that cannot be opened is: a refusal for the profile, whatever the reason;
12056
+ * nothing for a file beside it (`readSideFile`) when it is not there, and a refusal when it is there and cannot be
12057
+ * opened.
12058
+ */
12059
+ async function readBounded(path, what) {
12060
+ let handle;
12061
+ try {
12062
+ handle = await open(path, constants$1.O_RDONLY | (constants$1.O_NONBLOCK ?? 0));
12063
+ } catch (error) {
12064
+ return what.unopened(error);
12065
+ }
12066
+ try {
12067
+ if (!(await handle.stat()).isFile()) throw new CommsError("USAGE", `${shownPath(path)} is not a file`);
12068
+ const buffer = Buffer.allocUnsafe(65537);
12069
+ const { bytesRead } = await handle.read(buffer, 0, 65537, 0);
12070
+ 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.` });
12071
+ return Buffer.from(buffer.subarray(0, bytesRead));
12072
+ } catch (error) {
12073
+ if (error instanceof CommsError) throw error;
12074
+ throw new CommsError("NOT_FOUND", `${what.noun} at ${shownPath(path)} could not be read`, { cause: error });
12075
+ } finally {
12076
+ await handle.close();
12077
+ }
12078
+ }
12079
+ /** The most symbolic links followed from a profile's path to its file: as many as the system itself follows. */
12080
+ const LINK_HOPS = 40;
12081
+ /**
12082
+ * The file a profile's path names, through every symbolic link at its end: the path itself when it is not a link, or
12083
+ * cannot be looked at — what reading it then finds is the bounded read's to say. A link's target is read against the
12084
+ * 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
12085
+ * folder whatever they resolve to.
12086
+ *
12087
+ * What the files beside a profile are looked for beside (`readProfileFile`): an organisation keeps them beside its
12088
+ * profile, in its own checkout, and a person who links to that profile from somewhere else has linked to the profile
12089
+ * alone. Looked for beside the link, a caps file is not there, and the caps it names would be lifted.
12090
+ */
12091
+ function profileTarget(path) {
12092
+ let at = path;
12093
+ for (let hops = 0; hops < LINK_HOPS; hops += 1) {
12094
+ let link;
12095
+ try {
12096
+ if (!lstatSync(at).isSymbolicLink()) return at;
12097
+ link = readlinkSync(at);
12098
+ } catch {
12099
+ return at;
12100
+ }
12101
+ at = resolve(dirname(at), link);
12102
+ }
12103
+ 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." });
12104
+ }
12105
+ /** A side file's path for a profile: in the same folder, the profile's name with `.<suffix>.json` for its `.json`. */
12106
+ function sidePathFor(profilePath, suffix) {
12107
+ return /\.json$/i.test(profilePath) ? `${profilePath.slice(0, -5)}.${suffix}.json` : `${profilePath}.${suffix}.json`;
12108
+ }
12109
+ /**
12110
+ * Reads one file beside a profile, if there is one, and holds it to the profile: the same organisation, and whatever
12111
+ * else `agrees` asks. Read in one bounded read as the profile is (`readBounded`), strict as the profile is, and refused
12112
+ * in the same words — naming each problem by where it is, never by the value found there — but naming this file, so
12113
+ * 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
12114
+ * opened is refused, since read as none it would lift what it says — the caps, or the one app `workspace bot` takes.
12115
+ */
12116
+ async function readSideFile(path, profile, side) {
12117
+ const where = shownPath(path);
12118
+ const bytes = await readBounded(path, {
12119
+ unopened: (error) => {
12120
+ if (error.code === "ENOENT") return null;
12121
+ throw new CommsError("NOT_FOUND", `${side.noun} at ${where} could not be opened`, { cause: error });
12122
+ },
12123
+ noun: side.noun,
12124
+ size: side.size
12125
+ });
12126
+ if (bytes === null) return void 0;
12127
+ const refuse = (problem) => {
12128
+ throw new CommsError("BAD_DATA", `${side.noun} ${where} is not valid: ${problem}`, { hint: side.hint });
12129
+ };
12130
+ let text;
12131
+ try {
12132
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
12133
+ } catch {
12134
+ return refuse("it is not UTF-8 text");
12135
+ }
12136
+ let raw;
12137
+ try {
12138
+ raw = JSON.parse(text.startsWith("") ? text.slice(1) : text);
12139
+ } catch {
12140
+ return refuse("it is not valid JSON");
12141
+ }
12142
+ const parsed = side.schema.safeParse(raw);
12143
+ if (!parsed.success) return refuse(parsed.error.issues.slice(0, 6).map((issue) => {
12144
+ const at = issue.path.length > 0 ? issue.path.map(String).join(".") : "the file";
12145
+ 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}`;
12146
+ }).join("; "));
12147
+ const document = parsed.data;
12148
+ if (document.organisation !== profile.organisation) return refuse(`it names the organisation ${shownText(document.organisation, 40)}, not ${profile.organisation}, whose profile it sits beside`);
12149
+ const problem = side.agrees?.(document, profile);
12150
+ if (problem !== void 0) return refuse(problem);
12151
+ return {
12152
+ path,
12153
+ sha256: createHash("sha256").update(bytes).digest("hex"),
12154
+ document
12155
+ };
12156
+ }
12157
+ strictObject({
12158
+ agentcomms: literal("organisation-agent-app"),
12159
+ version: literal(1),
12160
+ organisation: string(),
12161
+ slack: strictObject({
12162
+ workspace: string().regex(SLACK_WORKSPACE_ID_PATTERN, "a Slack workspace id is T followed by capital letters or digits"),
12163
+ appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits")
12164
+ })
12165
+ });
12166
+ /** The organisation profiles this configuration records — none on version 1, which cannot hold them. */
12167
+ function organisationsOf(config) {
12168
+ return hasNames(config) ? config.organisations ?? {} : {};
12169
+ }
11310
12170
  strictObject({
11311
12171
  organisation: string().refine((word) => parseOrganisation(word) !== null, "an organisation is a name’s first half"),
11312
12172
  role: _enum(["read", "send"]),
@@ -11319,6 +12179,802 @@ strictObject({
11319
12179
  sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
11320
12180
  });
11321
12181
  /**
12182
+ * An organisation's update caps (design 2026-10-16, U8): the most its members' machines may update — `max`,
12183
+ * `every-14-days` or `never` — and `background: false`, no background job. They only ever tighten: a member's level is
12184
+ * capped at `max`, and nothing a member sets goes past it.
12185
+ *
12186
+ * **They are named in a file beside the profile, not in it** (`readSideFile`): a key such as `updates` in the profile
12187
+ * would make every release from 0.13.0 to 0.19.0 refuse it. They go in `<profile>.updates.json`, recorded as the
12188
+ * organisation's `updates`.
12189
+ */
12190
+ const UPDATE_CAPS = ["every-14-days", "never"];
12191
+ /** The updates file for a profile: in the same folder, the profile's name with `.updates.json` for its `.json`. */
12192
+ function updatesPathFor(profilePath) {
12193
+ return sidePathFor(profilePath, "updates");
12194
+ }
12195
+ const updatesSchema = strictObject({
12196
+ organisation: string(),
12197
+ max: _enum(UPDATE_CAPS, { message: "max is every-14-days or never: a cap only ever makes updates less frequent" }).optional(),
12198
+ background: literal(false, { message: "background is false or left out: a cap can only turn background updates off" }).optional()
12199
+ });
12200
+ /** The updates file, held to the profile's organisation (`readSideFile`). */
12201
+ async function readUpdatesFile(path, profile) {
12202
+ const read = await readSideFile(path, profile, {
12203
+ noun: "the updates file",
12204
+ size: "an updates file",
12205
+ schema: updatesSchema,
12206
+ 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."
12207
+ });
12208
+ if (read === void 0) return void 0;
12209
+ const { max, background } = read.document;
12210
+ return {
12211
+ path: read.path,
12212
+ sha256: read.sha256,
12213
+ ...max === void 0 ? {} : { max },
12214
+ ...background === void 0 ? {} : { background: false }
12215
+ };
12216
+ }
12217
+ /**
12218
+ * The caps file beside a profile, read with no profile to hold it to but the organisation's word: what stands in for
12219
+ * the caps the configuration records when `config.json` cannot be read (`effectiveUpdates`, failing closed). Beside the
12220
+ * file the path names (`profileTarget`), in the same bounded, strict read, held to the same organisation. Refused as
12221
+ * `readProfileFile` refuses it; undefined when there is none.
12222
+ */
12223
+ async function readUpdatesBeside(profilePath, organisation) {
12224
+ return readUpdatesFile(updatesPathFor(profileTarget(profilePath)), { organisation });
12225
+ }
12226
+ /** The systemd units' and the Windows task's name. */
12227
+ const UPDATE_JOB_NAME = "agentcomms-update";
12228
+ Object.freeze(["update", "--background"]);
12229
+ function updateJobSupported(platform) {
12230
+ return platform === "darwin" || platform === "linux" || platform === "win32";
12231
+ }
12232
+ function updateJobMarkerPath(stateDir) {
12233
+ return join(stateDir, "update-job.json");
12234
+ }
12235
+ const isTime$1 = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
12236
+ /** The record, or null when there is none — or none this release can read, which is treated as none. */
12237
+ async function readUpdateJobMarker(stateDir) {
12238
+ let parsed;
12239
+ try {
12240
+ parsed = JSON.parse(await readFile(updateJobMarkerPath(stateDir), "utf8"));
12241
+ } catch {
12242
+ return null;
12243
+ }
12244
+ const record = parsed;
12245
+ if (typeof record !== "object" || record === null || !updateJobSupported(String(record.platform)) || typeof record.installedAt !== "string" || typeof record.label !== "string") return null;
12246
+ return {
12247
+ platform: record.platform,
12248
+ installedAt: record.installedAt,
12249
+ label: record.label,
12250
+ ...isTime$1(record.lastRun) ? { lastRun: record.lastRun } : {}
12251
+ };
12252
+ }
12253
+ function within$1(at, now, intervalMs) {
12254
+ if (at === void 0 || !isTime$1(at)) return false;
12255
+ const since = now.getTime() - Date.parse(at);
12256
+ return since >= -3e5 && since < intervalMs;
12257
+ }
12258
+ /** The job's health, from its record; null when there is none (`readUpdateJobMarker`). */
12259
+ async function updateJobHealth(stateDir, now = /* @__PURE__ */ new Date()) {
12260
+ const marker = await readUpdateJobMarker(stateDir);
12261
+ if (marker === null) return null;
12262
+ return {
12263
+ marker,
12264
+ working: within$1(marker.lastRun, now, 1728e5) || within$1(marker.installedAt, now, 1728e5),
12265
+ since: marker.lastRun ?? marker.installedAt
12266
+ };
12267
+ }
12268
+ Object.freeze(["/usr/bin", "/bin"]);
12269
+ /** A PowerShell single-quoted string: nothing inside is special but a single quote — the curly ones too — doubled. */
12270
+ function powerShellLiteral(text) {
12271
+ return `'${text.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
12272
+ }
12273
+ `${powerShellLiteral(UPDATE_JOB_NAME)}`;
12274
+ Object.freeze(["every-14-days", "never"]);
12275
+ /**
12276
+ * The daily update check, as every server and command reads it (design 2026-09-28, and 2026-10-16 for the notice that
12277
+ * replaced its stop): one file per machine, `update-check.json` in the state directory, saying when the registry was
12278
+ * last asked, the latest release it named, whether this machine's registrations were behind it, any "not now" with when
12279
+ * it runs out, and when a command last said so.
12280
+ *
12281
+ * This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
12282
+ * Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
12283
+ * imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
12284
+ * that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
12285
+ */
12286
+ /** The file, in the state directory: one per machine, shared by every server and command on it. */
12287
+ const UPDATE_CHECK_FILE = "update-check.json";
12288
+ /**
12289
+ * The switch that turns the whole thing off for one process — no check, no notice — whatever the setting says. Every
12290
+ * test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
12291
+ * real registry.
12292
+ */
12293
+ const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
12294
+ /**
12295
+ * The level for one process, whatever the machine's (design 2026-10-16, U2): `never` switches it all off as
12296
+ * `AGENT_COMMS_UPDATE_CHECK=off` does. Any other word leaves the machine's level as it is.
12297
+ */
12298
+ const UPDATES_ENV = "AGENT_COMMS_UPDATES";
12299
+ /**
12300
+ * Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
12301
+ * extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
12302
+ * and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its notice always says
12303
+ * where it is updated, whatever the channel's registrations say: restarting would start the release the plugin pins.
12304
+ */
12305
+ const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
12306
+ const DAY_MS = 864e5;
12307
+ /** How long a check is good for on `auto`: the registry is asked at most once in this long, by the whole machine. */
12308
+ const UPDATE_CHECK_INTERVAL_MS = DAY_MS;
12309
+ /** How long a check, and a notice, is good for on `every-14-days`. */
12310
+ const SPACED_INTERVAL_MS = 14 * DAY_MS;
12311
+ Object.freeze([
12312
+ 1,
12313
+ 7,
12314
+ 14
12315
+ ]);
12316
+ function readSecurityShown(value) {
12317
+ if (typeof value !== "object" || value === null) return null;
12318
+ const { version, approvals } = value;
12319
+ if (!isPlainVersion(version) || !Array.isArray(approvals)) return null;
12320
+ return {
12321
+ version,
12322
+ approvals: approvals.filter((id) => typeof id === "string" && APPROVAL_ID_PATTERN.test(id)).slice(-32)
12323
+ };
12324
+ }
12325
+ const NOTICE_OUTCOMES = [
12326
+ "installed",
12327
+ "skipped",
12328
+ "refused",
12329
+ "failed",
12330
+ "job-removed"
12331
+ ];
12332
+ function readBackgroundNotice(value) {
12333
+ if (typeof value !== "object" || value === null) return null;
12334
+ const { at, outcome, version, text } = value;
12335
+ if (!isTime(at) || !NOTICE_OUTCOMES.includes(outcome) || typeof text !== "string") return null;
12336
+ return {
12337
+ at,
12338
+ outcome,
12339
+ version: isVersion(version) ? version : null,
12340
+ text: text.slice(0, 600)
12341
+ };
12342
+ }
12343
+ const EMPTY_UPDATE_CHECK = Object.freeze({
12344
+ lastChecked: null,
12345
+ latest: null,
12346
+ behind: null,
12347
+ current: null,
12348
+ lastError: null,
12349
+ snoozedUntil: null,
12350
+ checking: null,
12351
+ commandNoticeAt: null,
12352
+ noticeAt: null,
12353
+ pausedAt: null,
12354
+ pausedUntil: null,
12355
+ release: null,
12356
+ securityAsked: null,
12357
+ securityShown: null,
12358
+ backgroundNotice: null,
12359
+ moves: null
12360
+ });
12361
+ function updateCheckPath(stateDir) {
12362
+ return join(stateDir, UPDATE_CHECK_FILE);
12363
+ }
12364
+ const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
12365
+ const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
12366
+ function readRelease(value) {
12367
+ if (typeof value !== "object" || value === null) return null;
12368
+ const { version, security, needsDecision } = value;
12369
+ if (!isPlainVersion(version)) return null;
12370
+ return {
12371
+ version,
12372
+ security: security === true,
12373
+ needsDecision: needsDecision === true
12374
+ };
12375
+ }
12376
+ function readCurrent(value) {
12377
+ if (typeof value !== "object" || value === null) return null;
12378
+ const { registered, global } = value;
12379
+ return isWords(registered) && isWords(global) ? {
12380
+ registered: [...registered],
12381
+ global: [...global]
12382
+ } : null;
12383
+ }
12384
+ /**
12385
+ * The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
12386
+ * as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
12387
+ */
12388
+ async function readUpdateCheck(stateDir) {
12389
+ let parsed;
12390
+ try {
12391
+ parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
12392
+ } catch {
12393
+ return { ...EMPTY_UPDATE_CHECK };
12394
+ }
12395
+ const held = typeof parsed === "object" && parsed !== null ? parsed : {};
12396
+ return {
12397
+ lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
12398
+ latest: isPlainVersion(held.latest) ? held.latest : null,
12399
+ behind: typeof held.behind === "boolean" ? held.behind : null,
12400
+ current: readCurrent(held.current),
12401
+ lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
12402
+ snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
12403
+ checking: isTime(held.checking) ? held.checking : null,
12404
+ commandNoticeAt: isTime(held.commandNoticeAt) ? held.commandNoticeAt : null,
12405
+ noticeAt: isTime(held.noticeAt) ? held.noticeAt : null,
12406
+ pausedAt: isTime(held.pausedAt) ? held.pausedAt : null,
12407
+ pausedUntil: isTime(held.pausedUntil) ? held.pausedUntil : null,
12408
+ release: readRelease(held.release),
12409
+ securityAsked: isPlainVersion(held.securityAsked) ? held.securityAsked : null,
12410
+ securityShown: readSecurityShown(held.securityShown),
12411
+ backgroundNotice: readBackgroundNotice(held.backgroundNotice),
12412
+ moves: typeof held.moves === "number" && Number.isInteger(held.moves) && held.moves >= 0 ? held.moves : null
12413
+ };
12414
+ }
12415
+ /**
12416
+ * Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
12417
+ * and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
12418
+ * alongside the record written; `change` returning `null` writes nothing.
12419
+ */
12420
+ async function changeUpdateCheck(stateDir, change) {
12421
+ return withFileLock(join(stateDir, ".update-check.lock"), async () => {
12422
+ const current = await readUpdateCheck(stateDir);
12423
+ const next = change(current);
12424
+ if (next === null) return {
12425
+ record: current,
12426
+ result: void 0,
12427
+ written: false
12428
+ };
12429
+ await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
12430
+ return {
12431
+ record: next.record,
12432
+ result: next.result,
12433
+ written: true
12434
+ };
12435
+ }, { timeoutMs: 2e3 });
12436
+ }
12437
+ /**
12438
+ * Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
12439
+ * minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
12440
+ * moments, and a check just claimed by one must not look due to the other.
12441
+ */
12442
+ function updateCheckDue(record, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
12443
+ return !within(record.lastChecked, now, intervalMs);
12444
+ }
12445
+ /** Whether `at` is less than `intervalMs` ago — a time from more than a few minutes in the future is not trusted. */
12446
+ function within(at, now, intervalMs) {
12447
+ if (at === null) return false;
12448
+ const since = now.getTime() - Date.parse(at);
12449
+ return since >= -CLOCK_SKEW_MS && since < intervalMs;
12450
+ }
12451
+ const CLOCK_SKEW_MS = 3e5;
12452
+ /** What turned updates off for this process, from its environment: `CI`, or a switch. Null when nothing did. */
12453
+ function updateCheckSwitchedOff(env) {
12454
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
12455
+ const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
12456
+ if (value !== void 0 && [
12457
+ "off",
12458
+ "0",
12459
+ "false",
12460
+ "no"
12461
+ ].includes(value)) return UPDATE_CHECK_ENV;
12462
+ if (env["AGENT_COMMS_UPDATES"]?.trim().toLowerCase() === "never") return UPDATES_ENV;
12463
+ return null;
12464
+ }
12465
+ /** How much a level updates: `auto` 2, `every-14-days` 1, `never` 0. */
12466
+ const rankOf = (level) => UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
12467
+ /** `profileTarget`, for a path read back from the configuration: one it cannot follow is shown as it is. */
12468
+ function safeTarget(path) {
12469
+ try {
12470
+ return profileTarget(path);
12471
+ } catch {
12472
+ return path;
12473
+ }
12474
+ }
12475
+ /**
12476
+ * The caps the organisation profiles here name (design 2026-10-16, U8): the strictest `max`, and the first profile
12477
+ * with `background: false`, each with where it comes from. A `max` word this release does not know counts as `never`,
12478
+ * the strictest it could mean: caps only ever tighten.
12479
+ */
12480
+ function organisationCaps(config) {
12481
+ let cap;
12482
+ let backgroundOff;
12483
+ for (const [organisation, record] of Object.entries(organisationsOf(config)).sort(([a], [b]) => a.localeCompare(b))) {
12484
+ const caps = record.updates;
12485
+ if (caps === void 0) continue;
12486
+ const where = {
12487
+ organisation,
12488
+ source: updatesPathFor(safeTarget(record.source.path))
12489
+ };
12490
+ if (caps.max !== void 0) {
12491
+ const level = caps.max === "every-14-days" ? "every-14-days" : "never";
12492
+ if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
12493
+ ...where,
12494
+ level
12495
+ };
12496
+ }
12497
+ if (caps.background === false && backgroundOff === void 0) backgroundOff = where;
12498
+ }
12499
+ return {
12500
+ cap,
12501
+ backgroundOff
12502
+ };
12503
+ }
12504
+ /**
12505
+ * The level in force by the configuration alone (design 2026-10-16, U2, U8): the level set, capped by its
12506
+ * organisations' caps, and the organisation that turns background updates off. What `effectiveUpdates` starts from,
12507
+ * and what a background run and the background job's setup read — neither of which runs where the environment
12508
+ * switched updates off, and neither of which counts a configuration it cannot read as anything.
12509
+ */
12510
+ function configuredUpdates(config) {
12511
+ const set = updateLevelOf(config);
12512
+ const caps = organisationCaps(config);
12513
+ const cap = caps.cap !== void 0 && rankOf(caps.cap.level) < rankOf(set) ? caps.cap : void 0;
12514
+ return {
12515
+ level: cap?.level ?? set,
12516
+ set,
12517
+ ...cap ? { cap } : {},
12518
+ ...caps.backgroundOff ? { backgroundOff: caps.backgroundOff } : {}
12519
+ };
12520
+ }
12521
+ /**
12522
+ * The caps that stand in for the configuration's when `config.json` cannot be read (U8, failing closed): for every
12523
+ * organisation the file still names — read loosely, as JSON, whatever its schema says — the caps it records, and the
12524
+ * caps file beside its profile, read again (`readUpdatesBeside`). The strictest `max` of them all, and the first
12525
+ * `background: false`. A caps file that cannot be read is passed over; what the configuration recorded of it stands.
12526
+ * A file that is not JSON at all names no organisation, and so no cap.
12527
+ */
12528
+ async function capsWithoutConfig(core) {
12529
+ let raw;
12530
+ try {
12531
+ raw = JSON.parse(await readFile(core.config.path, "utf8"));
12532
+ } catch {
12533
+ return { read: 0 };
12534
+ }
12535
+ const organisations = raw?.organisations;
12536
+ if (typeof organisations !== "object" || organisations === null) return { read: 0 };
12537
+ let cap;
12538
+ let backgroundOff;
12539
+ let read = 0;
12540
+ const take = (organisation, source, caps) => {
12541
+ read += 1;
12542
+ if (caps.max !== void 0) {
12543
+ const level = caps.max === "every-14-days" ? "every-14-days" : "never";
12544
+ if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
12545
+ organisation,
12546
+ source,
12547
+ level
12548
+ };
12549
+ }
12550
+ if (caps.background === false && backgroundOff === void 0) backgroundOff = {
12551
+ organisation,
12552
+ source
12553
+ };
12554
+ };
12555
+ for (const [organisation, record] of Object.entries(organisations).sort(([a], [b]) => a.localeCompare(b))) {
12556
+ const held = typeof record === "object" && record !== null ? record : {};
12557
+ const path = typeof held.source?.path === "string" && isAbsolute(held.source.path) ? held.source.path : void 0;
12558
+ const source = path === void 0 ? core.config.path : updatesPathFor(safeTarget(path));
12559
+ if (typeof held.updates === "object" && held.updates !== null) take(organisation, source, held.updates);
12560
+ if (path === void 0) continue;
12561
+ try {
12562
+ const file = await readUpdatesBeside(path, organisation);
12563
+ if (file !== void 0) take(organisation, file.path, file);
12564
+ } catch {}
12565
+ }
12566
+ return {
12567
+ ...cap ? { cap } : {},
12568
+ ...backgroundOff ? { backgroundOff } : {},
12569
+ read
12570
+ };
12571
+ }
12572
+ /**
12573
+ * The level in force here, read from the environment first, then the configuration and its organisations' caps.
12574
+ *
12575
+ * A configuration that cannot be read sets the default, `auto` — what is wrong with it is for the doctor and the tools
12576
+ * to say, and it must not quietly turn updates off — but it never lifts an organisation's cap: the strictest cap that
12577
+ * can still be read in its place holds (`capsWithoutConfig`), and nothing installs in the background, which reads the
12578
+ * configuration too.
12579
+ */
12580
+ async function effectiveUpdates(core, env, now = /* @__PURE__ */ new Date()) {
12581
+ let configured;
12582
+ try {
12583
+ configured = configuredUpdates(await core.config.load());
12584
+ } catch (error) {
12585
+ const { read, cap, backgroundOff } = await capsWithoutConfig(core);
12586
+ configured = {
12587
+ level: cap?.level ?? "auto",
12588
+ set: "auto",
12589
+ ...cap ? { cap } : {},
12590
+ ...backgroundOff ? { backgroundOff } : {},
12591
+ unreadableConfig: {
12592
+ reason: shownText(error instanceof Error ? error.message : String(error), 200),
12593
+ read
12594
+ }
12595
+ };
12596
+ }
12597
+ const { level, set, ...extra } = configured;
12598
+ const switched = updateCheckSwitchedOff(env);
12599
+ if (switched !== null) return {
12600
+ level: "never",
12601
+ set,
12602
+ by: switched,
12603
+ background: false,
12604
+ ...extra
12605
+ };
12606
+ if (level !== "auto" || extra.backgroundOff !== void 0 || extra.unreadableConfig !== void 0) return {
12607
+ level,
12608
+ set,
12609
+ by: "setting",
12610
+ background: false,
12611
+ ...extra
12612
+ };
12613
+ const job = await updateJobHealth(core.paths.stateDir, now);
12614
+ return {
12615
+ level,
12616
+ set,
12617
+ by: "setting",
12618
+ background: job?.working === true,
12619
+ ...extra,
12620
+ ...job !== null && !job.working ? { jobSilent: { since: job.since } } : {}
12621
+ };
12622
+ }
12623
+ /** Whether a check may run at all, how often, and if not, what turned it off: the environment, then the level. */
12624
+ async function updateCheckEnabled(core, env) {
12625
+ const updates = await effectiveUpdates(core, env);
12626
+ if (updates.level === "never") return {
12627
+ on: false,
12628
+ by: updates.by
12629
+ };
12630
+ return {
12631
+ on: true,
12632
+ intervalMs: updates.level === "every-14-days" ? SPACED_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS
12633
+ };
12634
+ }
12635
+ /**
12636
+ * When a pause ends, while one holds at `now` — or null. A pause holds from when it was asked until its end, and only
12637
+ * 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
12638
+ * hand further off, holds nothing, so no pause ever lasts longer than 14 days from when it was asked.
12639
+ */
12640
+ function pauseEnd(record, now) {
12641
+ if (record.pausedAt === null || record.pausedUntil === null) return null;
12642
+ const asked = Date.parse(record.pausedAt);
12643
+ const ends = Date.parse(record.pausedUntil);
12644
+ if (ends - asked > 12099e5) return null;
12645
+ if (now.getTime() < asked - CLOCK_SKEW_MS || now.getTime() >= ends) return null;
12646
+ return new Date(ends);
12647
+ }
12648
+ /**
12649
+ * The security fix this record says is out, for a process running `running`: the latest release, when its manifest
12650
+ * declares one and `running` is behind it. Null otherwise.
12651
+ */
12652
+ function securityRelease(record, running) {
12653
+ const latest = countedLatest(record);
12654
+ if (latest === null || record.release?.version !== latest || !record.release.security) return null;
12655
+ return isVersion(running) && isBehind(running, latest) ? latest : null;
12656
+ }
12657
+ /**
12658
+ * The person answered the security line (design 2026-10-16, U7): the send or post `approvalId` was approved, or claimed
12659
+ * to be sent. When its preview carried the line, that release counts as asked from now on, and the next preview is as
12660
+ * it always was. An approval no preview carried it on answers nothing. Called by the approval store
12661
+ * (`ApprovalStoreOptions.answered`); it never throws, and a send never waits long on it.
12662
+ */
12663
+ async function securityAnswered(stateDir, approvalId) {
12664
+ const carried = (record) => record.securityShown?.approvals.includes(approvalId) === true;
12665
+ try {
12666
+ if (!carried(await readUpdateCheck(stateDir))) return;
12667
+ await changeUpdateCheck(stateDir, (record) => {
12668
+ if (!carried(record) || record.securityShown === null || record.securityShown === void 0) return null;
12669
+ return { record: {
12670
+ ...record,
12671
+ securityAsked: record.securityShown.version,
12672
+ securityShown: null
12673
+ } };
12674
+ });
12675
+ } catch {}
12676
+ }
12677
+ /** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
12678
+ function localStamp(at) {
12679
+ const two = (value) => String(value).padStart(2, "0");
12680
+ return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
12681
+ }
12682
+ /**
12683
+ * The latest release as the check counts it: a plain `MAJOR.MINOR.PATCH` — 0.8.0-rc.1 is never an update anybody is
12684
+ * told of, and 0.8.1+anything is not a release at all. Null when the file names none.
12685
+ */
12686
+ function countedLatest(record) {
12687
+ return isPlainVersion(record.latest) ? record.latest : null;
12688
+ }
12689
+ /**
12690
+ * What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
12691
+ *
12692
+ * `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
12693
+ * registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
12694
+ * registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
12695
+ * configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
12696
+ * code, and a notice that said to restart would give advice that cannot work.
12697
+ */
12698
+ function updateVerdict(record, running, where) {
12699
+ const latest = countedLatest(record);
12700
+ if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
12701
+ return {
12702
+ kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
12703
+ running,
12704
+ latest
12705
+ };
12706
+ }
12707
+ function openCore(options = {}) {
12708
+ const { paths, pathOverrides } = resolvePathIdentity(options);
12709
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
12710
+ const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
12711
+ caller: options.caller,
12712
+ paths,
12713
+ platform: options.platform,
12714
+ env: options.env
12715
+ });
12716
+ const config = new ConfigStore(paths.configDir, { handoffs });
12717
+ const audit = new AuditLog(paths.stateDir, now);
12718
+ let cached = null;
12719
+ return {
12720
+ paths,
12721
+ pathOverrides,
12722
+ config,
12723
+ states: new InboxStateStore(paths.stateDir),
12724
+ approvals: new ApprovalStore(paths.stateDir, {
12725
+ now,
12726
+ handoffs,
12727
+ loadConfig: () => config.load(),
12728
+ audit,
12729
+ answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
12730
+ }),
12731
+ ledger: new SendLedger(paths.stateDir, now),
12732
+ plans: new PlanStore(paths.stateDir, now),
12733
+ taint: new TaintStore(paths.stateDir, now),
12734
+ audit,
12735
+ handoffs,
12736
+ async secrets(kind) {
12737
+ const chosen = kind ?? secretsStoreOf(await config.load());
12738
+ if (cached?.kind === chosen) return cached.store;
12739
+ const store = await openSecretStore(chosen, {
12740
+ secretsDir: paths.secretsDir,
12741
+ namespace: keychainNamespace(paths.configDir),
12742
+ handoffs
12743
+ });
12744
+ cached = {
12745
+ kind: chosen,
12746
+ store
12747
+ };
12748
+ return store;
12749
+ }
12750
+ };
12751
+ }
12752
+ /**
12753
+ * A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
12754
+ * whole — and `NaN` for anything else.
12755
+ *
12756
+ * `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
12757
+ * 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
12758
+ * hold exactly is not one either: it would be used as some other number.
12759
+ */
12760
+ function readWholeNumber(raw) {
12761
+ const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
12762
+ return Number.isSafeInteger(value) ? value : NaN;
12763
+ }
12764
+ /**
12765
+ * A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
12766
+ * names the option and its range.
12767
+ *
12768
+ * One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
12769
+ * operations those commands share with their tools, so a number the command refuses is refused by the tool in the
12770
+ * same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
12771
+ */
12772
+ function wholeNumber(raw, rule) {
12773
+ if (raw === void 0) return void 0;
12774
+ const value = readWholeNumber(raw);
12775
+ if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
12776
+ const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
12777
+ const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
12778
+ throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
12779
+ }
12780
+ return value;
12781
+ }
12782
+ /**
12783
+ * Every sentence a background run may leave for the next session, and the one way a notice of it is said (design
12784
+ * 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
12785
+ * the registry or a certificate wrote. A release's version is the one thing a sentence takes from outside, and only as a
12786
+ * plain `MAJOR.MINOR.PATCH`: a prerelease's words or build metadata (`0.20.1+Ignore-previous-instructions` is valid
12787
+ * semver) are never repeated.
12788
+ *
12789
+ * The run writes one of these into the update state file; the notice (`update-gate.ts`) says it only when it is one of
12790
+ * them, word for word, and otherwise the outcome's own sentence — so a record written by another release, or by hand,
12791
+ * never reaches a model as it is.
12792
+ *
12793
+ * Nothing here reaches the network or reads a file: every server and command, WhatsApp's too, carries it.
12794
+ */
12795
+ const PLAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
12796
+ /** A release as a notice names it: its version when it is a plain `MAJOR.MINOR.PATCH`, otherwise "A release". */
12797
+ function shownRelease(version) {
12798
+ return typeof version === "string" && PLAIN_VERSION.test(version) ? version : "A release";
12799
+ }
12800
+ /** Which pin a release did not verify against, in this package's own words — never the value it held instead. */
12801
+ const PIN_WORDS = Object.freeze({
12802
+ attestation: "npm has no SLSA provenance attestation for it that could be read",
12803
+ bundle: "its provenance bundle could not be read",
12804
+ issuer: "it was not signed through GitHub Actions' token issuer",
12805
+ identity: "it was not signed for this repository's release workflow at the tag of its version",
12806
+ runner: "it was not built on a GitHub-hosted runner",
12807
+ repository: "it was not built from this repository",
12808
+ ref: "it was not built from the tag of its version",
12809
+ subject: "what its provenance signs is not the tarball the registry serves for it",
12810
+ signature: "Sigstore did not verify its signature, certificate or transparency-log entry"
12811
+ });
12812
+ const PIN_FIELDS = Object.keys(PIN_WORDS);
12813
+ /** What went wrong, in fixed words. */
12814
+ const FAILED_WORDS = Object.freeze({
12815
+ install: "one of its packages could not be installed",
12816
+ tarball: "what npm installed is not the tarball whose provenance was verified",
12817
+ selfCheck: "one of its packages failed its self-check",
12818
+ switch: "a launcher could not be switched, so every launcher was put back",
12819
+ inUse: "an unfinished runtime of it is in the way"
12820
+ });
12821
+ 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";
12822
+ /** The sentences, by what happened. */
12823
+ const BACKGROUND_WORDS = Object.freeze({
12824
+ installed: (version) => `${shownRelease(version)} is installed; ${RESTART}.`,
12825
+ 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.`,
12826
+ 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.`,
12827
+ downgrade: (version) => `The registry names ${shownRelease(version)} as the latest release, older than what runs here: nothing was changed in the background.`,
12828
+ waits: (version) => `${shownRelease(version)} waits for you: it was not installed in the background. comms_update shows each step first.`,
12829
+ 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.`,
12830
+ refused: (version) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow. Nothing was changed.`,
12831
+ 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.`,
12832
+ 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.`,
12833
+ 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.`,
12834
+ 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.",
12835
+ 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."
12836
+ });
12837
+ /** Every sentence a run may leave for `outcome` and `version`, the outcome's own first. */
12838
+ function sentencesFor(outcome, version) {
12839
+ const words = BACKGROUND_WORDS;
12840
+ switch (outcome) {
12841
+ case "installed": return [words.installed(version ?? "")];
12842
+ case "skipped": return [
12843
+ words.waits(version),
12844
+ words.major(version ?? ""),
12845
+ words.decision(version ?? ""),
12846
+ words.downgrade(version ?? "")
12847
+ ];
12848
+ case "refused": return [words.refused(version), ...PIN_FIELDS.map((field) => words.unverified(version, field))];
12849
+ case "failed": return [
12850
+ words.stopped(version),
12851
+ ...Object.keys(FAILED_WORDS).map((what) => words.failed(version, what)),
12852
+ words.partlySwitched(version),
12853
+ words.heldUnreadable
12854
+ ];
12855
+ case "job-removed": return [words.jobRemoved];
12856
+ }
12857
+ }
12858
+ /**
12859
+ * What a notice says of the last background run: the run's own sentence when it is one of these, word for word, for
12860
+ * its outcome and release — and otherwise the outcome's own. Never the record's text as it is.
12861
+ */
12862
+ function backgroundNoticeLine(notice) {
12863
+ const sentences = sentencesFor(notice.outcome, notice.version);
12864
+ return sentences.includes(notice.text) ? notice.text : sentences[0];
12865
+ }
12866
+ /** The SHA-512 in a subresource-integrity string, as hex; null when it holds none. */
12867
+ function sha512Hex(integrity) {
12868
+ for (const part of integrity.trim().split(/\s+/)) {
12869
+ if (!part.startsWith("sha512-")) continue;
12870
+ const bytes = Buffer.from(part.slice(7).split("?")[0] ?? "", "base64");
12871
+ if (bytes.length === 64) return bytes.toString("hex");
12872
+ }
12873
+ return null;
12874
+ }
12875
+ /** The tarball integrity the registry gives for one version, or null when it gives none. */
12876
+ function releaseIntegrity(packument, version) {
12877
+ const integrity = packument.versions?.[version]?.dist?.integrity;
12878
+ return typeof integrity === "string" && integrity.trim() !== "" ? integrity : null;
12879
+ }
12880
+ /** What a release that verified was built by, in one phrase: the preview's and the dry run's words. */
12881
+ 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";
12882
+ /** The scope every package of this suite is published under, and so attested for. */
12883
+ const SUITE = "@agentcomms/";
12884
+ /**
12885
+ * Why the runtime at `runtime` is not what was verified, or null when it is (security review of 0.20.0, severity 5).
12886
+ *
12887
+ * Every package of this suite npm installed in it — `packageName` itself, and any of ours it depends on at any depth,
12888
+ * as Gmail depends on core — has to be, by the runtime's own lockfile, a tarball whose provenance verified: the one
12889
+ * whose SHA-512 is `sha512` for `packageName`, and for each other either one already verified (`known`, by
12890
+ * `package@version`, the SHA-512 it signs) or one `verify` verifies now, against the integrity npm recorded. npm checks
12891
+ * each tarball it downloads against the integrity the registry gives it then, and records that; a registry that gave
12892
+ * another one to the install than to the check, or a runtime made some other way, is caught here.
12893
+ *
12894
+ * The packages of others it holds are checked by npm alone, against the registry's integrity: no pin says who builds
12895
+ * them (SECURITY.md says so). No package's install script ran (`installManagedRuntime`).
12896
+ */
12897
+ async function runtimeIntegrityProblem(runtime, packageName, sha512, options = {}) {
12898
+ let lock;
12899
+ try {
12900
+ lock = JSON.parse(await readFile(join(runtime, "package-lock.json"), "utf8"));
12901
+ } catch {
12902
+ lock = null;
12903
+ }
12904
+ const own = `node_modules/${packageName}`;
12905
+ const integrity = lock?.packages?.[own]?.integrity;
12906
+ 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}`;
12907
+ if (sha512Hex(integrity) !== sha512) return `what is in ${runtime} is not the tarball whose provenance was verified: its lockfile records another integrity for ${packageName}`;
12908
+ const known = options.known ?? /* @__PURE__ */ new Map();
12909
+ for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
12910
+ if (key === own) continue;
12911
+ const name = key.slice(key.lastIndexOf("node_modules/") + 13);
12912
+ if (!key.includes("node_modules/") || !name.startsWith(SUITE)) continue;
12913
+ const version = entry?.version;
12914
+ const recorded = entry?.integrity;
12915
+ 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`;
12916
+ const hex = sha512Hex(recorded);
12917
+ if (known.get(`${name}@${version}`) === hex) continue;
12918
+ if (!options.verify) return `${name}@${version}, which ${packageName} in ${runtime} depends on, cannot be verified here`;
12919
+ const verdict = await options.verify({
12920
+ packageName: name,
12921
+ version,
12922
+ integrity: recorded
12923
+ });
12924
+ 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]}`;
12925
+ known.set(`${name}@${version}`, hex);
12926
+ }
12927
+ return null;
12928
+ }
12929
+ /**
12930
+ * The refusal of a whole update or install for one release that did not verify: which, and which pin — never what it
12931
+ * held. `hint` replaces the general one where there is more to say: where it can be verified, for a process that
12932
+ * cannot.
12933
+ */
12934
+ function unverifiedRelease(packageName, version, why, pin, act = "update", hint) {
12935
+ const which = act === "update" ? "An update installs" : "An install fetches";
12936
+ const plain = isPlainVersion(version);
12937
+ 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}`, {
12938
+ 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.`,
12939
+ details: {
12940
+ package: packageName,
12941
+ ...plain ? { version } : {},
12942
+ pin
12943
+ }
12944
+ });
12945
+ }
12946
+ /**
12947
+ * Verifies every release in `wanted` — its tarball's integrity as the registry gives it, and npm's attestation for it,
12948
+ * held to the pins the background run holds a release to (design 2026-10-16 U5 step 3). The first that does not verify
12949
+ * refuses the whole update or install (`unverifiedRelease`); what verified is returned by package, for the preview
12950
+ * and for the integrity check after install. Reads; changes nothing. With no registry or no verifier, the first
12951
+ * release is refused before anything is asked.
12952
+ */
12953
+ async function verifyReleaseSet(sources, wanted, act = "update", noVerifierHint) {
12954
+ const verified = /* @__PURE__ */ new Map();
12955
+ if (wanted.size === 0) return verified;
12956
+ const { registry, verify, why } = sources;
12957
+ for (const [packageName, version] of [...wanted].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
12958
+ if (!isPlainVersion(version)) throw unverifiedRelease(packageName, version, "it is not a plain release (MAJOR.MINOR.PATCH)", "version", act);
12959
+ if (registry === null || verify === null) throw unverifiedRelease(packageName, version, why, "verifier", act, noVerifierHint);
12960
+ let integrity;
12961
+ try {
12962
+ integrity = releaseIntegrity((await registry(packageName)).packument, version);
12963
+ } catch (error) {
12964
+ 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." });
12965
+ }
12966
+ if (integrity === null) throw unverifiedRelease(packageName, version, "the registry gives no tarball integrity for it", "subject", act);
12967
+ const verdict = await verify({
12968
+ packageName,
12969
+ version,
12970
+ integrity
12971
+ });
12972
+ if (!verdict.ok) throw unverifiedRelease(packageName, version, PIN_WORDS[verdict.field], verdict.field, act);
12973
+ verified.set(packageName, verdict.provenance);
12974
+ }
12975
+ return verified;
12976
+ }
12977
+ /**
11322
12978
  * Registering, pruning and listing every declared channel or service MCP server, from the core.
11323
12979
  *
11324
12980
  * `agentcomms mcp install` registers core or a declared service; `comms_server_install` registers any declared
@@ -11482,6 +13138,14 @@ function installOptions(request) {
11482
13138
  };
11483
13139
  }
11484
13140
  /**
13141
+ * The install receipt a fetched runtime keeps in its folder: which release, whether its provenance was verified, and
13142
+ * by whom it was installed — core, which verifies, or a channel's own command, which cannot (owner's decision,
13143
+ * 2026-10-17). Doctor and a person can read it; nothing else depends on it.
13144
+ */
13145
+ const INSTALL_RECEIPT = ".agentcomms-install.json";
13146
+ /** A sentence's first letter in capitals: core's command, said after a full stop. */
13147
+ const capitalised = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
13148
+ /**
11485
13149
  * Registering one declared server with one client, as a change.
11486
13150
  *
11487
13151
  * The effects are what a person is agreeing to, one sentence each, and everything that decides what the server may
@@ -11514,6 +13178,101 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11514
13178
  const launcher = request.launcher ?? "managed";
11515
13179
  const productNow = async () => own ?? await serverProduct(request.channel, request.launcher);
11516
13180
  let planned;
13181
+ const sources = () => ({
13182
+ registry: deps.registry ?? null,
13183
+ verify: deps.verifyRelease ?? null,
13184
+ 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"
13185
+ });
13186
+ /**
13187
+ * Core's own command for this same install, located — the one that verifies — said by `say`; `instead` where no
13188
+ * command can be named.
13189
+ */
13190
+ const coreWords = () => {
13191
+ const words = [
13192
+ "mcp",
13193
+ "install",
13194
+ "--server",
13195
+ request.channel,
13196
+ "--client",
13197
+ request.client
13198
+ ];
13199
+ if (request.name !== void 0) words.push("--name", request.name);
13200
+ const pin = pinOf(request);
13201
+ const option = pinOption(request.channel);
13202
+ if (pin !== void 0 && option !== void 0 && option !== "readOnly") words.push(`--${option}`, pin);
13203
+ if (request.readOnly) words.push("--read-only");
13204
+ if (request.force) words.push("--force");
13205
+ return words;
13206
+ };
13207
+ const inCore = (say, instead) => {
13208
+ try {
13209
+ return handoffSentence(requireHandoffs(core, deps.platform).core(coreWords()), say, { instead });
13210
+ } catch {
13211
+ return instead;
13212
+ }
13213
+ };
13214
+ /** Where a process that cannot verify — a channel's own, with no Sigstore — sends the person: core, which can. */
13215
+ 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.");
13216
+ /** Where the release a channel's own command installs unverified is checked: core's command, located. */
13217
+ const checkedInCore = () => inCore((command) => `core's ${command} checks it before it installs it`, "core's `mcp install --server` checks it before it installs it");
13218
+ /** The receipt a runtime this install fetched keeps beside it (`INSTALL_RECEIPT`). */
13219
+ const writeReceipt = (runtime, packageName, version, verified, by) => writeFileAtomic(join(runtime, INSTALL_RECEIPT), `${JSON.stringify({
13220
+ package: packageName,
13221
+ version,
13222
+ verified,
13223
+ by,
13224
+ at: (/* @__PURE__ */ new Date()).toISOString()
13225
+ }, null, 2)}\n`);
13226
+ const verifiedRuntime = async (done) => {
13227
+ const { dataDir } = core.paths;
13228
+ const { packageName, version } = done.product;
13229
+ const runtime = managedRuntimeDir(dataDir, packageName, version);
13230
+ const there = await reusableRuntime(dataDir, packageName, version);
13231
+ if (done.verified === void 0) {
13232
+ if (there !== null) return void 0;
13233
+ if (done.unverifiedOwn) {
13234
+ await installManagedRuntime(context, done.product, version).catch(async (error) => {
13235
+ throw await remedyLocated(error, core);
13236
+ });
13237
+ await writeReceipt(runtime, packageName, version, false, `${label}’s own command`);
13238
+ return {
13239
+ package: packageName,
13240
+ version,
13241
+ verified: false,
13242
+ 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())}.`
13243
+ };
13244
+ }
13245
+ 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." });
13246
+ }
13247
+ if (there === null) await installManagedRuntime(context, done.product, version).catch(async (error) => {
13248
+ throw await remedyLocated(error, core);
13249
+ });
13250
+ const { sha512 } = done.verified;
13251
+ const problem = await runtimeIntegrityProblem(runtime, packageName, sha512, {
13252
+ verify: sources().verify,
13253
+ known: /* @__PURE__ */ new Map([[`${packageName}@${version}`, sha512]])
13254
+ });
13255
+ if (problem === null) {
13256
+ await writeReceipt(runtime, packageName, version, true, "core");
13257
+ return {
13258
+ package: packageName,
13259
+ version,
13260
+ verified: true
13261
+ };
13262
+ }
13263
+ if (there === null) await rm(runtime, {
13264
+ recursive: true,
13265
+ force: true
13266
+ }).catch(() => void 0);
13267
+ 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.";
13268
+ throw new CommsError("BAD_DATA", `nothing was registered: ${problem}${there === null ? ", so it was removed" : ""}. The launcher was not pointed at it`, {
13269
+ 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}`,
13270
+ details: {
13271
+ package: packageName,
13272
+ version
13273
+ }
13274
+ });
13275
+ };
11517
13276
  return {
11518
13277
  plan: async (config) => {
11519
13278
  checkPin(config, request);
@@ -11523,9 +13282,21 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11523
13282
  throw await remedyLocated(error, core);
11524
13283
  });
11525
13284
  const { target, previous, effective, kept, command } = preflight;
13285
+ const managedApply = request.print !== true && launcher === "managed";
13286
+ const fetches = managedApply && await reusableRuntime(core.paths.dataDir, product.packageName, version) === null;
13287
+ const checksDisk = managedApply && !fetches && deps.verifyRelease !== void 0;
13288
+ const from = sources();
13289
+ const running = deps.running ?? (own === void 0 ? void 0 : {
13290
+ packageName: own.packageName,
13291
+ version: own.version
13292
+ });
13293
+ const unverifiedOwn = fetches && deps.verifyRelease === void 0 && running !== void 0 && running.packageName === product.packageName && running.version === version;
13294
+ 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;
11526
13295
  planned = {
11527
13296
  product,
11528
- install: plannedInstall(preflight)
13297
+ install: plannedInstall(preflight),
13298
+ verified,
13299
+ unverifiedOwn
11529
13300
  };
11530
13301
  const effects = [];
11531
13302
  if (target.writes) {
@@ -11542,7 +13313,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11542
13313
  if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
11543
13314
  else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
11544
13315
  }
11545
- 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)}`);
13316
+ if (fetches) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
13317
+ if (request.print !== true && launcher === "managed") effects.push(launcherSwitchEffect(core.paths.dataDir, facts.packageName, label, version));
11546
13318
  if (request.print !== true && launcher === "managed" && managedShimsSupported(context)) {
11547
13319
  const path = managedShimPath({
11548
13320
  env,
@@ -11551,6 +13323,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11551
13323
  }, facts.binary);
11552
13324
  effects.push(`writes or repoints the owned CLI shim at ${path}; a foreign file there is left unchanged`);
11553
13325
  }
13326
+ if (verified !== void 0 && fetches) effects.push(`${verified.package}@${verified.version} was verified before this preview: built by ${BUILT_BY}`);
13327
+ 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`);
11554
13328
  return {
11555
13329
  before: config,
11556
13330
  after: config,
@@ -11560,18 +13334,30 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11560
13334
  },
11561
13335
  apply: async () => {
11562
13336
  if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
13337
+ const provenance = request.print !== true && launcher === "managed" ? await verifiedRuntime(planned) : void 0;
11563
13338
  const installed = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
11564
13339
  throw await remedyLocated(error, core);
11565
13340
  });
11566
13341
  const result = request.print === true ? installed : await withManagedShim(context, planned.product, installed);
11567
13342
  return {
11568
13343
  ...result,
13344
+ ...provenance?.note === void 0 ? {} : { warnings: [...result.warnings, provenance.note] },
13345
+ ...provenance === void 0 ? {} : { provenance },
11569
13346
  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
11570
13347
  };
11571
13348
  }
11572
13349
  };
11573
13350
  }
11574
13351
  /**
13352
+ * What a managed install or update does to the product's stable launcher, as the preview says it (design 2026-10-16,
13353
+ * U6): it points it at the release's runtime, and every registration of the product that starts it — in any client —
13354
+ * runs that release from its next start.
13355
+ */
13356
+ function launcherSwitchEffect(dataDir, packageName, label, version) {
13357
+ const { launcher } = launcherPaths(dataDir, launcherName(packageName));
13358
+ 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`;
13359
+ }
13360
+ /**
11575
13361
  * A managed install's CLI shim (CUE-406), once the registration's outcome is known: the command the locator finds in
11576
13362
  * the runtime the entry starts, written where nothing is or over this installation's own unchanged shim, and never
11577
13363
  * over anything else. A shim that cannot be written leaves the registration as it went, and says how to repair both.
@@ -11579,8 +13365,6 @@ function serverInstallChange(core, env, request, own, deps = {}) {
11579
13365
  */
11580
13366
  async function withManagedShim(context, product, installed) {
11581
13367
  if (installed.launcher !== "managed") return installed;
11582
- const cliEntry = installed.entry.args[0];
11583
- if (cliEntry === void 0) throw new CommsError("UNEXPECTED", "the managed entry has no CLI path");
11584
13368
  const shimContext = {
11585
13369
  env: context.env,
11586
13370
  dataDir: context.core.paths.dataDir,
@@ -11594,6 +13378,11 @@ async function withManagedShim(context, product, installed) {
11594
13378
  let shim;
11595
13379
  let shimFailure;
11596
13380
  try {
13381
+ const name = launcherName(product.packageName);
13382
+ const { launcher } = launcherPaths(context.core.paths.dataDir, name);
13383
+ const pointer = await readPointer(context.core.paths.dataDir, name);
13384
+ if (pointer === void 0) throw new CommsError("CONFIG", `the stable launcher ${launcher} has no pointer, so it starts nothing yet`);
13385
+ const cliEntry = launcherCli(pointer.current, name);
11597
13386
  const located = locateCliCommand({
11598
13387
  caller: {
11599
13388
  url: pathToFileURL(cliEntry).href,
@@ -11612,13 +13401,14 @@ async function withManagedShim(context, product, installed) {
11612
13401
  ...context.core.paths,
11613
13402
  downloadsDir: context.core.paths.dataDir
11614
13403
  },
11615
- platform: context.platform
13404
+ platform: context.platform,
13405
+ env: context.env
11616
13406
  });
11617
13407
  if (!located.ok) throw new CommsError("CONFIG", `the managed CLI command could not be located: ${located.message}`);
11618
13408
  shim = await (context.writeShim ?? writeManagedShim)(shimContext, {
11619
13409
  binary: product.binary,
11620
- command: located.command.words,
11621
- entry: located.command.entry
13410
+ command: [launcher],
13411
+ entry: launcher
11622
13412
  });
11623
13413
  } catch (error) {
11624
13414
  shimFailure = error instanceof Error ? error.message : String(error);
@@ -11631,334 +13421,88 @@ async function withManagedShim(context, product, installed) {
11631
13421
  warnings,
11632
13422
  ...shim === void 0 ? {} : { shim },
11633
13423
  ...shimFailure === void 0 ? {} : { shimFailure }
11634
- };
11635
- }
11636
- /**
11637
- * A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
11638
- * the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
11639
- * installer says only the words; it may not import the locator.
11640
- */
11641
- async function remedyLocated(error, core) {
11642
- const remedy = installRemedyOf(error);
11643
- if (remedy === void 0 || !(error instanceof CommsError)) return error;
11644
- const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
11645
- if (server === void 0) return error;
11646
- const maker = await requireHandoffs(core).registered();
11647
- const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
11648
- "mcp",
11649
- "install",
11650
- "--server",
11651
- server,
11652
- ...remedy.words.slice(2)
11653
- ]);
11654
- return new CommsError(error.code, error.message, {
11655
- hint: handoffSentence(handoff, remedy.say),
11656
- ...error.details === void 0 ? {} : { details: error.details },
11657
- ...error.cause === void 0 ? {} : { cause: error.cause }
11658
- });
11659
- }
11660
- /**
11661
- * Removing a declared server's unused managed runtimes, as a change.
11662
- *
11663
- * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
11664
- * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
11665
- * then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
11666
- *
11667
- * `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
11668
- * release of the command that was run; checked to be that channel's, as for an install.
11669
- */
11670
- function serverPruneChange(core, env, request, own) {
11671
- if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
11672
- const context = {
11673
- env,
11674
- core
11675
- };
11676
- const { packageName, version } = ownProduct(request.channel, own) ?? {
11677
- packageName: serverFacts(request.channel).packageName,
11678
- version: "0.18.2"
11679
- };
11680
- const product = {
11681
- packageName,
11682
- version
11683
- };
11684
- const label = serverLabel(request.channel);
11685
- const base = {
11686
- includePrinted: request.includePrinted === true,
11687
- ...request.processes ? { processes: request.processes } : {}
11688
- };
11689
- let approved = [];
11690
- return {
11691
- plan: async (config) => {
11692
- if (request.dryRun) return {
11693
- before: config,
11694
- after: config,
11695
- summary: `Show which ${label} runtimes are unused`
11696
- };
11697
- const preview = await pruneManagedRuntimes(context, product, {
11698
- ...base,
11699
- dryRun: true
11700
- });
11701
- approved = preview.removed.map((item) => item.path);
11702
- return {
11703
- before: config,
11704
- after: config,
11705
- 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}`)],
11706
- summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
11707
- };
11708
- },
11709
- apply: () => pruneManagedRuntimes(context, product, {
11710
- ...base,
11711
- dryRun: request.dryRun === true,
11712
- ...request.dryRun ? {} : { only: approved }
11713
- })
11714
- };
11715
- }
11716
- /**
11717
- * The daily update check, as every server and command reads it (design 2026-09-28): one file per machine,
11718
- * `update-check.json` in the state directory, saying when the registry was last asked, the latest release it named,
11719
- * whether this machine's registrations were behind it, and any "not now" with when it runs out.
11720
- *
11721
- * This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
11722
- * Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
11723
- * imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
11724
- * that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
11725
- */
11726
- /** The file, in the state directory: one per machine, shared by every server and command on it. */
11727
- const UPDATE_CHECK_FILE = "update-check.json";
11728
- /**
11729
- * The switch that turns the whole thing off for one process — no check, no stop — whatever the setting says. Every
11730
- * test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
11731
- * real registry.
11732
- */
11733
- const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
11734
- /**
11735
- * Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
11736
- * extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
11737
- * and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its stop always says
11738
- * "update", whatever the channel's registrations say: restarting would start the release the plugin pins.
11739
- */
11740
- const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
11741
- /** What the stop says first, word for word: the owner's words. */
11742
- const UPDATE_FIRST = "Hang on a minute, there's an update. Let's update first.";
11743
- const EMPTY_UPDATE_CHECK = Object.freeze({
11744
- lastChecked: null,
11745
- latest: null,
11746
- behind: null,
11747
- current: null,
11748
- lastError: null,
11749
- snoozedUntil: null,
11750
- checking: null
11751
- });
11752
- function updateCheckPath(stateDir) {
11753
- return join(stateDir, UPDATE_CHECK_FILE);
11754
- }
11755
- const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
11756
- const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
11757
- function readCurrent(value) {
11758
- if (typeof value !== "object" || value === null) return null;
11759
- const { registered, global } = value;
11760
- return isWords(registered) && isWords(global) ? {
11761
- registered: [...registered],
11762
- global: [...global]
11763
- } : null;
11764
- }
11765
- /**
11766
- * The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
11767
- * as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
11768
- */
11769
- async function readUpdateCheck(stateDir) {
11770
- let parsed;
11771
- try {
11772
- parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
11773
- } catch {
11774
- return { ...EMPTY_UPDATE_CHECK };
11775
- }
11776
- const held = typeof parsed === "object" && parsed !== null ? parsed : {};
11777
- return {
11778
- lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
11779
- latest: isVersion(held.latest) ? held.latest : null,
11780
- behind: typeof held.behind === "boolean" ? held.behind : null,
11781
- current: readCurrent(held.current),
11782
- lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
11783
- snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
11784
- checking: isTime(held.checking) ? held.checking : null
11785
- };
11786
- }
11787
- /**
11788
- * Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
11789
- * and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
11790
- * alongside the record written; `change` returning `null` writes nothing.
11791
- */
11792
- async function changeUpdateCheck(stateDir, change) {
11793
- return withFileLock(join(stateDir, ".update-check.lock"), async () => {
11794
- const current = await readUpdateCheck(stateDir);
11795
- const next = change(current);
11796
- if (next === null) return {
11797
- record: current,
11798
- result: void 0,
11799
- written: false
11800
- };
11801
- await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
11802
- return {
11803
- record: next.record,
11804
- result: next.result,
11805
- written: true
11806
- };
11807
- }, { timeoutMs: 2e3 });
11808
- }
11809
- /**
11810
- * Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
11811
- * minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
11812
- * moments, and a check just claimed by one must not look due to the other.
11813
- */
11814
- function updateCheckDue(record, now) {
11815
- if (record.lastChecked === null) return true;
11816
- const since = now.getTime() - Date.parse(record.lastChecked);
11817
- return !(since >= -CLOCK_SKEW_MS && since < 864e5);
11818
- }
11819
- const CLOCK_SKEW_MS = 3e5;
11820
- /** What turned the check off for this process, from its environment: `CI`, or the switch. Null when neither did. */
11821
- function updateCheckSwitchedOff(env) {
11822
- if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
11823
- const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
11824
- if (value !== void 0 && [
11825
- "off",
11826
- "0",
11827
- "false",
11828
- "no"
11829
- ].includes(value)) return UPDATE_CHECK_ENV;
11830
- return null;
11831
- }
11832
- /** Whether a check may run at all, and if not, what turned it off: the environment first, then the machine's setting. */
11833
- async function updateCheckEnabled(core, env) {
11834
- const switched = updateCheckSwitchedOff(env);
11835
- if (switched !== null) return {
11836
- on: false,
11837
- by: switched
11838
- };
11839
- return await machineSetting(core) === "off" ? {
11840
- on: false,
11841
- by: "setting"
11842
- } : { on: true };
11843
- }
11844
- /**
11845
- * The machine's setting, from `config.json`. A configuration that cannot be read counts as `on`, the default: what is
11846
- * wrong with it is for the doctor and the tools to say, and it must not quietly turn this off.
11847
- */
11848
- async function machineSetting(core) {
11849
- try {
11850
- return updateCheckSetting(await core.config.load());
11851
- } catch {
11852
- return "on";
11853
- }
11854
- }
11855
- /** The start of the next local day: when a "not now" runs out. */
11856
- function nextLocalMidnight(now) {
11857
- return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
11858
- }
11859
- /** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
11860
- function localStamp(at) {
11861
- const two = (value) => String(value).padStart(2, "0");
11862
- return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
11863
- }
11864
- /** Whether a person's "not now" still holds. */
11865
- function updateSnoozed(record, now) {
11866
- return record.snoozedUntil !== null && now.getTime() < Date.parse(record.snoozedUntil);
11867
- }
11868
- /**
11869
- * The latest release as the check counts it: a version, and not a prerelease — 0.8.0-rc.1 is never an update anybody
11870
- * is stopped for. Null when the file names none.
11871
- */
11872
- function countedLatest(record) {
11873
- return record.latest !== null && isVersion(record.latest) && !isPrerelease(record.latest) ? record.latest : null;
13424
+ };
11874
13425
  }
11875
13426
  /**
11876
- * What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
11877
- *
11878
- * `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
11879
- * registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
11880
- * registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
11881
- * configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
11882
- * code, and a reply that said to restart would stop every call with advice that cannot work.
13427
+ * A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
13428
+ * the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
13429
+ * installer says only the words; it may not import the locator.
11883
13430
  */
11884
- function updateVerdict(record, running, where) {
11885
- const latest = countedLatest(record);
11886
- if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
11887
- return {
11888
- kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
11889
- running,
11890
- latest
11891
- };
13431
+ async function remedyLocated(error, core) {
13432
+ const remedy = installRemedyOf(error);
13433
+ if (remedy === void 0 || !(error instanceof CommsError)) return error;
13434
+ const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
13435
+ if (server === void 0) return error;
13436
+ const maker = await requireHandoffs(core).registered();
13437
+ const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
13438
+ "mcp",
13439
+ "install",
13440
+ "--server",
13441
+ server,
13442
+ ...remedy.words.slice(2)
13443
+ ]);
13444
+ return new CommsError(error.code, error.message, {
13445
+ hint: handoffSentence(handoff, remedy.say),
13446
+ ...error.details === void 0 ? {} : { details: error.details },
13447
+ ...error.cause === void 0 ? {} : { cause: error.cause }
13448
+ });
11892
13449
  }
11893
13450
  /**
11894
- * The update that stops this process now, from the file as it is — or null: switched off, snoozed, or nothing newer.
11895
- * Reads two files and asks nobody.
13451
+ * Removing a declared server's unused managed runtimes, as a change.
13452
+ *
13453
+ * A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
13454
+ * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
13455
+ * then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
11896
13456
  *
11897
- * "Restart" is decided per channel, from its registrations, and a server a plugin or an extension started is not one
11898
- * of them (`UPDATE_STARTED_BY_ENV`): beside a registration the update moved, it was told to restart every day, and
11899
- * restarting started the release the plugin pins. It is told to update.
13457
+ * `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
13458
+ * release of the command that was run; checked to be that channel's, as for an install.
11900
13459
  */
11901
- async function pendingUpdate(options) {
11902
- if (!(await updateCheckEnabled(options.core, options.env)).on) return null;
11903
- const record = await readUpdateCheck(options.core.paths.stateDir);
11904
- if (updateSnoozed(record, (options.now ?? (() => /* @__PURE__ */ new Date()))())) return null;
11905
- const verdict = updateVerdict(record, options.running, options);
11906
- const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
11907
- if (verdict?.kind === "restart" && options.surface === "server" && startedBy) return {
11908
- ...verdict,
11909
- kind: "update"
13460
+ function serverPruneChange(core, env, request, own) {
13461
+ if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
13462
+ const context = {
13463
+ env,
13464
+ core
11910
13465
  };
11911
- return verdict;
11912
- }
11913
- function updateCommands(core, platform) {
11914
- const handoffs = requireHandoffs(core, platform);
11915
- return {
11916
- update: handoffs.core(["update"]),
11917
- later: handoffs.core(["update", "--later"])
13466
+ const { packageName, version } = ownProduct(request.channel, own) ?? {
13467
+ packageName: serverFacts(request.channel).packageName,
13468
+ version: "0.20.0"
11918
13469
  };
11919
- }
11920
- /** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
11921
- function updateCommandSaid(commands, which, say) {
11922
- return handoffSentence(commands[which], say);
11923
- }
11924
- function updateWaysOf(commands) {
13470
+ const product = {
13471
+ packageName,
13472
+ version
13473
+ };
13474
+ const label = serverLabel(request.channel);
13475
+ const base = {
13476
+ includePrinted: request.includePrinted === true,
13477
+ ...request.processes ? { processes: request.processes } : {}
13478
+ };
13479
+ let approved = [];
11925
13480
  return {
11926
- update: {
11927
- tool: "comms_update",
11928
- command: commands.update
13481
+ plan: async (config) => {
13482
+ if (request.dryRun) return {
13483
+ before: config,
13484
+ after: config,
13485
+ summary: `Show which ${label} runtimes are unused`
13486
+ };
13487
+ const preview = await pruneManagedRuntimes(context, product, {
13488
+ ...base,
13489
+ dryRun: true
13490
+ });
13491
+ approved = preview.removed.map((item) => item.path);
13492
+ return {
13493
+ before: config,
13494
+ after: config,
13495
+ 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}`)],
13496
+ summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
13497
+ };
11929
13498
  },
11930
- later: {
11931
- tool: "comms_update",
11932
- arguments: { later: true },
11933
- command: commands.later
11934
- }
13499
+ apply: () => pruneManagedRuntimes(context, product, {
13500
+ ...base,
13501
+ dryRun: request.dryRun === true,
13502
+ ...request.dryRun ? {} : { only: approved }
13503
+ })
11935
13504
  };
11936
13505
  }
11937
- /** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
11938
- function labelOf(channel) {
11939
- return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
11940
- }
11941
- /**
11942
- * What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
11943
- * two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
11944
- */
11945
- function updateStopMessage(pending, where, commands) {
11946
- const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
11947
- const didNotRun = `Nothing was done: ${where.tool} did not run.`;
11948
- 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}.`)}`;
11949
- if (pending.kind === "restart") return [
11950
- "Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
11951
- `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}`,
11952
- `Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
11953
- ].join("\n");
11954
- return [
11955
- UPDATE_FIRST,
11956
- `This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
11957
- "Ask the person which they want:",
11958
- `- 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.`,
11959
- `- ${later}`
11960
- ].join("\n");
11961
- }
11962
13506
  /**
11963
13507
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
11964
13508
  *
@@ -11973,6 +13517,11 @@ function updateStopMessage(pending, where, commands) {
11973
13517
  * The check reads and asks nobody. The update is a change whose effects are every step, one sentence each, so the
11974
13518
  * approval is bound to exactly that list — the versions in it included: a release published between the question and
11975
13519
  * the yes is a different change, and the claim is refused. Nothing behind, nothing is prepared.
13520
+ *
13521
+ * A registration that starts the stable launcher (design 2026-10-16, U6) is not registered again: the update installs
13522
+ * the latest runtime and switches the launcher's pointer, and no client's configuration is touched. A managed
13523
+ * registration from before the launcher names a versioned runtime; the update registers it again on the launcher —
13524
+ * the one update that edits it — even when it is already at the latest release.
11976
13525
  */
11977
13526
  /**
11978
13527
  * The packages this suite publishes, whose latest releases an update reads: every declared server owner's, from the
@@ -11984,149 +13533,253 @@ const SERVER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server
11984
13533
  Object.freeze([.../* @__PURE__ */ new Set([...OWNER_PACKAGES, ...SERVER_PACKAGES])].sort());
11985
13534
  serverFacts("core").packageName;
11986
13535
  /**
11987
- * "Not now": no server or command on this machine stops for the update until the next local midnight.
11988
- *
11989
- * `plan` reads the file on both calls, so a claim made after midnight — a different midnight in the sentence — or
11990
- * for a newer release is a different change, and refused.
11991
- */
11992
- function updateLaterChange(core, options = {}) {
11993
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
11994
- let planned = null;
11995
- return {
11996
- plan: async (config) => {
11997
- const record = await readUpdateCheck(core.paths.stateDir);
11998
- const until = nextLocalMidnight(now());
11999
- const latest = countedLatest(record);
12000
- const changed = record.snoozedUntil === null || Date.parse(record.snoozedUntil) < until.getTime();
12001
- planned = {
12002
- until,
12003
- latest,
12004
- changed
12005
- };
12006
- const what = latest === null ? "the daily update check" : `the update to ${latest}`;
12007
- return {
12008
- before: config,
12009
- after: config,
12010
- 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`] : [],
12011
- summary: latest === null ? "Skip the daily update check until tomorrow" : `Skip the update to ${latest} until tomorrow`
12012
- };
12013
- },
12014
- apply: async () => {
12015
- if (planned === null) throw new CommsError("UNEXPECTED", "not now was applied before it was planned");
12016
- const { until, latest, changed } = planned;
12017
- if (changed) await changeUpdateCheck(core.paths.stateDir, (record) => ({ record: {
12018
- ...record,
12019
- snoozedUntil: until.toISOString()
12020
- } }));
12021
- return {
12022
- snoozedUntil: (await readUpdateCheck(core.paths.stateDir)).snoozedUntil,
12023
- latest,
12024
- changed
12025
- };
12026
- }
12027
- };
12028
- }
12029
- Object.freeze({
12030
- kind: "send",
12031
- lookup: true
12032
- });
12033
- Object.freeze({ kind: "change" });
12034
- Object.freeze({
12035
- kind: "download",
12036
- argument: "choiceId"
12037
- });
12038
- /**
12039
- * Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
12040
- * every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
12041
- * call takes where the surface says which. Only then does the call go past the stop.
12042
- *
12043
- * The key alone is not a claim. An empty `approvalId`, or an id nobody prepared, claims nothing, and let past it
12044
- * would run the tool's first-call path with an update out — a preview, or a tightening applied at once. Each tool
12045
- * refuses an approval it does not know anyway; the stop must not be what an unknown one walks around. The store
12046
- * refuses anything that is not an approval id before it looks, so that is not checked twice.
13536
+ * The update notice, where every server and every command meets it (design 2026-10-16, U1). It replaces the stop of
13537
+ * the daily update check (design 2026-09-28): nothing is refused for being behind any more. A server's first tool
13538
+ * result of its process carries one line beside the result; a command prints the same line once on stderr, at most
13539
+ * once a day for the machine, and runs.
12047
13540
  *
12048
- * Nor is every id the store holds. Records stay in it after they are used, revoked or expired, and one of those let
12049
- * any call past the stop for as long as it was kept: an old "not now" the person turned down walked `agentcomms
12050
- * channels` straight through. Such a record claims nothing any more — there is nothing left of it for the person to
12051
- * lose by waiting — except to a look-up (`lookup`), which only reads it.
12052
- *
12053
- * What is not checked here is that the approval was prepared for this very call. The store binds a change to its
12054
- * digest, and a send to its draft, which only the tool can compute; it does, when it claims the approval, and refuses
12055
- * any other. A change that needs no approval claims none, and refuses one it is handed (`gatedChange`), so an approval
12056
- * prepared for another change gets a call past the stop only to that refusal.
13541
+ * Reader only, like `update-state.ts`: what asks the registry is handed in — `refresh` to a server's notice, `check` to
13542
+ * a command's — by every package but WhatsApp, which has no network code and hands in nothing.
12057
13543
  */
12058
- async function claimsApproval(core, id, claim = {}) {
12059
- if (typeof id !== "string") return false;
12060
- const seen = await core.approvals.inspect(id, claim.kind === void 0 ? {} : { kind: claim.kind }).catch(() => null);
12061
- if (seen === null) return false;
12062
- const { stored, outcome } = seen;
12063
- if (claim.lookup === true) return stored.form === "v2" || stored.form === "legacy";
12064
- return stored.form === "v2" && (outcome.state === "pending" || outcome.claimable);
12065
- }
13544
+ /** The product, as a notice names it: never a command's name, which a sentence would read as one to run (CUE-403). */
13545
+ const PRODUCT = "agent-communications";
12066
13546
  /**
12067
- * 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
12068
- * on — the setting, `CI` and `AGENT_COMMS_UPDATE_CHECK` — the tool is not exempt, and the call claims no approval the
12069
- * person already gave, the tool does not run and the call is answered with the stop.
13547
+ * A server's notice. After a tool answers: if a newer release is out, the machine's level says so (`decideNotice`),
13548
+ * and the tool is not exempt, the result carries one line, and no later result of this process carries it again.
13549
+ * Nothing is ever refused, and nothing waits for the check: the file is read as it is.
12070
13550
  *
12071
- * The file is read as it is: the check itself never delays a call. A call carrying the id of an approval this machine
12072
- * holds goes ahead — the person said yes to exactly it, perhaps moments before the check landed — and the stop applies
12073
- * from the next new request (§2, as agreed).
13551
+ * At most once per process, on the first result there is something to say on: a server started on a day the check is
13552
+ * due learns of the release a moment after its first call, and says so on the next.
12074
13553
  */
12075
- function updateToolGate(options) {
13554
+ function updateToolNotice(options) {
12076
13555
  const exempt = new Set(options.exempt);
12077
13556
  let running = null;
12078
- return async (tool, args) => {
12079
- if (updateCheckSwitchedOff(options.env) !== null) return null;
13557
+ let given = false;
13558
+ let turn = Promise.resolve();
13559
+ return (tool) => {
13560
+ if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
12080
13561
  if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
12081
13562
  running = null;
12082
13563
  });
12083
- if (exempt.has(tool)) return null;
12084
- const pending = await pendingUpdate({
12085
- ...options,
12086
- surface: "server"
13564
+ if (given || exempt.has(tool)) return Promise.resolve(null);
13565
+ const decided = turn.then(async () => {
13566
+ if (given) return null;
13567
+ const decision = await decideNotice(options, {
13568
+ channel: options.channel,
13569
+ surface: "server"
13570
+ });
13571
+ if (decision === null) return null;
13572
+ given = true;
13573
+ return serverDecisionLine(decision, options.env);
12087
13574
  });
12088
- if (pending === null) return null;
12089
- const claim = options.approvals?.[tool];
12090
- if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
12091
- return stoppedCall(pending, {
12092
- server: options.server,
12093
- channel: options.channel,
12094
- tool
12095
- }, updateCommands(options.core));
13575
+ turn = decided.catch(() => null);
13576
+ return decided.catch(() => null);
12096
13577
  };
12097
13578
  }
13579
+ /** How long a command's notice is good for, by level: a day, or 14 days on `every-14-days`. */
13580
+ function spacingOf(updates, surface) {
13581
+ if (updates.level === "every-14-days") return SPACED_INTERVAL_MS;
13582
+ return surface === "command" ? COMMAND_NOTICE_INTERVAL_MS : null;
13583
+ }
13584
+ const majorOf = (version) => Number(version.split(".")[0]);
12098
13585
  /**
12099
- * The answer to a stopped call. Its text opens with the owner's sentence, word for word, and says the rest in prose:
12100
- * a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
12101
- * `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
13586
+ * What the record means for this process's notice now — or null, with nothing written — decided and recorded under the
13587
+ * file's lock, so two processes deciding together say it once where the level says once (design 2026-10-16, U2, U3,
13588
+ * U7):
13589
+ *
13590
+ * - switched off for the process — `CI`, `AGENT_COMMS_UPDATE_CHECK=off`, `AGENT_COMMS_UPDATES=never` — nothing;
13591
+ * - a security fix (the latest release's manifest says so) is said on `every-14-days`, on `never`, and on `auto` while
13592
+ * its background job is not set up, whatever the pause or the 14-day spacing: the job installs one within a day;
13593
+ * - otherwise nothing on `never`, nothing during a pause, and on `auto` with its job set up only what needs the person:
13594
+ * a restart, a major release, a release needing a decision;
13595
+ * - `every-14-days` says it at most once in 14 days for the machine, and a command at most once a day.
13596
+ *
13597
+ * Beside it, by the same rules of level and pause: what the last background run left (U5 step 7), said by the first
13598
+ * process it applies to and then cleared from the file — "installed" applies only to a process behind what was
13599
+ * installed — and the registrations still naming a versioned runtime (U6), spaced as the notice is.
12102
13600
  */
12103
- function stoppedCall(pending, where, commands) {
12104
- const message = updateStopMessage(pending, where, commands);
12105
- const ways = updateWaysOf(commands);
12106
- const leaving = (value) => JSON.parse(JSON.stringify(value));
12107
- return {
12108
- isError: true,
12109
- content: [{
12110
- type: "text",
12111
- text: message
12112
- }],
12113
- structuredContent: leaving({ error: {
12114
- code: "UPDATE_REQUIRED",
12115
- message,
12116
- 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.",
12117
- details: {
12118
- tool: where.tool,
12119
- server: where.server,
12120
- running: pending.running,
12121
- latest: pending.latest,
12122
- installed: pending.kind === "restart",
12123
- update: ways.update,
12124
- later: ways.later
13601
+ async function decideNotice(options, where) {
13602
+ if (updateCheckSwitchedOff(options.env) !== null) return null;
13603
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
13604
+ const updates = await effectiveUpdates(options.core, options.env, now());
13605
+ const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
13606
+ const offerJob = updates.level === "auto" && !updates.background && await jobCanBeSetUp(options.core, updates);
13607
+ const spacing = spacingOf(updates, where.surface);
13608
+ const reach = updates.background ? await jobReach(options.core, options.env, where.channel) : null;
13609
+ /** What the level and the spacing let this process say now, or null: decided once as read, then under the lock. */
13610
+ const due = (record, at) => {
13611
+ let notice = noticeOf(record, options.running, where, updates, at, reach);
13612
+ const last = where.surface === "command" && updates.level !== "every-14-days" ? record.commandNoticeAt : record.noticeAt;
13613
+ const spaced = spacing !== null && within(last, at, spacing);
13614
+ if (notice !== null) {
13615
+ if (!notice.security) {
13616
+ if (spaced) notice = null;
13617
+ } else if (where.surface === "command" && within(record.commandNoticeAt, at, 864e5)) notice = null;
13618
+ }
13619
+ const quiet = updates.level === "never" || pauseEnd(record, at) !== null;
13620
+ const background = quiet ? null : backgroundFor(record.backgroundNotice, options.running);
13621
+ const moves = quiet || spaced ? 0 : record.moves ?? 0;
13622
+ const jobSilent = quiet || spaced ? null : updates.jobSilent?.since ?? null;
13623
+ if (notice === null && background === null && moves === 0 && jobSilent === null) return null;
13624
+ return {
13625
+ notice,
13626
+ background,
13627
+ moves,
13628
+ jobSilent,
13629
+ offerJob: offerJob && (notice !== null || jobSilent !== null)
13630
+ };
13631
+ };
13632
+ if (due(await readUpdateCheck(options.core.paths.stateDir), now()) === null) return null;
13633
+ return (await changeUpdateCheck(options.core.paths.stateDir, (record) => {
13634
+ const at = now();
13635
+ const decision = due(record, at);
13636
+ if (decision === null) return null;
13637
+ const stamp = at.toISOString();
13638
+ const spaces = decision.notice !== null || decision.moves > 0 || (decision.jobSilent ?? null) !== null;
13639
+ const written = {
13640
+ ...record,
13641
+ ...spaces ? { noticeAt: stamp } : {},
13642
+ ...spaces && where.surface === "command" ? { commandNoticeAt: stamp } : {},
13643
+ ...decision.background !== null ? { backgroundNotice: null } : {}
13644
+ };
13645
+ const { notice } = decision;
13646
+ const kind = notice?.kind === "restart" && where.surface === "server" && startedBy ? "update" : notice?.kind;
13647
+ return {
13648
+ record: written,
13649
+ result: {
13650
+ ...decision,
13651
+ notice: notice === null || kind === void 0 ? null : {
13652
+ ...notice,
13653
+ kind
13654
+ }
12125
13655
  }
12126
- } })
13656
+ };
13657
+ })).result ?? null;
13658
+ }
13659
+ /**
13660
+ * What a background run left, when it is this process's to say: an install only to a process running a release before
13661
+ * it — one already running it has nothing to restart into — and anything else, a refusal, a skip, a removed job, to
13662
+ * any process.
13663
+ */
13664
+ function backgroundFor(left, running) {
13665
+ if (left === null || left === void 0) return null;
13666
+ if (left.outcome !== "installed") return left;
13667
+ return left.version !== null && isVersion(running) && isBehind(running, left.version) ? left : null;
13668
+ }
13669
+ /**
13670
+ * Whether the background job could be set up here, so offering it is not offering a refusal (design 2026-10-16 U4):
13671
+ * a platform it is written for, no organisation turning background updates off, and the stable core launcher it runs.
13672
+ */
13673
+ async function jobCanBeSetUp(core, updates) {
13674
+ if (updates.backgroundOff !== void 0 || !updateJobSupported(process.platform)) return false;
13675
+ try {
13676
+ return (await stat(launcherPaths(core.paths.dataDir, "core").launcher)).isFile();
13677
+ } catch {
13678
+ return false;
13679
+ }
13680
+ }
13681
+ /** Whether the job reaches a process of `name`'s launcher (`JobReach`); null where it does not, or it cannot be read. */
13682
+ async function jobReach(core, env, name) {
13683
+ const launched = env[LAUNCHER_ENV]?.trim();
13684
+ if (name === void 0 || launched !== name) return null;
13685
+ try {
13686
+ const pointer = await readPointer(core.paths.dataDir, name);
13687
+ if (pointer === void 0) return null;
13688
+ const folder = basename(pointer.current);
13689
+ const version = folder.endsWith(`-${name}`) ? folder.slice(0, -(name.length + 1)) : "";
13690
+ return { pointed: isPlainVersion(version) ? version : null };
13691
+ } catch {
13692
+ return null;
13693
+ }
13694
+ }
13695
+ /**
13696
+ * Whether the job covers a security fix for this process: it reaches it (`jobReach`), it installs the fix on its own —
13697
+ * not a new major, nor one needing a decision, which it leaves for the person — and its launcher does not start the
13698
+ * fix yet, so the job installs it within a day. Once it does, the process still runs the release before: it is told.
13699
+ */
13700
+ function coveredByJob(reach, fix, record, running) {
13701
+ if (reach === null) return false;
13702
+ const from = reach.pointed ?? running;
13703
+ if (!isVersion(from) || majorOf(fix) > majorOf(from) || record.release?.needsDecision === true) return false;
13704
+ return !(reach.pointed !== null && !isBehind(reach.pointed, fix));
13705
+ }
13706
+ /** The notice the record makes for this process, before any spacing: see `decideNotice`. */
13707
+ function noticeOf(record, running, where, updates, at, reach) {
13708
+ const pending = updateVerdict(record, running, where);
13709
+ if (pending === null) return null;
13710
+ const background = updates.background && reach !== null;
13711
+ const fix = securityRelease(record, running);
13712
+ const security = fix !== null && !(background && coveredByJob(reach, fix, record, running));
13713
+ const pausedUntil = pauseEnd(record, at);
13714
+ const facts = record.release?.version === pending.latest ? record.release : null;
13715
+ const notice = {
13716
+ ...pending,
13717
+ security,
13718
+ major: background && isVersion(running) && majorOf(pending.latest) > majorOf(running),
13719
+ needsDecision: background && facts?.needsDecision === true,
13720
+ pausedUntil
12127
13721
  };
13722
+ if (security) return notice;
13723
+ if (updates.level === "never" || pausedUntil !== null) return null;
13724
+ if (background && pending.kind !== "restart" && !notice.major && !notice.needsDecision) return null;
13725
+ return notice;
13726
+ }
13727
+ /** Who started a server from a release it pins itself, in words: the plugin's, the extension's. */
13728
+ function startedByWords(startedBy) {
13729
+ if (startedBy === "claude-code-plugin") return "the Claude Code plugin";
13730
+ if (startedBy === "gemini-extension") return "the Gemini extension";
13731
+ return "a plugin or an extension";
13732
+ }
13733
+ /**
13734
+ * The server's line. "Installed" only on the check's positive finding that restarting starts `latest`; a server a
13735
+ * plugin or an extension started is updated where it was installed, which `comms_update` never moves.
13736
+ */
13737
+ function serverNoticeLine(notice, env = {}) {
13738
+ const paused = pausedWords(notice);
13739
+ if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; restart the client (in Claude Code, /mcp → Reconnect) to use it.${paused}`;
13740
+ const startedBy = env[UPDATE_STARTED_BY_ENV]?.trim();
13741
+ 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}`;
13742
+ return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; say "update" ${notice.security ? "soon" : "when it suits you"}.${paused}`;
13743
+ }
13744
+ /**
13745
+ * The line for a background job that has not run in 48 hours (U4, U7): as though there were none, every notice and the
13746
+ * security line are said again, and this says why, and where to look.
13747
+ */
13748
+ function jobSilentLine(since) {
13749
+ const at = new Date(since);
13750
+ const day = Number.isFinite(at.getTime()) ? localStamp(at).slice(0, 10) : "it was set up";
13751
+ return `${PRODUCT}: background updates have not run since ${day}; check Login Items (macOS) or run doctor.`;
13752
+ }
13753
+ /** How many registrations, in words: "one registration", "2 registrations". */
13754
+ const registrations = (count) => count === 1 ? "one registration" : `${count} registrations`;
13755
+ /**
13756
+ * A server's one line, from everything `decideNotice` decided: the background run's word first, then the release this
13757
+ * server is behind — unless the run's word is about that same release — then the move to the stable launcher, and the
13758
+ * offer of the background job.
13759
+ */
13760
+ function serverDecisionLine(decision, env = {}) {
13761
+ const { notice, background, moves } = decision;
13762
+ const parts = [];
13763
+ if (background !== null) parts.push(backgroundNoticeLine(background));
13764
+ if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(serverNoticeLine(notice, env));
13765
+ if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
13766
+ 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.`);
13767
+ 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.");
13768
+ return parts.join(" ");
12128
13769
  }
12129
- /** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
13770
+ /** What is particular about the release, after "is out": a security fix, a major release, a decision it needs. */
13771
+ function whatItIs(notice) {
13772
+ if (notice.security) return ", and fixes a security problem";
13773
+ if (notice.major) return ", a major release, which waits for you";
13774
+ if (notice.needsDecision) return ", and needs a decision from you before it installs";
13775
+ return "";
13776
+ }
13777
+ /** Every notice during a pause says when it ends (U3): only a security fix is said then. */
13778
+ function pausedWords(notice) {
13779
+ if (!notice.pausedUntil) return "";
13780
+ return ` Updates are paused until ${localStamp(notice.pausedUntil)}; a security fix is not held.`;
13781
+ }
13782
+ /** The commands that never print the notice, by their first word; `mcp` alone runs a server, which has its own. */
12130
13783
  const EXEMPT_COMMANDS = Object.freeze([
12131
13784
  "update",
12132
13785
  "doctor",
@@ -12136,201 +13789,105 @@ const EXEMPT_COMMANDS = Object.freeze([
12136
13789
  "help"
12137
13790
  ]);
12138
13791
  /**
12139
- * Whether a command is left alone by the update gate: `update` in every form, `doctor`, `paths`, `approve`,
12140
- * `approvals`, help, and `mcp` alone — the server, which gates every call itself. `mcp install` and `mcp prune` are
12141
- * not: they register and prune at this command's release. `also` adds a channel's own — WhatsApp's `status`, its
12142
- * doctor. `--help` and `--version` never reach a command.
13792
+ * Whether a command is left alone by the update notice: `update` in every form, `doctor`, `paths`, `approve`,
13793
+ * `approvals`, help, and `mcp` alone — the server, which gives its own notice. `mcp install` and `mcp prune` are not.
13794
+ * `also` adds a channel's own — WhatsApp's `status`, its doctor. `--help` and `--version` never reach a command.
12143
13795
  */
12144
- function exemptFromUpdateGate(path, also = []) {
13796
+ function exemptFromUpdateNotice(path, also = []) {
12145
13797
  const [first] = path;
12146
13798
  if (first === void 0) return true;
12147
13799
  if (EXEMPT_COMMANDS.includes(first) || also.includes(first)) return true;
12148
13800
  return first === "mcp" && path.length === 1;
12149
13801
  }
12150
- /** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateGate` reads. */
13802
+ /** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateNotice` reads. */
12151
13803
  function commandPathOf(command) {
12152
13804
  const path = [];
12153
13805
  for (let at = command; at?.parent; at = at.parent) path.unshift(at.name());
12154
13806
  return path;
12155
13807
  }
13808
+ /** How often a command says it, for the whole machine: once a day. */
13809
+ const COMMAND_NOTICE_INTERVAL_MS = 864e5;
12156
13810
  /**
12157
- * The approvals a Commander command claims: its `--approval`, the `--mcp-approval` Gmail's `setup` carries beside it,
12158
- * the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
12159
- * cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
12160
- * What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
12161
- * by the time a `preAction` hook runs.
12162
- */
12163
- function approvalsOf(command) {
12164
- const options = command.opts();
12165
- const named = (command.registeredArguments ?? []).flatMap((argument, index) => argument.name() === "approvalId" ? [command.processedArgs?.[index]] : []);
12166
- return [
12167
- options.approval,
12168
- options.mcpApproval,
12169
- options.choice,
12170
- ...named
12171
- ];
12172
- }
12173
- /**
12174
- * The update gate at a terminal, before a command runs. Resolves to null to run the command, or to the exit status to
12175
- * end with instead; throws `UPDATE_REQUIRED` (exit 11) where nobody can be asked.
12176
- *
12177
- * It reads the file first. If the file is a day old it asks the registry — through `check` — waiting at most about
12178
- * three seconds and going on without it after that. Then, when a newer release is out, nothing puts it off, and the
12179
- * command claims no approval the person already gave:
13811
+ * The update notice at a terminal, before a command runs. It never stops the command, and never throws.
12180
13812
  *
12181
- * - a person at a terminal (stdin and stdout a terminal, no `--json`, not `CI`, not an agent) is asked "Update now,
12182
- * later today, or cancel?". Now runs the update, with its own preview and yes, and ends asking them to run the
12183
- * command again; later records "not now" — their answer is the approval — and runs the command; cancel ends, having
12184
- * done nothing;
12185
- * - anything else — a script, cron, an agent, `--json` — gets `UPDATE_REQUIRED`, naming `agentcomms update` and
12186
- * `agentcomms update --later`, and the command does not run.
12187
- *
12188
- * An update installed and not yet run by this command — this command's package is installed globally at the latest
12189
- * release, and what is running is an older copy from somewhere else — stops it the same way, as the servers stop for
12190
- * a client not yet restarted (§3: the terminal stops as chat does). Its "now" says to run the command again from the
12191
- * installed one: there is nothing to update.
13813
+ * It reads the file first. If the check is due for the machine's level it asks the registry — through `check` — waiting
13814
+ * at most about three seconds and going on without it after that. Then, when the level says so (`decideNotice`), it
13815
+ * prints one line on stderr — never on stdout, where a result or `--json` is — and the command runs.
12192
13816
  */
12193
- async function updateGateAtTerminal(options) {
12194
- const { core, env, streams, output } = options;
13817
+ async function updateNoticeAtTerminal(options) {
13818
+ const { core, env, streams } = options;
12195
13819
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
12196
- if (!(await updateCheckEnabled(core, env)).on) return null;
12197
- if (options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now())) {
12198
- let timer;
12199
- const gaveUp = new Promise((resolve) => {
12200
- timer = setTimeout(resolve, options.waitMs ?? 3e3);
12201
- });
12202
- try {
12203
- await Promise.race([options.check().catch(() => void 0), gaveUp]);
12204
- } finally {
12205
- clearTimeout(timer);
13820
+ try {
13821
+ if (updateCheckSwitchedOff(env) !== null) return;
13822
+ const enabled = await updateCheckEnabled(core, env);
13823
+ if (enabled.on && options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now(), enabled.intervalMs)) {
13824
+ let timer;
13825
+ const gaveUp = new Promise((resolve) => {
13826
+ timer = setTimeout(resolve, options.waitMs ?? 3e3);
13827
+ });
13828
+ try {
13829
+ await Promise.race([options.check().catch(() => void 0), gaveUp]);
13830
+ } finally {
13831
+ clearTimeout(timer);
13832
+ }
12206
13833
  }
12207
- }
12208
- for (const id of options.approvals ?? []) if (await claimsApproval(core, id, options.approvalClaim)) return null;
12209
- const where = {
13834
+ const line = await commandNotice(options, now);
13835
+ if (line !== null) streams.stderr.write(`${line}\n`);
13836
+ } catch {}
13837
+ }
13838
+ /** The command's line, decided and recorded under the file's lock: see `decideNotice`. */
13839
+ async function commandNotice(options, now) {
13840
+ const decision = await decideNotice({
13841
+ ...options,
13842
+ now
13843
+ }, {
12210
13844
  channel: options.channel,
12211
13845
  surface: "command"
12212
- };
12213
- const pending = await pendingUpdate({
12214
- core,
12215
- env,
12216
- running: options.running,
12217
- now,
12218
- ...where
12219
- });
12220
- if (pending === null) return null;
12221
- const person = agentMarker(env) === null && canPrompt(env, streams, {
12222
- json: output.json,
12223
- noInput: options.noInput === true
12224
13846
  });
12225
- const commands = updateCommands(core, output.platform);
12226
- if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
12227
- const bold = (word) => paint(output.color, "bold", word);
12228
- 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();
12229
- if (answer === "now" || answer === "update") {
12230
- if (pending.kind === "restart") {
12231
- 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`);
12232
- return EXIT_CODES.UPDATE;
12233
- }
12234
- if (!options.update) {
12235
- 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`);
12236
- return EXIT_CODES.UPDATE;
12237
- }
12238
- return afterUpdate(await options.update(), options, pending);
12239
- }
12240
- if (answer === "later" || answer === "l") {
12241
- const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
12242
- channel: options.channel,
12243
- env,
12244
- output,
12245
- rerun: ["update", "--later"],
12246
- rerunOn: "core",
12247
- answered: true,
12248
- streams
12249
- });
12250
- streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
12251
- return null;
12252
- }
12253
- 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}.`) });
13847
+ if (decision === null) return null;
13848
+ return commandDecisionLine(options.core, decision, options.channel, options.output.platform);
12254
13849
  }
12255
13850
  /**
12256
- * The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
12257
- * command most people can run, and which a sentence would read as one (CUE-403).
13851
+ * The CLI a notice names, in product words — "Gmail CLI", "core CLI" — never its binary, which is no command most
13852
+ * people can run, and which a sentence would read as one (CUE-403).
12258
13853
  */
12259
13854
  function cliOf(channel) {
12260
13855
  if (channel === "core") return "core CLI";
12261
13856
  return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
12262
13857
  }
12263
13858
  /**
12264
- * The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
12265
- * — the update installed, and an older copy running — says to run the command from the installed one.
13859
+ * The command's line: the update, as core's own command located from what is printing — or why there is none here —
13860
+ * or, when this command's package is installed globally at `latest` and an older copy is running, to run that one.
12266
13861
  */
12267
- function updateRequired(pending, cli, commands) {
12268
- const ways = updateWaysOf(commands);
12269
- const details = {
12270
- running: pending.running,
12271
- latest: pending.latest,
12272
- installed: pending.kind === "restart",
12273
- update: ways.update,
12274
- later: ways.later
12275
- };
12276
- 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`)}`, {
12277
- 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.`,
12278
- details
12279
- });
12280
- 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`)}`, {
12281
- 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.`,
12282
- details
12283
- });
12284
- }
12285
- /** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
12286
- function bothSaid(commands, say) {
12287
- return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
13862
+ function commandNoticeLine(core, notice, channel, platform) {
13863
+ const paused = pausedWords(notice);
13864
+ 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}`;
13865
+ const update = requireHandoffs(core, platform).core(["update"]);
13866
+ const when = notice.security ? "soon" : "when it suits you";
13867
+ return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)} (this is ${notice.running}); ${handoffSentence(update, (command) => `run ${command} ${when}.`)}${paused}`;
12288
13868
  }
12289
13869
  /**
12290
- * What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
12291
- * the update did everything and this command's release is now installed where it runs from — its global package at
12292
- * the latest. Anything short of that says what, and ends with a status that is not 0: the command has not run, and a
12293
- * script that goes on after `cmd && next` must not take it for the command's success.
13870
+ * A command's one line, from everything `decideNotice` decided, as `serverDecisionLine` puts it together — every
13871
+ * command named located for this installation, or why there is none here.
12294
13872
  */
12295
- async function afterUpdate(outcome, options, before) {
12296
- const { streams } = options;
12297
- const cli = cliOf(options.channel);
12298
- const notRun = "The command did not run";
12299
- const commands = updateCommands(options.core, options.output.platform);
12300
- if (outcome === "failed") {
12301
- streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
12302
- return EXIT_CODES.UNAVAILABLE;
12303
- }
12304
- const after = updateVerdict(await readUpdateCheck(options.core.paths.stateDir), options.running, {
12305
- channel: options.channel,
12306
- surface: "command"
12307
- });
12308
- if (after === null || after.kind === "restart") {
12309
- if (outcome === "updated") {
12310
- streams.stdout.write("Updated. Run your command again.\n");
12311
- return EXIT_CODES.OK;
12312
- }
12313
- streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
12314
- return EXIT_CODES.UPDATE;
12315
- }
12316
- 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`);
12317
- return EXIT_CODES.UPDATE;
12318
- }
12319
- async function askLine(streams, question) {
12320
- const { createInterface } = await import("node:readline/promises");
12321
- const rl = createInterface({
12322
- input: streams.stdin,
12323
- output: streams.stderr
12324
- });
12325
- try {
12326
- return await rl.question(question);
12327
- } finally {
12328
- rl.close();
12329
- }
13873
+ function commandDecisionLine(core, decision, channel, platform) {
13874
+ const { notice, background, moves } = decision;
13875
+ const handoffs = requireHandoffs(core, platform);
13876
+ const parts = [];
13877
+ if (background !== null) parts.push(backgroundNoticeLine(background));
13878
+ if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(commandNoticeLine(core, notice, channel, platform));
13879
+ if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
13880
+ 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.`)}`);
13881
+ if (decision.offerJob) parts.push(handoffSentence(handoffs.core([
13882
+ "update",
13883
+ "--background-job",
13884
+ "on"
13885
+ ]), (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.`));
13886
+ return parts.join(" ");
12330
13887
  }
12331
13888
  channelServer("core").packageName;
12332
13889
  //#endregion
12333
- //#region ../core/dist/tool-arguments-JbqHfSTd.mjs
13890
+ //#region ../core/dist/tool-arguments-4c3WR2Gy.mjs
12334
13891
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
12335
13892
  const TARGET = "draft-2020-12";
12336
13893
  /**
@@ -12342,13 +13899,14 @@ const TARGET = "draft-2020-12";
12342
13899
  * `refuse` is handed a `USAGE` `CommsError` and returns the tool result, so the refusal is in the same envelope as
12343
13900
  * every other refusal that server makes.
12344
13901
  *
12345
- * `gate` is asked next, with the checked arguments, before the handler: the daily update check's stop (design
12346
- * 2026-09-28), which every server builds with `updateToolGate`. Here because this is the one place every tool of every
12347
- * server already passes through, so no tool can be registered without it; given to the wrapper rather than built
12348
- * in, because what it asks the registry with is each server's own — and WhatsApp's is nothing at all. A call it
12349
- * answers reaches nothing, as a refused one does.
13902
+ * `notice` is asked once the handler has answered: the update notice (design 2026-10-16, U1), which every server builds
13903
+ * with `updateToolNotice`. Here because this is the one place every tool of every server already passes through, so no
13904
+ * tool can be registered without it; given to the wrapper rather than built in, because what it asks the registry
13905
+ * with is each server's own — and WhatsApp's is nothing at all. It never stops a call: a line it gives is carried
13906
+ * beside the result (`withUpdateNotice`), and a tool's output schema is published with room for it
13907
+ * (`withUpdateField`), so a client that checks a result against its schema takes the one that carries it.
12350
13908
  */
12351
- function strictToolArguments(server, refuse, gate, retired = {}) {
13909
+ function strictToolArguments(server, refuse, notice, retired = {}) {
12352
13910
  const target = server;
12353
13911
  if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
12354
13912
  if (target[APPLIED]) return;
@@ -12359,15 +13917,21 @@ function strictToolArguments(server, refuse, gate, retired = {}) {
12359
13917
  const declared = config.inputSchema !== void 0;
12360
13918
  const schema = strictSchemaOf(name, config.inputSchema);
12361
13919
  const published = schema["~standard"].jsonSchema.input({ target: TARGET });
12362
- return register(name, {
13920
+ const output = notice && config.outputSchema !== void 0 ? withUpdateField(config.outputSchema) : null;
13921
+ const room = config.outputSchema === void 0 || output !== null;
13922
+ const registered = output === null ? config : {
12363
13923
  ...config,
13924
+ outputSchema: output
13925
+ };
13926
+ return register(name, {
13927
+ ...registered,
12364
13928
  inputSchema: passThrough(schema)
12365
13929
  }, async (input, context) => {
12366
13930
  const parsed = await schema.safeParseAsync(input ?? {});
12367
13931
  if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
12368
- const stopped = gate ? await gate(name, parsed.data) : null;
12369
- if (stopped !== null) return stopped;
12370
- return declared ? handler(parsed.data, context) : handler(context);
13932
+ const result = await (declared ? handler(parsed.data, context) : handler(context));
13933
+ const line = notice ? await notice(name).catch(() => null) : null;
13934
+ return line === null ? result : withUpdateNotice(result, line, room);
12371
13935
  });
12372
13936
  };
12373
13937
  Object.defineProperty(target, APPLIED, { value: true });
@@ -12407,6 +13971,83 @@ function passThrough(schema) {
12407
13971
  jsonSchema: schema["~standard"].jsonSchema
12408
13972
  } };
12409
13973
  }
13974
+ /** The field a result carries the update notice in, as every output schema publishes it. */
13975
+ const UPDATE_FIELD = "update";
13976
+ const UPDATE_PROPERTY = {
13977
+ type: "string",
13978
+ 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."
13979
+ };
13980
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
13981
+ /**
13982
+ * A result with the notice beside it: a text block of its own after the result's — a client that shows the model only
13983
+ * text reads it there — and, where the structured content is an object with room for it, its `update` field, which is
13984
+ * all Claude Code and Codex show. Nothing of the result is changed or taken away.
13985
+ */
13986
+ function withUpdateNotice(result, line, room = true) {
13987
+ if (!isPlainObject(result)) return result;
13988
+ const content = Array.isArray(result.content) ? result.content : [];
13989
+ const next = {
13990
+ ...result,
13991
+ content: [...content, {
13992
+ type: "text",
13993
+ text: line
13994
+ }]
13995
+ };
13996
+ const structured = result.structuredContent;
13997
+ if (room && isPlainObject(structured) && !Object.hasOwn(structured, UPDATE_FIELD)) next.structuredContent = {
13998
+ ...structured,
13999
+ [UPDATE_FIELD]: line
14000
+ };
14001
+ return next;
14002
+ }
14003
+ /** A JSON Schema with the `update` field among its properties — each side of a union — or null with no room for it. */
14004
+ function publishedWithUpdate(json) {
14005
+ const alternatives = json.anyOf ?? json.oneOf;
14006
+ if (Array.isArray(alternatives) && json.properties === void 0) {
14007
+ const each = alternatives.map(publishedWithUpdate);
14008
+ if (each.some((alternative) => alternative === null)) return null;
14009
+ return {
14010
+ ...json,
14011
+ ...json.anyOf ? { anyOf: each } : { oneOf: each }
14012
+ };
14013
+ }
14014
+ if (!(Array.isArray(json.type) ? json.type : [json.type]).includes("object") || json.properties === void 0) return null;
14015
+ if (Object.hasOwn(json.properties, UPDATE_FIELD)) return null;
14016
+ return {
14017
+ ...json,
14018
+ properties: {
14019
+ ...json.properties,
14020
+ [UPDATE_FIELD]: UPDATE_PROPERTY
14021
+ }
14022
+ };
14023
+ }
14024
+ /**
14025
+ * A tool's output schema, published with room for the `update` field: the same schema, with that one property added
14026
+ * — a zod object publishes `additionalProperties: false`, and a client that checks a result against it would refuse
14027
+ * the one carrying the notice — and checked as it always was, without the field. Null when the schema has no room for
14028
+ * it: one that declares `update` itself, or is not an object; that tool's notice is a text block alone.
14029
+ */
14030
+ function withUpdateField(outputSchema) {
14031
+ const standard = (isPlainObject(outputSchema) && !("~standard" in outputSchema) && Object.values(outputSchema).every(isZodSchema) ? object(outputSchema) : outputSchema)?.["~standard"];
14032
+ if (typeof standard?.validate !== "function" || standard.jsonSchema === void 0) return null;
14033
+ const { jsonSchema } = standard;
14034
+ const input = publishedWithUpdate(jsonSchema.input({ target: TARGET }));
14035
+ const output = publishedWithUpdate(jsonSchema.output({ target: TARGET }));
14036
+ if (input === null || output === null) return null;
14037
+ return { "~standard": {
14038
+ version: 1,
14039
+ vendor: "agentcomms",
14040
+ validate: (value) => {
14041
+ if (!isPlainObject(value) || !Object.hasOwn(value, UPDATE_FIELD)) return standard.validate(value);
14042
+ const { [UPDATE_FIELD]: _notice, ...rest } = value;
14043
+ return standard.validate(rest);
14044
+ },
14045
+ jsonSchema: {
14046
+ input: () => input,
14047
+ output: () => output
14048
+ }
14049
+ } };
14050
+ }
12410
14051
  /** A USAGE refusal naming what was wrong and what the tool takes. */
12411
14052
  function refusal(tool, published, input, issues, retired) {
12412
14053
  const properties = published.properties ?? {};
@@ -16039,4 +17680,4 @@ async function syncAccount(context, request) {
16039
17680
  });
16040
17681
  }
16041
17682
  //#endregion
16042
- 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 };
17683
+ 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 };