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