@agentcomms/whatsapp 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +12 -25
- package/dist/index.d.mts +44 -3
- package/dist/index.mjs +2 -2
- package/dist/{server-YS_r6FYq.mjs → server-DP6vz_rw.mjs} +2 -3
- package/dist/{stdio-entry-c95dpKwO.mjs → stdio-entry-83qlMjK3.mjs} +1 -1
- package/dist/{sync-Q9xVuh21.mjs → sync-Coys7_vB.mjs} +2282 -842
- 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.20.0";
|
|
15
15
|
//#endregion
|
|
16
16
|
//#region src/caller.ts
|
|
17
17
|
/** This package's name, as its manifest has it. */
|
|
@@ -28,23 +28,20 @@ const CALLER = Object.freeze({
|
|
|
28
28
|
packageName: PACKAGE_NAME
|
|
29
29
|
});
|
|
30
30
|
//#endregion
|
|
31
|
-
//#region ../core/dist/update-check-
|
|
31
|
+
//#region ../core/dist/update-check-N_-uYKZQ.mjs
|
|
32
32
|
/**
|
|
33
33
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
34
34
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
35
|
-
* on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10
|
|
35
|
+
* on the code, humans read the message. Exit statuses follow BSD sysexits where one fits; 10 is ours.
|
|
36
|
+
*
|
|
37
|
+
* 11 was ours too, for a command the daily update check stopped (design 2026-09-28). Nothing is stopped for being behind
|
|
38
|
+
* any more (design 2026-10-16, U1), so `UPDATE_REQUIRED` and 11 are retired: a client that still sees one is talking to
|
|
39
|
+
* a server older than 0.20.0.
|
|
36
40
|
*/
|
|
37
41
|
const EXIT_CODES = {
|
|
38
42
|
OK: 0,
|
|
39
43
|
UNEXPECTED: 1,
|
|
40
44
|
APPROVAL: 10,
|
|
41
|
-
/**
|
|
42
|
-
* A command that did not run because a newer release is out and nobody at a terminal could be asked about it
|
|
43
|
-
* (design 2026-09-28 §3). None of sysexits' codes means "run something else first", and a script that branches on
|
|
44
|
-
* it has exactly two things to do — update, or put it off — so it has one of its own. 11, beside 10: both mean a
|
|
45
|
-
* person has to decide before the command can go on.
|
|
46
|
-
*/
|
|
47
|
-
UPDATE: 11,
|
|
48
45
|
USAGE: 64,
|
|
49
46
|
BAD_DATA: 65,
|
|
50
47
|
NOT_FOUND: 66,
|
|
@@ -109,11 +106,6 @@ const ERROR_REGISTRY = {
|
|
|
109
106
|
retryable: false,
|
|
110
107
|
summary: "a safety setting can only be loosened by a person"
|
|
111
108
|
},
|
|
112
|
-
UPDATE_REQUIRED: {
|
|
113
|
-
exit: EXIT_CODES.UPDATE,
|
|
114
|
-
retryable: false,
|
|
115
|
-
summary: "a newer release is out: update first, or put it off until tomorrow"
|
|
116
|
-
},
|
|
117
109
|
USAGE: {
|
|
118
110
|
exit: EXIT_CODES.USAGE,
|
|
119
111
|
retryable: false,
|
|
@@ -983,11 +975,26 @@ const ACT_KINDS = Object.freeze([
|
|
|
983
975
|
"file",
|
|
984
976
|
"send"
|
|
985
977
|
]);
|
|
978
|
+
/**
|
|
979
|
+
* How an `agent` act looks in one Slack workspace (the owner's decision, 2026-10-17): `app`, the default, posts as the
|
|
980
|
+
* app where S2 says it can, with Slack's APP tag, and labelled as the person elsewhere; `labelled` posts every one as
|
|
981
|
+
* the person, labelled as S3's fallback is, even where the app could post. Slack's APP tag cannot be hidden or renamed,
|
|
982
|
+
* so this is the person's choice between that tag and words in the message.
|
|
983
|
+
*/
|
|
984
|
+
const SLACK_STYLES = Object.freeze(["app", "labelled"]);
|
|
986
985
|
/** L2's characters a name may not hold: parentheses would fake a second label, the rest break an address header. */
|
|
987
986
|
const FORBIDDEN_NAME_CHARACTERS = /[()<>"@,;:\\]/u;
|
|
988
987
|
function isAgentLabelWord(value) {
|
|
989
988
|
return typeof value === "string" && AGENT_LABEL_WORDS.includes(value);
|
|
990
989
|
}
|
|
990
|
+
function isSlackStyle(value) {
|
|
991
|
+
return typeof value === "string" && SLACK_STYLES.includes(value);
|
|
992
|
+
}
|
|
993
|
+
/** A Slack workspace's style, by its account id: `app` unless `labelled` is recorded for it. */
|
|
994
|
+
function slackStyleOf(settings, accountId) {
|
|
995
|
+
const styles = settings?.slackStyles;
|
|
996
|
+
return styles !== void 0 && Object.hasOwn(styles, accountId) && styles[accountId] === "labelled" ? "labelled" : "app";
|
|
997
|
+
}
|
|
991
998
|
/** Why a name cannot be labelled, or undefined when it can. */
|
|
992
999
|
function nameProblem(name) {
|
|
993
1000
|
if (name.length === 0) return "the name is empty";
|
|
@@ -1004,7 +1011,9 @@ const ruleKey = (rule) => JSON.stringify([
|
|
|
1004
1011
|
]);
|
|
1005
1012
|
/**
|
|
1006
1013
|
* 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.
|
|
1014
|
+
* the label looks. Adding an `agent` rule or removing a `me` rule tightens, and is not listed. A Slack workspace moved
|
|
1015
|
+
* from `app` to `labelled` trades Slack's own APP tag, which no one can fake, for words in the message, so it waits;
|
|
1016
|
+
* moved back to `app`, it tightens. Its values are each moved workspace's style, before and after.
|
|
1008
1017
|
*/
|
|
1009
1018
|
function classifyAgentLabelChange(before, after) {
|
|
1010
1019
|
const changes = [];
|
|
@@ -1034,6 +1043,12 @@ function classifyAgentLabelChange(before, after) {
|
|
|
1034
1043
|
was: before?.slackIcons ?? {},
|
|
1035
1044
|
now: after?.slackIcons ?? {}
|
|
1036
1045
|
});
|
|
1046
|
+
const styled = [.../* @__PURE__ */ new Set([...Object.keys(before?.slackStyles ?? {}), ...Object.keys(after?.slackStyles ?? {})])].filter((id) => slackStyleOf(before, id) !== slackStyleOf(after, id)).sort();
|
|
1047
|
+
if (styled.some((id) => slackStyleOf(after, id) === "labelled")) changes.push({
|
|
1048
|
+
path: "agentLabel.slackStyles",
|
|
1049
|
+
was: Object.fromEntries(styled.map((id) => [id, slackStyleOf(before, id)])),
|
|
1050
|
+
now: Object.fromEntries(styled.map((id) => [id, slackStyleOf(after, id)]))
|
|
1051
|
+
});
|
|
1037
1052
|
return changes;
|
|
1038
1053
|
}
|
|
1039
1054
|
function sorted(record) {
|
|
@@ -1053,7 +1068,8 @@ function isRule(value) {
|
|
|
1053
1068
|
/**
|
|
1054
1069
|
* The stored `agentLabel`, read so that a bad value can only tighten. An unknown word reads as `agent`, an invalid
|
|
1055
1070
|
* 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.
|
|
1071
|
+
* broader `me` rule in charge. A malformed icon entry is dropped, falling back to the plain photo. A Slack style that is
|
|
1072
|
+
* neither `app` nor `labelled` is dropped too, so that workspace is `app`: the stronger disclosure.
|
|
1057
1073
|
*/
|
|
1058
1074
|
function readAgentLabelSettings(raw) {
|
|
1059
1075
|
if (typeof raw !== "object" || raw === null) return {};
|
|
@@ -1066,6 +1082,10 @@ function readAgentLabelSettings(raw) {
|
|
|
1066
1082
|
const icons = Object.entries(stored.slackIcons).filter((entry) => typeof entry[1] === "string" && EMOJI_NAME.test(entry[1]));
|
|
1067
1083
|
if (icons.length > 0) settings.slackIcons = Object.fromEntries(icons);
|
|
1068
1084
|
}
|
|
1085
|
+
if (typeof stored.slackStyles === "object" && stored.slackStyles !== null && !Array.isArray(stored.slackStyles)) {
|
|
1086
|
+
const styles = Object.entries(stored.slackStyles).filter((entry) => isSlackStyle(entry[1]));
|
|
1087
|
+
if (styles.length > 0) settings.slackStyles = Object.fromEntries(styles);
|
|
1088
|
+
}
|
|
1069
1089
|
return settings;
|
|
1070
1090
|
}
|
|
1071
1091
|
/** The person's label settings from a loaded configuration, read as `readAgentLabelSettings` reads them. */
|
|
@@ -3141,6 +3161,404 @@ function commandLinePackage(command, args) {
|
|
|
3141
3161
|
const line = [command, ...withoutPathOptions(args).words].join(" ");
|
|
3142
3162
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
3143
3163
|
}
|
|
3164
|
+
/** The scope every server package is published under; a launcher is named for the rest of the package name. */
|
|
3165
|
+
const SCOPE$1 = "@agentcomms";
|
|
3166
|
+
const LAUNCH = "launch";
|
|
3167
|
+
const RUNTIMES = "runtime";
|
|
3168
|
+
const POINTER = ".current";
|
|
3169
|
+
const FALLBACK = ".fallback";
|
|
3170
|
+
const LOCK = ".lock";
|
|
3171
|
+
/**
|
|
3172
|
+
* What a launcher hands the process it starts (re-review of 0.20.0, severity 6): its own name, so the process knows it
|
|
3173
|
+
* was started through its package's stable launcher — the one the background job switches — and not by npx, a
|
|
3174
|
+
* versioned or local registration, or a command from somewhere else. The update notice holds back only for such a
|
|
3175
|
+
* process what the job will install (`update-gate.ts`), and only a run of `update --background` started through the
|
|
3176
|
+
* core launcher, as the job starts it, records that the job ran (`update-background.ts`).
|
|
3177
|
+
*/
|
|
3178
|
+
const LAUNCHER_ENV = "AGENT_COMMS_LAUNCHER";
|
|
3179
|
+
/** A launcher's name: one plain word, never a path — the unscoped name of a server package. */
|
|
3180
|
+
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
3181
|
+
/** A runtime's folder in `<data>/runtime`: no separator, nothing that starts with a dot. */
|
|
3182
|
+
const FOLDER = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/;
|
|
3183
|
+
/** The version a managed runtime's folder starts with, as `mcp prune` reads one. */
|
|
3184
|
+
const VERSION$1 = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
3185
|
+
/** The CLI inside a runtime, as words from the runtime's folder: what `managedRuntimeEntry` names. */
|
|
3186
|
+
function cliWords(name) {
|
|
3187
|
+
return [
|
|
3188
|
+
"node_modules",
|
|
3189
|
+
SCOPE$1,
|
|
3190
|
+
name,
|
|
3191
|
+
"dist",
|
|
3192
|
+
"cli.mjs"
|
|
3193
|
+
];
|
|
3194
|
+
}
|
|
3195
|
+
function checkedName(name) {
|
|
3196
|
+
if (!NAME.test(name)) throw new CommsError("CONFIG", `${JSON.stringify(name)} is not a launcher name: one plain word, such as gmail`);
|
|
3197
|
+
return name;
|
|
3198
|
+
}
|
|
3199
|
+
/** The launcher a server package is started through: its name without the scope, `@agentcomms/gmail` → `gmail`. */
|
|
3200
|
+
function launcherName(packageName) {
|
|
3201
|
+
const [scope, rest, extra] = packageName.split("/");
|
|
3202
|
+
if (scope !== SCOPE$1 || rest === void 0 || extra !== void 0) throw new CommsError("CONFIG", `${packageName} is not a package a stable launcher starts`);
|
|
3203
|
+
return checkedName(rest);
|
|
3204
|
+
}
|
|
3205
|
+
/** Where a launcher and its pointer are: `<data>/launch/<name>` and `<data>/launch/<name>.current`. */
|
|
3206
|
+
function launcherPaths(dataDir, name) {
|
|
3207
|
+
const directory = join(dataDir, LAUNCH);
|
|
3208
|
+
checkedName(name);
|
|
3209
|
+
return {
|
|
3210
|
+
launcher: join(directory, name),
|
|
3211
|
+
pointer: join(directory, `${name}${POINTER}`)
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/** Where the launcher records that it fell back: `<data>/launch/<name>.fallback`. */
|
|
3215
|
+
function fallbackRecordPath(dataDir, name) {
|
|
3216
|
+
return join(dataDir, LAUNCH, `${checkedName(name)}${FALLBACK}`);
|
|
3217
|
+
}
|
|
3218
|
+
/** The CLI a runtime holds for a launcher: `<runtime>/node_modules/@agentcomms/<name>/dist/cli.mjs`. */
|
|
3219
|
+
function launcherCli(runtimeDir, name) {
|
|
3220
|
+
return join(runtimeDir, ...cliWords(checkedName(name)));
|
|
3221
|
+
}
|
|
3222
|
+
/** The version a runtime folder of this launcher is, `0.20.0` for `0.20.0-gmail`; null for any other folder. */
|
|
3223
|
+
function launcherRuntimeVersion(runtimeDir, name) {
|
|
3224
|
+
const folder = basename(runtimeDir);
|
|
3225
|
+
const suffix = `-${checkedName(name)}`;
|
|
3226
|
+
if (!folder.endsWith(suffix)) return null;
|
|
3227
|
+
const version = folder.slice(0, -suffix.length);
|
|
3228
|
+
return VERSION$1.test(version) ? version : null;
|
|
3229
|
+
}
|
|
3230
|
+
/** A runtime folder this launcher may point at: a folder of its own in `<data>/runtime`, named for it. */
|
|
3231
|
+
function isOwnFolder(folder, name) {
|
|
3232
|
+
return FOLDER.test(folder) && folder.endsWith(`-${name}`) && folder.length > name.length + 1;
|
|
3233
|
+
}
|
|
3234
|
+
/** The pointer's text: one runtime folder a line, read by the launcher with nothing but its shell's built-ins. */
|
|
3235
|
+
function pointerText(current, previous) {
|
|
3236
|
+
return [
|
|
3237
|
+
"# The runtime the stable launcher beside this file starts, written by agent-communications. Do not edit.",
|
|
3238
|
+
`current=${current}`,
|
|
3239
|
+
...previous === void 0 ? [] : [`previous=${previous}`],
|
|
3240
|
+
""
|
|
3241
|
+
].join("\n");
|
|
3242
|
+
}
|
|
3243
|
+
/** The pointer in `text`, as runtime folders, or null when it is not one this writes. */
|
|
3244
|
+
function parsePointer(text, name) {
|
|
3245
|
+
let current;
|
|
3246
|
+
let previous;
|
|
3247
|
+
for (const line of text.split(/\r?\n/)) {
|
|
3248
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
3249
|
+
const at = line.indexOf("=");
|
|
3250
|
+
const key = at < 0 ? line : line.slice(0, at);
|
|
3251
|
+
const value = at < 0 ? "" : line.slice(at + 1);
|
|
3252
|
+
if (key === "current" && current === void 0) current = value;
|
|
3253
|
+
else if (key === "previous" && previous === void 0) previous = value;
|
|
3254
|
+
else return null;
|
|
3255
|
+
}
|
|
3256
|
+
if (current === void 0 || !isOwnFolder(current, name)) return null;
|
|
3257
|
+
if (previous !== void 0 && !isOwnFolder(previous, name)) return null;
|
|
3258
|
+
return previous === void 0 ? { current } : {
|
|
3259
|
+
current,
|
|
3260
|
+
previous
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
function asPaths(dataDir, folders) {
|
|
3264
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3265
|
+
return folders.previous === void 0 ? { current: join(runtimes, folders.current) } : {
|
|
3266
|
+
current: join(runtimes, folders.current),
|
|
3267
|
+
previous: join(runtimes, folders.previous)
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
function unreadable$1(pointer, why) {
|
|
3271
|
+
return new CommsError("CONFIG", `${pointer} cannot be read as a pointer: ${why}`, { hint: "It is written by managed installs and updates, never by hand. Run a managed install or an update again: it writes the pointer afresh." });
|
|
3272
|
+
}
|
|
3273
|
+
/** The pointer's runtime folders, or undefined when there is no pointer; refused when it is not one this writes. */
|
|
3274
|
+
async function readFolders(dataDir, name) {
|
|
3275
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3276
|
+
let text;
|
|
3277
|
+
try {
|
|
3278
|
+
text = await readFile(pointer, "utf8");
|
|
3279
|
+
} catch (error) {
|
|
3280
|
+
if (error.code === "ENOENT") return void 0;
|
|
3281
|
+
throw unreadable$1(pointer, error.code ?? String(error));
|
|
3282
|
+
}
|
|
3283
|
+
const folders = parsePointer(text, name);
|
|
3284
|
+
if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
|
|
3285
|
+
return folders;
|
|
3286
|
+
}
|
|
3287
|
+
/**
|
|
3288
|
+
* The runtime a launcher starts, and the one before it; undefined when it has no pointer yet.
|
|
3289
|
+
*
|
|
3290
|
+
* A pointer that is there and is not one this writes is refused rather than taken for none: what it names may be in
|
|
3291
|
+
* use, and "no pointer" would let a prune or an update act as though nothing were.
|
|
3292
|
+
*/
|
|
3293
|
+
async function readPointer(dataDir, name) {
|
|
3294
|
+
const folders = await readFolders(dataDir, name);
|
|
3295
|
+
return folders === void 0 ? void 0 : asPaths(dataDir, folders);
|
|
3296
|
+
}
|
|
3297
|
+
/** `readPointer`, for a reader that cannot wait: the registration readers, which the command locator calls. */
|
|
3298
|
+
function readPointerSync(dataDir, name) {
|
|
3299
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3300
|
+
let text;
|
|
3301
|
+
try {
|
|
3302
|
+
text = readFileSync(pointer, "utf8");
|
|
3303
|
+
} catch (error) {
|
|
3304
|
+
if (error.code === "ENOENT") return void 0;
|
|
3305
|
+
throw unreadable$1(pointer, error.code ?? String(error));
|
|
3306
|
+
}
|
|
3307
|
+
const folders = parsePointer(text, name);
|
|
3308
|
+
if (folders === null) throw unreadable$1(pointer, `it does not name a runtime of ${name} in ${join(dataDir, RUNTIMES)}`);
|
|
3309
|
+
return asPaths(dataDir, folders);
|
|
3310
|
+
}
|
|
3311
|
+
/** Whether the CLI a runtime holds for this launcher is a file that can be read: whether the launcher can start it. */
|
|
3312
|
+
async function launcherCanStart(runtimeDir, name) {
|
|
3313
|
+
const cli = launcherCli(runtimeDir, name);
|
|
3314
|
+
try {
|
|
3315
|
+
if (!(await stat(cli)).isFile()) return false;
|
|
3316
|
+
await access(cli, constants$1.R_OK);
|
|
3317
|
+
return true;
|
|
3318
|
+
} catch {
|
|
3319
|
+
return false;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
/** `launcherCanStart`, for a reader that cannot wait. */
|
|
3323
|
+
function launcherCanStartSync(runtimeDir, name) {
|
|
3324
|
+
const cli = launcherCli(runtimeDir, name);
|
|
3325
|
+
try {
|
|
3326
|
+
if (!statSync(cli).isFile()) return false;
|
|
3327
|
+
accessSync(cli, constants$1.R_OK);
|
|
3328
|
+
return true;
|
|
3329
|
+
} catch {
|
|
3330
|
+
return false;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/** The folder of a runtime this launcher may be switched to, or why not. Nothing outside `<data>/runtime` is one. */
|
|
3334
|
+
async function switchableFolder(dataDir, name, runtimeDir) {
|
|
3335
|
+
const folder = basename(runtimeDir);
|
|
3336
|
+
if (!(resolve(dirname(runtimeDir)) === resolve(join(dataDir, RUNTIMES)) && isOwnFolder(folder, name))) throw new CommsError("CONFIG", `${runtimeDir} is not a runtime of ${name} in ${join(dataDir, RUNTIMES)}, so the launcher was not switched to it`);
|
|
3337
|
+
if (!await launcherCanStart(runtimeDir, name)) throw new CommsError("CONFIG", `${runtimeDir} has no CLI to start at ${launcherCli(runtimeDir, name)}, so the launcher was not switched to it`, { hint: "Install that runtime again: a managed install or an update puts its CLI in place." });
|
|
3338
|
+
return folder;
|
|
3339
|
+
}
|
|
3340
|
+
/** One change of a pointer at a time, across every process of this installation. */
|
|
3341
|
+
function withPointerLock(dataDir, name, change) {
|
|
3342
|
+
return withFileLock(join(dataDir, LAUNCH, `${checkedName(name)}${LOCK}`), change, {
|
|
3343
|
+
timeoutMs: 1e4,
|
|
3344
|
+
staleMs: 3e4
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* Points the launcher at `runtimeDir`, and returns the pointer it replaced (undefined when there was none).
|
|
3349
|
+
*
|
|
3350
|
+
* `switchPointer` without the answer; exported for an install or an update that may have to put it back
|
|
3351
|
+
* (`restorePointer`) and must know exactly what it was.
|
|
3352
|
+
*/
|
|
3353
|
+
async function exchangePointer(dataDir, name, runtimeDir) {
|
|
3354
|
+
const folder = await switchableFolder(dataDir, name, runtimeDir);
|
|
3355
|
+
return withPointerLock(dataDir, name, async () => {
|
|
3356
|
+
const before = await readFolders(dataDir, name).catch(() => void 0);
|
|
3357
|
+
if (before?.current === folder) return asPaths(dataDir, before);
|
|
3358
|
+
await writeFileAtomic(launcherPaths(dataDir, name).pointer, pointerText(folder, before?.current));
|
|
3359
|
+
return before === void 0 ? void 0 : asPaths(dataDir, before);
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Puts back the pointer a switch replaced (`exchangePointer`'s answer; undefined removes the pointer), only while the
|
|
3364
|
+
* pointer still names `expected` — what that switch pointed it at. Anything else means something switched it since,
|
|
3365
|
+
* and that newer pointer stays. Says whether it put it back.
|
|
3366
|
+
*/
|
|
3367
|
+
async function restorePointer(dataDir, name, before, expected) {
|
|
3368
|
+
return withPointerLock(dataDir, name, async () => {
|
|
3369
|
+
const now = await readFolders(dataDir, name).catch(() => void 0);
|
|
3370
|
+
if (now === void 0 || now.current !== basename(expected)) return false;
|
|
3371
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3372
|
+
if (before === void 0) await rm(pointer, { force: true });
|
|
3373
|
+
else await writeFileAtomic(pointer, pointerText(basename(before.current), before.previous === void 0 ? void 0 : basename(before.previous)));
|
|
3374
|
+
return true;
|
|
3375
|
+
});
|
|
3376
|
+
}
|
|
3377
|
+
function posixWord$1(word) {
|
|
3378
|
+
return `'${word.replaceAll("'", `'"'"'`)}'`;
|
|
3379
|
+
}
|
|
3380
|
+
/** The launcher for macOS and Linux: the shell's built-ins only, so nothing is looked for on PATH. */
|
|
3381
|
+
function posixSource(dataDir, name, node) {
|
|
3382
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3383
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3384
|
+
const record = fallbackRecordPath(dataDir, name);
|
|
3385
|
+
return `#!/bin/sh
|
|
3386
|
+
# The stable launcher of agent-communications for ${name}, format 1. Written by managed installs and updates; do not edit.
|
|
3387
|
+
# It starts the runtime the pointer beside it names, with the Node below. When that runtime's CLI is not there to
|
|
3388
|
+
# start, it starts the previous runtime instead, once, and records that beside the pointer. It never falls back from a
|
|
3389
|
+
# runtime that started, and never writes to stdout: an MCP client speaks there.
|
|
3390
|
+
node=${posixWord$1(node)}
|
|
3391
|
+
runtimes=${posixWord$1(runtimes)}
|
|
3392
|
+
pointer=${posixWord$1(pointer)}
|
|
3393
|
+
record=${posixWord$1(record)}
|
|
3394
|
+
cli=${posixWord$1(cliWords(name).join("/"))}
|
|
3395
|
+
suffix=${posixWord$1(`-${name}`)}
|
|
3396
|
+
${LAUNCHER_ENV}=${posixWord$1(name)}
|
|
3397
|
+
export ${LAUNCHER_ENV}
|
|
3398
|
+
current=
|
|
3399
|
+
previous=
|
|
3400
|
+
if [ -f "$pointer" ] && [ -r "$pointer" ]; then
|
|
3401
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
3402
|
+
case $line in
|
|
3403
|
+
current=*) [ -z "$current" ] && current=\${line#current=} ;;
|
|
3404
|
+
previous=*) [ -z "$previous" ] && previous=\${line#previous=} ;;
|
|
3405
|
+
esac
|
|
3406
|
+
done < "$pointer"
|
|
3407
|
+
fi
|
|
3408
|
+
startable() {
|
|
3409
|
+
case $1 in
|
|
3410
|
+
''|.*|*[!0-9A-Za-z.+_-]*) return 1 ;;
|
|
3411
|
+
esac
|
|
3412
|
+
case $1 in
|
|
3413
|
+
*"$suffix") ;;
|
|
3414
|
+
*) return 1 ;;
|
|
3415
|
+
esac
|
|
3416
|
+
[ "$1" != "$suffix" ] && [ -f "$runtimes/$1/$cli" ] && [ -r "$runtimes/$1/$cli" ]
|
|
3417
|
+
}
|
|
3418
|
+
if startable "$current"; then
|
|
3419
|
+
exec "$node" "$runtimes/$current/$cli" "$@"
|
|
3420
|
+
fi
|
|
3421
|
+
if [ "$previous" != "$current" ] && startable "$previous"; then
|
|
3422
|
+
{ printf 'from=%s\\n' "$current"; printf 'to=%s\\n' "$previous"; } 2>/dev/null > "$record"
|
|
3423
|
+
exec "$node" "$runtimes/$previous/$cli" "$@"
|
|
3424
|
+
fi
|
|
3425
|
+
printf '%s\\n' "agent-communications: the ${name} launcher has no runtime it can start: its pointer, $pointer, names \${current:-none}, and the runtime before it \${previous:-none}, and neither CLI is in $runtimes. A managed install or an update puts one in place." >&2
|
|
3426
|
+
exit 78
|
|
3427
|
+
`;
|
|
3428
|
+
}
|
|
3429
|
+
/**
|
|
3430
|
+
* The launcher for Windows: a CommonJS script Node runs — the registration starts the Node, and this is its first word —
|
|
3431
|
+
* which runs the runtime's CLI in the same process, so nothing re-parses the words and no second process is left behind
|
|
3432
|
+
* when the client stops the server. `argv[1]` is set to that CLI, as though Node had been started on it.
|
|
3433
|
+
*/
|
|
3434
|
+
function windowsSource(dataDir, name, node) {
|
|
3435
|
+
const runtimes = join(dataDir, RUNTIMES);
|
|
3436
|
+
const { pointer } = launcherPaths(dataDir, name);
|
|
3437
|
+
const record = fallbackRecordPath(dataDir, name);
|
|
3438
|
+
const json = JSON.stringify;
|
|
3439
|
+
return `// The stable launcher of agent-communications for ${name}, format 1, for Windows. Written by managed installs and
|
|
3440
|
+
// updates; do not edit. Started by ${json(node)} with this file as its first word: it starts the runtime the pointer
|
|
3441
|
+
// beside it names, in this process. When that runtime's CLI is not there to start, it starts the previous runtime
|
|
3442
|
+
// instead, once, and records that beside the pointer. It never falls back from a runtime that started.
|
|
3443
|
+
'use strict';
|
|
3444
|
+
const fs = require('node:fs');
|
|
3445
|
+
const path = require('node:path');
|
|
3446
|
+
const { pathToFileURL } = require('node:url');
|
|
3447
|
+
const runtimes = ${json(runtimes)};
|
|
3448
|
+
const pointer = ${json(pointer)};
|
|
3449
|
+
const record = ${json(record)};
|
|
3450
|
+
const cli = ${json(cliWords(name))};
|
|
3451
|
+
const suffix = ${json(`-${name}`)};
|
|
3452
|
+
let current = '';
|
|
3453
|
+
let previous = '';
|
|
3454
|
+
try {
|
|
3455
|
+
for (const line of fs.readFileSync(pointer, 'utf8').split(/\\r?\\n/)) {
|
|
3456
|
+
if (line.startsWith('current=') && current === '') current = line.slice(8);
|
|
3457
|
+
else if (line.startsWith('previous=') && previous === '') previous = line.slice(9);
|
|
3458
|
+
}
|
|
3459
|
+
} catch {}
|
|
3460
|
+
function startable(folder) {
|
|
3461
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(folder) || !folder.endsWith(suffix) || folder === suffix) return null;
|
|
3462
|
+
const entry = path.join(runtimes, folder, ...cli);
|
|
3463
|
+
try {
|
|
3464
|
+
if (!fs.statSync(entry).isFile()) return null;
|
|
3465
|
+
fs.accessSync(entry, fs.constants.R_OK);
|
|
3466
|
+
return entry;
|
|
3467
|
+
} catch {
|
|
3468
|
+
return null;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
let entry = startable(current);
|
|
3472
|
+
if (entry === null && previous !== current) {
|
|
3473
|
+
entry = startable(previous);
|
|
3474
|
+
if (entry !== null) {
|
|
3475
|
+
try {
|
|
3476
|
+
const temp = record + '.' + process.pid + '.tmp';
|
|
3477
|
+
fs.writeFileSync(temp, 'from=' + current + '\\nto=' + previous + '\\n');
|
|
3478
|
+
fs.renameSync(temp, record);
|
|
3479
|
+
} catch {}
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
if (entry === null) {
|
|
3483
|
+
process.stderr.write('agent-communications: the ' + ${json(name)} + ' launcher has no runtime it can start: its pointer, ' + pointer + ', names ' + (current || 'none') + ', and the runtime before it ' + (previous || 'none') + ', and neither CLI is in ' + runtimes + '. A managed install or an update puts one in place.\\n');
|
|
3484
|
+
process.exit(78);
|
|
3485
|
+
}
|
|
3486
|
+
process.argv[1] = entry;
|
|
3487
|
+
process.env.${LAUNCHER_ENV} = ${json(name)};
|
|
3488
|
+
import(pathToFileURL(entry).href).catch((error) => {
|
|
3489
|
+
process.exitCode = 1;
|
|
3490
|
+
console.error(error);
|
|
3491
|
+
});
|
|
3492
|
+
`;
|
|
3493
|
+
}
|
|
3494
|
+
/**
|
|
3495
|
+
* Writes the launcher `<data>/launch/<name>`: a temporary file, then a rename, so a launcher starting meanwhile runs
|
|
3496
|
+
* the old one or the new, whole. It starts the runtime its pointer names with `node` — given as the registration and
|
|
3497
|
+
* the shim name it, a link left a link (CUE-492) — and falls back once to `previous` when that runtime cannot start
|
|
3498
|
+
* (see the top of this module).
|
|
3499
|
+
*
|
|
3500
|
+
* POSIX: a shell script, executable by its owner alone. Windows (`platform`): a script for Node, with a `package.json`
|
|
3501
|
+
* beside it saying it is CommonJS, whatever a folder above the data directory says; the registration starts `node`.
|
|
3502
|
+
*/
|
|
3503
|
+
async function writeLauncher(dataDir, name, node, options = {}) {
|
|
3504
|
+
const platform = options.platform ?? process.platform;
|
|
3505
|
+
const { launcher } = launcherPaths(dataDir, name);
|
|
3506
|
+
if (!(platform === "win32" ? win32.isAbsolute(node) : posix.isAbsolute(node))) throw new CommsError("CONFIG", `the launcher's Node has to be named by its full path, not ${JSON.stringify(node)}`);
|
|
3507
|
+
const data = resolve(dataDir);
|
|
3508
|
+
if (platform === "win32") {
|
|
3509
|
+
await writeFileAtomic(join(dirname(launcher), "package.json"), `${JSON.stringify({ type: "commonjs" })}\n`);
|
|
3510
|
+
await writeFileAtomic(launcher, windowsSource(data, name, node));
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
await writeFileAtomic(launcher, posixSource(data, name, node), 448);
|
|
3514
|
+
}
|
|
3515
|
+
/** Whether the launcher file is there. */
|
|
3516
|
+
async function launcherExists(dataDir, name) {
|
|
3517
|
+
try {
|
|
3518
|
+
return (await stat(launcherPaths(dataDir, name).launcher)).isFile();
|
|
3519
|
+
} catch {
|
|
3520
|
+
return false;
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3523
|
+
/** The `--data-dir` a registration pins, in either form, before any `--`. */
|
|
3524
|
+
function dataDirPin(args) {
|
|
3525
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3526
|
+
const word = args[index];
|
|
3527
|
+
if (word === "--") return void 0;
|
|
3528
|
+
if (word === "--data-dir") return args[index + 1] === "--" ? void 0 : args[index + 1] || void 0;
|
|
3529
|
+
if (word.startsWith("--data-dir=")) return word.slice(11) || void 0;
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
/** A path as words to compare: separators made one, a trailing one dropped, without case on Windows. */
|
|
3533
|
+
function comparable(path, platform) {
|
|
3534
|
+
const plain = path.replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/$/, "");
|
|
3535
|
+
return platform === "win32" ? plain.toLowerCase() : plain;
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* The stable launcher a registered entry starts, read from its own words, or null when it starts none.
|
|
3539
|
+
*
|
|
3540
|
+
* Ours only when the launcher is in the very data directory the entry itself pins with `--data-dir` — the program on
|
|
3541
|
+
* POSIX, the first word after Node on Windows (`platform`) — named like one: a program called `launch/gmail` anywhere
|
|
3542
|
+
* else is somebody else's. Nothing is read from disk.
|
|
3543
|
+
*/
|
|
3544
|
+
function registeredLauncher(server, platform) {
|
|
3545
|
+
const dataDir = dataDirPin(server.args);
|
|
3546
|
+
if (dataDir === void 0) return null;
|
|
3547
|
+
const directory = `${comparable(dataDir, platform)}/${LAUNCH}`;
|
|
3548
|
+
for (const candidate of [server.command, server.args[0]]) {
|
|
3549
|
+
if (candidate === void 0) continue;
|
|
3550
|
+
const words = comparable(candidate, platform);
|
|
3551
|
+
if (!words.startsWith(`${directory}/`)) continue;
|
|
3552
|
+
const name = candidate.split(/[\\/]+/).at(-1) ?? "";
|
|
3553
|
+
if (words !== `${directory}/${comparable(name, platform)}` || !NAME.test(name)) continue;
|
|
3554
|
+
return {
|
|
3555
|
+
dataDir,
|
|
3556
|
+
name,
|
|
3557
|
+
launcher: candidate
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
return null;
|
|
3561
|
+
}
|
|
3144
3562
|
/**
|
|
3145
3563
|
* The programs this package starts, named so that no folder it happens to be in can stand in for them.
|
|
3146
3564
|
*
|
|
@@ -3239,12 +3657,12 @@ const RECEIPT_FILE = "managed-shims.json";
|
|
|
3239
3657
|
const MUTATION_LOCK_FILE = "managed-shims.lock";
|
|
3240
3658
|
const POSIX_SHIM_MODE = 493;
|
|
3241
3659
|
const WINDOWS_SHIM_NOTICE = "Managed CLI shims are not offered on Windows yet because cmd.exe re-parses arguments forwarded through .cmd files, including shell metacharacters. Use the full-path command or the labelled PowerShell & line the result provides.";
|
|
3242
|
-
function platformOf(context) {
|
|
3660
|
+
function platformOf$1(context) {
|
|
3243
3661
|
return context.platform ?? process.platform;
|
|
3244
3662
|
}
|
|
3245
3663
|
/** Why the selected platform has no managed shim in this release. */
|
|
3246
3664
|
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.";
|
|
3665
|
+
return platformOf$1(context) === "win32" ? WINDOWS_SHIM_NOTICE : "Managed CLI shims are offered only on macOS and Linux in this release. Use the full-path command the result provides.";
|
|
3248
3666
|
}
|
|
3249
3667
|
/** The per-user directory managed installs put their command shims in. */
|
|
3250
3668
|
function managedShimDirectory(context) {
|
|
@@ -3253,7 +3671,7 @@ function managedShimDirectory(context) {
|
|
|
3253
3671
|
}
|
|
3254
3672
|
/** Managed CLI shims are POSIX-only until Windows has an argument-forwarding form that does not re-parse input. */
|
|
3255
3673
|
function managedShimsSupported(context = {}) {
|
|
3256
|
-
const platform = platformOf(context);
|
|
3674
|
+
const platform = platformOf$1(context);
|
|
3257
3675
|
return platform === "darwin" || platform === "linux";
|
|
3258
3676
|
}
|
|
3259
3677
|
/** A managed shim's name: one plain command word, never a path, so joining it to the directory stays inside it. */
|
|
@@ -3437,7 +3855,7 @@ function normalPath(path, platform) {
|
|
|
3437
3855
|
return platform === "win32" ? trimmed.toLowerCase() : trimmed;
|
|
3438
3856
|
}
|
|
3439
3857
|
function directoryOnPath(context, directory) {
|
|
3440
|
-
const platform = platformOf(context);
|
|
3858
|
+
const platform = platformOf$1(context);
|
|
3441
3859
|
const value = context.env.PATH ?? context.env.Path ?? "";
|
|
3442
3860
|
const separator = platform === "win32" ? ";" : ":";
|
|
3443
3861
|
return value.split(separator).filter(Boolean).some((part) => normalPath(part, platform) === normalPath(directory, platform));
|
|
@@ -3752,6 +4170,8 @@ function pinnedVersion(argument, product, platform) {
|
|
|
3752
4170
|
*/
|
|
3753
4171
|
function isProductServer(server, product, platform) {
|
|
3754
4172
|
const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
4173
|
+
const stable = registeredLauncher(server, platform);
|
|
4174
|
+
if (stable !== null) return fold(stable.name) === fold(unscoped(product.packageName));
|
|
3755
4175
|
const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
3756
4176
|
if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
|
|
3757
4177
|
if (server.packageName) {
|
|
@@ -3819,12 +4239,14 @@ async function installManagedRuntime(context, product, version) {
|
|
|
3819
4239
|
private: true
|
|
3820
4240
|
}, null, 2)}\n`);
|
|
3821
4241
|
const npmCli = await findNpmCli();
|
|
4242
|
+
const prefix = await realpath(root);
|
|
3822
4243
|
await run(context.env, process.execPath, [
|
|
3823
4244
|
npmCli,
|
|
3824
4245
|
"install",
|
|
3825
4246
|
"--prefix",
|
|
3826
|
-
|
|
4247
|
+
prefix,
|
|
3827
4248
|
"--save-exact",
|
|
4249
|
+
"--ignore-scripts",
|
|
3828
4250
|
"--no-audit",
|
|
3829
4251
|
"--no-fund",
|
|
3830
4252
|
`${product.packageName}@${version}`
|
|
@@ -3913,40 +4335,49 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
3913
4335
|
runtimeMissing: false
|
|
3914
4336
|
};
|
|
3915
4337
|
}
|
|
4338
|
+
const { dataDir } = context.core.paths;
|
|
4339
|
+
const entry = {
|
|
4340
|
+
...managedEntryShape(context, product, node, [
|
|
4341
|
+
...pathArgs,
|
|
4342
|
+
"mcp",
|
|
4343
|
+
...serverArgs
|
|
4344
|
+
]),
|
|
4345
|
+
env
|
|
4346
|
+
};
|
|
3916
4347
|
if (!apply) {
|
|
3917
|
-
const
|
|
4348
|
+
const name = launcherName(product.packageName);
|
|
3918
4349
|
const ready = await reusableRuntime(dataDir, product.packageName, product.version);
|
|
4350
|
+
const pointer = await readPointer(dataDir, name).catch(() => void 0);
|
|
3919
4351
|
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
|
-
},
|
|
4352
|
+
entry,
|
|
3930
4353
|
launcher,
|
|
3931
|
-
runtimeMissing: ready ===
|
|
4354
|
+
runtimeMissing: !(ready !== null && await launcherExists(dataDir, name) && pointer?.current === managedRuntimeDir(dataDir, product.packageName, product.version))
|
|
3932
4355
|
};
|
|
3933
4356
|
}
|
|
4357
|
+
await installManagedRuntime(context, product, product.version);
|
|
3934
4358
|
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
|
-
},
|
|
4359
|
+
entry,
|
|
3945
4360
|
launcher,
|
|
3946
4361
|
runtimeMissing: false
|
|
3947
4362
|
};
|
|
3948
4363
|
}
|
|
3949
4364
|
/**
|
|
4365
|
+
* Points this product's stable launcher at the runtime of the release being installed — every registration of it on
|
|
4366
|
+
* this machine starts that from its next start — and writes the launcher afresh, with the Node the preview named.
|
|
4367
|
+
* Returns how to put the pointer back (`restorePointer`) if what comes after does not land.
|
|
4368
|
+
*/
|
|
4369
|
+
async function pointLauncher(context, product, node) {
|
|
4370
|
+
const { dataDir } = context.core.paths;
|
|
4371
|
+
const name = launcherName(product.packageName);
|
|
4372
|
+
const runtime = managedRuntimeDir(dataDir, product.packageName, product.version);
|
|
4373
|
+
await writeLauncher(dataDir, name, node, { platform: platformOf(context) });
|
|
4374
|
+
return {
|
|
4375
|
+
name,
|
|
4376
|
+
runtime,
|
|
4377
|
+
before: await exchangePointer(dataDir, name, runtime)
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
3950
4381
|
* The calling package's own CLI entry: `src/cli.ts` from source, `dist/cli.mjs` when bundled.
|
|
3951
4382
|
*
|
|
3952
4383
|
* `moduleUrl` comes from the product, not from this file. While this code lived inside the Gmail package,
|
|
@@ -4285,8 +4716,9 @@ async function preflightInstall(context, product, options) {
|
|
|
4285
4716
|
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4286
4717
|
}
|
|
4287
4718
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4288
|
-
const
|
|
4289
|
-
const
|
|
4719
|
+
const launcher = options.launcher ?? "managed";
|
|
4720
|
+
const node = launcher === "managed" ? nodeProgram(context.env, platformOf(context)) : await resolveNode(context.env);
|
|
4721
|
+
const command = launcher === "npx" ? await resolveNpx(context.env, node) : launcher === "managed" ? managedEntryShape(context, product, node, []).command : node;
|
|
4290
4722
|
return {
|
|
4291
4723
|
scan,
|
|
4292
4724
|
target,
|
|
@@ -4297,6 +4729,27 @@ async function preflightInstall(context, product, options) {
|
|
|
4297
4729
|
command
|
|
4298
4730
|
};
|
|
4299
4731
|
}
|
|
4732
|
+
function platformOf(context) {
|
|
4733
|
+
return context.platform ?? process.platform;
|
|
4734
|
+
}
|
|
4735
|
+
/** A product's stable launcher in this installation's data directory: `<data>/launch/<name>`. */
|
|
4736
|
+
function productLauncher(dataDir, product) {
|
|
4737
|
+
return launcherPaths(dataDir, launcherName(product.packageName)).launcher;
|
|
4738
|
+
}
|
|
4739
|
+
/**
|
|
4740
|
+
* A managed entry: the stable launcher with `words`. On POSIX it is the program; on Windows Node is, with the launcher
|
|
4741
|
+
* as its first word, since a client starts no script there without a shell (see `launcher.ts`).
|
|
4742
|
+
*/
|
|
4743
|
+
function managedEntryShape(context, product, node, words) {
|
|
4744
|
+
const launcher = productLauncher(context.core.paths.dataDir, product);
|
|
4745
|
+
return platformOf(context) === "win32" ? {
|
|
4746
|
+
command: node,
|
|
4747
|
+
args: [launcher, ...words]
|
|
4748
|
+
} : {
|
|
4749
|
+
command: launcher,
|
|
4750
|
+
args: [...words]
|
|
4751
|
+
};
|
|
4752
|
+
}
|
|
4300
4753
|
/**
|
|
4301
4754
|
* The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
|
|
4302
4755
|
*
|
|
@@ -4450,109 +4903,116 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4450
4903
|
let method = "printed";
|
|
4451
4904
|
let applied = false;
|
|
4452
4905
|
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" ? [
|
|
4906
|
+
const pointed = launcher === "managed" && apply ? await pointLauncher(context, product, preflight.node) : null;
|
|
4907
|
+
const unpoint = async () => pointed !== null && await restorePointer(context.core.paths.dataDir, pointed.name, pointed.before, pointed.runtime).catch(() => false);
|
|
4908
|
+
try {
|
|
4909
|
+
if (cliName && cliPath && writes) {
|
|
4910
|
+
const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
4911
|
+
const args = options.client === "claude-code" ? [
|
|
4474
4912
|
"mcp",
|
|
4475
|
-
"
|
|
4913
|
+
"add-json",
|
|
4476
4914
|
name,
|
|
4915
|
+
JSON.stringify(entry),
|
|
4477
4916
|
"--scope",
|
|
4478
4917
|
"user"
|
|
4479
4918
|
] : [
|
|
4480
4919
|
"mcp",
|
|
4481
|
-
"
|
|
4482
|
-
name
|
|
4920
|
+
"add",
|
|
4921
|
+
name,
|
|
4922
|
+
...codexEnv(entry.env),
|
|
4923
|
+
"--",
|
|
4924
|
+
entry.command,
|
|
4925
|
+
...entry.args
|
|
4483
4926
|
];
|
|
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" ? [
|
|
4927
|
+
const replacing = previous.find((server) => server.client === options.client);
|
|
4928
|
+
if (replacing) {
|
|
4929
|
+
const removal = options.client === "claude-code" ? [
|
|
4494
4930
|
"mcp",
|
|
4495
|
-
"
|
|
4931
|
+
"remove",
|
|
4496
4932
|
name,
|
|
4497
|
-
JSON.stringify({
|
|
4498
|
-
command: replacing.command,
|
|
4499
|
-
args: replacing.args,
|
|
4500
|
-
...replacing.env ? { env: replacing.env } : {}
|
|
4501
|
-
}),
|
|
4502
4933
|
"--scope",
|
|
4503
4934
|
"user"
|
|
4504
4935
|
] : [
|
|
4505
4936
|
"mcp",
|
|
4506
|
-
"
|
|
4507
|
-
name
|
|
4508
|
-
...codexEnv(replacing.env),
|
|
4509
|
-
"--",
|
|
4510
|
-
replacing.command,
|
|
4511
|
-
...replacing.args
|
|
4937
|
+
"remove",
|
|
4938
|
+
name
|
|
4512
4939
|
];
|
|
4513
|
-
let restored = true;
|
|
4514
4940
|
try {
|
|
4515
|
-
await run(context.env, cliPath,
|
|
4516
|
-
} catch {
|
|
4517
|
-
|
|
4941
|
+
await run(context.env, cliPath, removal);
|
|
4942
|
+
} catch (error) {
|
|
4943
|
+
const message = error instanceof CommsError ? error.message : String(error);
|
|
4944
|
+
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
4518
4945
|
}
|
|
4519
|
-
|
|
4520
|
-
|
|
4946
|
+
try {
|
|
4947
|
+
await run(context.env, cliPath, args);
|
|
4948
|
+
} catch (error) {
|
|
4949
|
+
const restore = options.client === "claude-code" ? [
|
|
4950
|
+
"mcp",
|
|
4951
|
+
"add-json",
|
|
4952
|
+
name,
|
|
4953
|
+
JSON.stringify({
|
|
4954
|
+
command: replacing.command,
|
|
4955
|
+
args: replacing.args,
|
|
4956
|
+
...replacing.env ? { env: replacing.env } : {}
|
|
4957
|
+
}),
|
|
4958
|
+
"--scope",
|
|
4959
|
+
"user"
|
|
4960
|
+
] : [
|
|
4961
|
+
"mcp",
|
|
4962
|
+
"add",
|
|
4963
|
+
name,
|
|
4964
|
+
...codexEnv(replacing.env),
|
|
4965
|
+
"--",
|
|
4966
|
+
replacing.command,
|
|
4967
|
+
...replacing.args
|
|
4968
|
+
];
|
|
4969
|
+
let restored = true;
|
|
4970
|
+
try {
|
|
4971
|
+
await run(context.env, cliPath, restore);
|
|
4972
|
+
} catch {
|
|
4973
|
+
restored = false;
|
|
4974
|
+
}
|
|
4975
|
+
if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
|
|
4976
|
+
hint: "Check the client is not running, then try again.",
|
|
4977
|
+
cause: error
|
|
4978
|
+
});
|
|
4979
|
+
throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
4980
|
+
binary: product.binary,
|
|
4981
|
+
words: installWords(product, effective, name, []),
|
|
4982
|
+
say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
|
|
4983
|
+
}, { cause: error });
|
|
4984
|
+
}
|
|
4985
|
+
} else try {
|
|
4986
|
+
await run(context.env, cliPath, args);
|
|
4987
|
+
} catch (error) {
|
|
4988
|
+
const message = error instanceof CommsError ? error.message : String(error);
|
|
4989
|
+
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4990
|
+
hint: `Look at it with ${clientCommandHint([
|
|
4991
|
+
cliName,
|
|
4992
|
+
"mcp",
|
|
4993
|
+
"get",
|
|
4994
|
+
name
|
|
4995
|
+
], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
|
|
4996
|
+
cliName,
|
|
4997
|
+
"mcp",
|
|
4998
|
+
"remove",
|
|
4999
|
+
name
|
|
5000
|
+
], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
|
|
4521
5001
|
cause: error
|
|
4522
5002
|
});
|
|
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 });
|
|
5003
|
+
throw error;
|
|
4528
5004
|
}
|
|
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;
|
|
5005
|
+
method = "cli";
|
|
5006
|
+
applied = true;
|
|
5007
|
+
} else if (cliName && apply) notApplied = `${cliName} was not found ${clientCliSearch(context.env)}, so nothing was registered`;
|
|
5008
|
+
else if (configPath && writes) {
|
|
5009
|
+
await mergeIntoJsonConfig(configPath, options.client, name, entry);
|
|
5010
|
+
method = "file";
|
|
5011
|
+
applied = true;
|
|
4548
5012
|
}
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
else if (configPath && writes) {
|
|
4553
|
-
await mergeIntoJsonConfig(configPath, options.client, name, entry);
|
|
4554
|
-
method = "file";
|
|
4555
|
-
applied = true;
|
|
5013
|
+
} catch (error) {
|
|
5014
|
+
await unpoint();
|
|
5015
|
+
throw error;
|
|
4556
5016
|
}
|
|
4557
5017
|
if (record && !applied) try {
|
|
4558
5018
|
await appendPrivateLine(ledger, JSON.stringify(record));
|
|
@@ -4564,12 +5024,13 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4564
5024
|
let verification = "skipped";
|
|
4565
5025
|
let verifyDetail;
|
|
4566
5026
|
if (!options.noVerify) {
|
|
4567
|
-
if (runtimeMissing) verifyDetail = "the
|
|
5027
|
+
if (runtimeMissing) verifyDetail = "the stable launcher does not start this release's managed runtime yet, and --print installs nothing";
|
|
4568
5028
|
else {
|
|
4569
5029
|
const check = await verifyEntry(entry, product);
|
|
4570
5030
|
verified = check.ok;
|
|
4571
5031
|
verification = check.ok ? "passed" : "failed";
|
|
4572
5032
|
verifyDetail = check.detail;
|
|
5033
|
+
if (!check.ok && pointed !== null && await unpoint()) verifyDetail = pointed.before === void 0 ? `${check.detail}; so the stable launcher's pointer was taken away again, as there was none before this install` : `${check.detail}; so the stable launcher was pointed back at ${pointed.before.current}, which it starts until this is fixed`;
|
|
4573
5034
|
}
|
|
4574
5035
|
}
|
|
4575
5036
|
return {
|
|
@@ -4808,11 +5269,25 @@ async function pruneManagedRuntimes(context, product, options = {}) {
|
|
|
4808
5269
|
const registered = scan.servers;
|
|
4809
5270
|
const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
|
|
4810
5271
|
const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
|
|
5272
|
+
const name = launcherName(product.packageName);
|
|
5273
|
+
let pointer;
|
|
5274
|
+
try {
|
|
5275
|
+
pointer = await readPointer(context.core.paths.dataDir, name);
|
|
5276
|
+
} catch {
|
|
5277
|
+
const at = launcherPaths(context.core.paths.dataDir, name).pointer;
|
|
5278
|
+
return refuse(`${at} could not be read, so none of these can be shown not to be what the stable launcher starts`);
|
|
5279
|
+
}
|
|
5280
|
+
const pointed = (aliases) => {
|
|
5281
|
+
const named = (runtime) => runtime !== void 0 && aliases.some((alias) => resolve(alias) === resolve(runtime));
|
|
5282
|
+
if (named(pointer?.current)) return "the stable launcher starts it";
|
|
5283
|
+
if (named(pointer?.previous)) return "the stable launcher's previous runtime: what it falls back to, and what a rollback goes back to";
|
|
5284
|
+
return null;
|
|
5285
|
+
};
|
|
4811
5286
|
const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
|
|
4812
5287
|
for (const { path, version, aliases } of candidates) {
|
|
4813
5288
|
const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
|
|
4814
5289
|
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;
|
|
5290
|
+
const reason = path === current ? "this release" : pointed(aliases) ?? (owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null);
|
|
4816
5291
|
if (reason) {
|
|
4817
5292
|
result.kept.push({
|
|
4818
5293
|
path,
|
|
@@ -5308,6 +5783,19 @@ const READABLE_CONFIG_VERSIONS = [
|
|
|
5308
5783
|
];
|
|
5309
5784
|
const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
5310
5785
|
const ALIAS_MESSAGE = "names must be 1–32 lowercase letters, digits or hyphens, starting with a letter or digit";
|
|
5786
|
+
/**
|
|
5787
|
+
* How this machine updates (design 2026-10-16, U2), from the most updating to the least: `auto` — the default — checks
|
|
5788
|
+
* daily and installs in the background once its job is set up; `every-14-days` checks at most once every 14 days and
|
|
5789
|
+
* installs only with the person's yes; `never` asks the registry nothing.
|
|
5790
|
+
*/
|
|
5791
|
+
const UPDATE_LEVELS = [
|
|
5792
|
+
"auto",
|
|
5793
|
+
"every-14-days",
|
|
5794
|
+
"never"
|
|
5795
|
+
];
|
|
5796
|
+
function isUpdateLevel(value) {
|
|
5797
|
+
return typeof value === "string" && UPDATE_LEVELS.includes(value);
|
|
5798
|
+
}
|
|
5311
5799
|
/** Which naming a configuration's body has: 1 — plain words — or 2 — `organisation/platform`. */
|
|
5312
5800
|
function namingOf(config) {
|
|
5313
5801
|
return config.version === 3 ? config.naming : config.version;
|
|
@@ -5418,7 +5906,8 @@ const defaultsSchema = looseObject({
|
|
|
5418
5906
|
downloadsDir: string().optional(),
|
|
5419
5907
|
timezone: string().default("system"),
|
|
5420
5908
|
confirm: looseObject({ elicitationClients: array(string()).default([]) }).default({ elicitationClients: [] }),
|
|
5421
|
-
updateCheck: _enum(["on", "off"]).optional()
|
|
5909
|
+
updateCheck: _enum(["on", "off"]).optional(),
|
|
5910
|
+
updates: string().min(1).optional()
|
|
5422
5911
|
});
|
|
5423
5912
|
const RESERVED_ALIASES = /* @__PURE__ */ new Set(["all"]);
|
|
5424
5913
|
/** Within one map, a reserved alias or a duplicate id is a hard error in every version. */
|
|
@@ -5492,6 +5981,10 @@ const slackAppRecordSchema = looseObject({
|
|
|
5492
5981
|
clientId: string().min(1),
|
|
5493
5982
|
appId: string().min(1).optional()
|
|
5494
5983
|
});
|
|
5984
|
+
const agentAppRecordSchema = looseObject({
|
|
5985
|
+
appId: string().min(1),
|
|
5986
|
+
sha256: string().min(1)
|
|
5987
|
+
});
|
|
5495
5988
|
const organisationRecordSchema = looseObject({
|
|
5496
5989
|
label: string(),
|
|
5497
5990
|
source: looseObject({
|
|
@@ -5512,8 +6005,14 @@ const organisationRecordSchema = looseObject({
|
|
|
5512
6005
|
redirectPort: number().int().min(1).max(65535),
|
|
5513
6006
|
apps: looseObject({
|
|
5514
6007
|
read: slackAppRecordSchema.optional(),
|
|
5515
|
-
send: slackAppRecordSchema.optional()
|
|
6008
|
+
send: slackAppRecordSchema.optional(),
|
|
6009
|
+
agent: agentAppRecordSchema.optional()
|
|
5516
6010
|
}).default({})
|
|
6011
|
+
}).optional(),
|
|
6012
|
+
updates: looseObject({
|
|
6013
|
+
max: string().min(1).optional(),
|
|
6014
|
+
background: boolean().optional(),
|
|
6015
|
+
sha256: string().min(1)
|
|
5517
6016
|
}).optional()
|
|
5518
6017
|
});
|
|
5519
6018
|
const formerNameSchema = looseObject({
|
|
@@ -5695,10 +6194,30 @@ function governingChangePolicy(config, change) {
|
|
|
5695
6194
|
};
|
|
5696
6195
|
return [byId(change.target?.id), ...change.loosened.map((loosening) => byId(loosening.id))].includes("confirm") ? "confirm" : "chat";
|
|
5697
6196
|
}
|
|
5698
|
-
/**
|
|
6197
|
+
/** 0.18's switch, as written: `defaults.updateCheck`, absent reading as `on`. */
|
|
5699
6198
|
function updateCheckSetting(config) {
|
|
5700
6199
|
return config.defaults.updateCheck ?? "on";
|
|
5701
6200
|
}
|
|
6201
|
+
/**
|
|
6202
|
+
* The level this machine's configuration sets (design 2026-10-16, U2): `defaults.updates`, else 0.18's switch read as a
|
|
6203
|
+
* level — `on` as `auto`, `off` as `never`. A word this release does not know reads as `auto`, the default.
|
|
6204
|
+
*/
|
|
6205
|
+
function updateLevelOf(config) {
|
|
6206
|
+
const set = config.defaults.updates;
|
|
6207
|
+
if (set !== void 0) return isUpdateLevel(set) ? set : "auto";
|
|
6208
|
+
return updateCheckSetting(config) === "off" ? "never" : "auto";
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* How much a level updates, for `classifyChange`: `auto` 2, `every-14-days` 1, `never` 0. A word this release does not
|
|
6212
|
+
* know is judged as the most updating on the side it leaves and the least on the side it arrives on, as an account's
|
|
6213
|
+
* mode is — an unknown word can cost a person a question, never skip one.
|
|
6214
|
+
*/
|
|
6215
|
+
function updateRank(config, side) {
|
|
6216
|
+
const set = config.defaults.updates;
|
|
6217
|
+
const level = set === void 0 ? updateLevelOf(config) : isUpdateLevel(set) ? set : null;
|
|
6218
|
+
if (level === null) return side === "before" ? UPDATE_LEVELS.length - 1 : 0;
|
|
6219
|
+
return UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
|
|
6220
|
+
}
|
|
5702
6221
|
/** `naming` is the body's: 2 for `organisation/platform` names (version 2, or version 3 over it), 1 for plain ones. */
|
|
5703
6222
|
function describeIssues(error, naming) {
|
|
5704
6223
|
return error.issues.slice(0, 5).map((issue) => {
|
|
@@ -6255,7 +6774,8 @@ function classifyChange(before, after) {
|
|
|
6255
6774
|
if (POLICY_RANK$1[a.sendPolicy] < POLICY_RANK$1[b.sendPolicy]) loosen("defaults.sendPolicy", b.sendPolicy, a.sendPolicy);
|
|
6256
6775
|
if (POLICY_RANK$1[defaultChangePolicy(after)] < POLICY_RANK$1[defaultChangePolicy(before)]) loosen("defaults.changePolicy", defaultChangePolicy(before), defaultChangePolicy(after));
|
|
6257
6776
|
if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
|
|
6258
|
-
if (
|
|
6777
|
+
if (!(b.updates !== void 0 && b.updates === a.updates) && updateRank(after, "after") < updateRank(before, "before")) loosen("defaults.updates", before.defaults.updates ?? updateLevelOf(before), after.defaults.updates ?? updateLevelOf(after));
|
|
6778
|
+
else if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
|
|
6259
6779
|
if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
|
|
6260
6780
|
const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
|
|
6261
6781
|
const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
|
|
@@ -6286,11 +6806,14 @@ function agentLabelRuleText(rule) {
|
|
|
6286
6806
|
const kinds = rule.kinds === void 0 ? "" : ` for ${rule.kinds.join("/")}`;
|
|
6287
6807
|
return `${rule.account}${where}${kinds}: ${rule.as}`;
|
|
6288
6808
|
}
|
|
6289
|
-
/**
|
|
6809
|
+
/**
|
|
6810
|
+
* A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles as they are, the
|
|
6811
|
+
* rest as text.
|
|
6812
|
+
*/
|
|
6290
6813
|
function labelSettingValue(loosening, value) {
|
|
6291
6814
|
if (value === void 0 || value === null) return void 0;
|
|
6292
6815
|
if (loosening.path === "agentLabel.rules") return value.map(agentLabelRuleText);
|
|
6293
|
-
if (loosening.path === "agentLabel.slackIcons") return { ...value };
|
|
6816
|
+
if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") return { ...value };
|
|
6294
6817
|
return String(value);
|
|
6295
6818
|
}
|
|
6296
6819
|
/** The label settings a change can write, as the file holds them — raw, as `changedSettings` records every setting. */
|
|
@@ -6298,7 +6821,8 @@ const LABEL_SETTINGS = [
|
|
|
6298
6821
|
"word",
|
|
6299
6822
|
"name",
|
|
6300
6823
|
"rules",
|
|
6301
|
-
"slackIcons"
|
|
6824
|
+
"slackIcons",
|
|
6825
|
+
"slackStyles"
|
|
6302
6826
|
];
|
|
6303
6827
|
function storedAgentLabel(config) {
|
|
6304
6828
|
const stored = config.agentLabel;
|
|
@@ -7161,11 +7685,15 @@ var ApprovalStore = class {
|
|
|
7161
7685
|
#now;
|
|
7162
7686
|
#handoffs;
|
|
7163
7687
|
#loadConfig;
|
|
7688
|
+
/** `ApprovalStoreOptions.answered`, never throwing. */
|
|
7689
|
+
answered;
|
|
7164
7690
|
constructor(stateDir, options = {}) {
|
|
7165
7691
|
this.directory = join(stateDir, "approvals");
|
|
7166
7692
|
this.#handoffs = options.handoffs;
|
|
7167
7693
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
7168
7694
|
this.#loadConfig = options.loadConfig;
|
|
7695
|
+
const answered = options.answered;
|
|
7696
|
+
this.answered = answered === void 0 ? void 0 : (approvalId) => answered(approvalId).catch(() => void 0);
|
|
7169
7697
|
registerStoreInternals(this, {
|
|
7170
7698
|
directory: this.directory,
|
|
7171
7699
|
now: () => this.#now(),
|
|
@@ -7754,6 +8282,7 @@ var ApprovalStore = class {
|
|
|
7754
8282
|
}, { usedSaid: options.usedSaid });
|
|
7755
8283
|
const failed = failure;
|
|
7756
8284
|
if (failed) throw refusedAfter(refusalFor(done.record)(failed.code, failed.reason, done.record), done);
|
|
8285
|
+
if (kind === "send" && done.record.state === "approved") await this.answered?.(approvalId);
|
|
7757
8286
|
return done.record;
|
|
7758
8287
|
}
|
|
7759
8288
|
/**
|
|
@@ -7803,6 +8332,7 @@ var ApprovalStore = class {
|
|
|
7803
8332
|
if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
|
|
7804
8333
|
const claimToken = randomBytes(16).toString("hex");
|
|
7805
8334
|
await this.#markClaimed(done, claimToken);
|
|
8335
|
+
await this.answered?.(approvalId);
|
|
7806
8336
|
return {
|
|
7807
8337
|
record: done.record,
|
|
7808
8338
|
claimToken,
|
|
@@ -9436,6 +9966,7 @@ function registrationFactsOf({ packageName, manifest }) {
|
|
|
9436
9966
|
/** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
|
|
9437
9967
|
function launcherOf(server, facts, platform) {
|
|
9438
9968
|
const fold = folding(platform);
|
|
9969
|
+
if (stableLauncherOf(server, facts, platform) !== null) return "managed";
|
|
9439
9970
|
const parts = [server.command, ...server.args];
|
|
9440
9971
|
if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
|
|
9441
9972
|
if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
|
|
@@ -9443,10 +9974,49 @@ function launcherOf(server, facts, platform) {
|
|
|
9443
9974
|
if (parts.some((part) => local.test(part))) return "local";
|
|
9444
9975
|
return "other";
|
|
9445
9976
|
}
|
|
9446
|
-
/**
|
|
9977
|
+
/**
|
|
9978
|
+
* The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none.
|
|
9979
|
+
*
|
|
9980
|
+
* An entry that starts the stable launcher names no version: it runs the release its launcher's pointer names, read
|
|
9981
|
+
* here — null when the pointer is not there, cannot be read, or names a folder that is not a release.
|
|
9982
|
+
*/
|
|
9447
9983
|
function registrationVersion(server, facts, platform) {
|
|
9984
|
+
const stable = stableLauncherOf({
|
|
9985
|
+
command: server.command ?? "",
|
|
9986
|
+
args: server.args
|
|
9987
|
+
}, facts, platform);
|
|
9988
|
+
if (stable !== null) {
|
|
9989
|
+
const current = pointedRuntime(stable.dataDir, stable.name);
|
|
9990
|
+
return current === null ? null : launcherRuntimeVersion(current, stable.name);
|
|
9991
|
+
}
|
|
9448
9992
|
return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
|
|
9449
9993
|
}
|
|
9994
|
+
/**
|
|
9995
|
+
* The stable launcher of this product an entry starts, in the data directory the entry pins, or null: see
|
|
9996
|
+
* `registeredLauncher`. Another product's launcher is not this one's.
|
|
9997
|
+
*/
|
|
9998
|
+
function stableLauncherOf(server, facts, platform) {
|
|
9999
|
+
const stable = registeredLauncher(server, platform);
|
|
10000
|
+
if (stable === null) return null;
|
|
10001
|
+
let own;
|
|
10002
|
+
try {
|
|
10003
|
+
own = launcherName(facts.packageName);
|
|
10004
|
+
} catch {
|
|
10005
|
+
return null;
|
|
10006
|
+
}
|
|
10007
|
+
return folding(platform)(stable.name) === folding(platform)(own) ? {
|
|
10008
|
+
...stable,
|
|
10009
|
+
name: own
|
|
10010
|
+
} : null;
|
|
10011
|
+
}
|
|
10012
|
+
/** The runtime a launcher's pointer names as current, or null when it has none that can be read. */
|
|
10013
|
+
function pointedRuntime(dataDir, name) {
|
|
10014
|
+
try {
|
|
10015
|
+
return readPointerSync(dataDir, name)?.current ?? null;
|
|
10016
|
+
} catch {
|
|
10017
|
+
return null;
|
|
10018
|
+
}
|
|
10019
|
+
}
|
|
9450
10020
|
PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
9451
10021
|
/**
|
|
9452
10022
|
* Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
|
|
@@ -9454,6 +10024,7 @@ PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
|
9454
10024
|
* A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
|
|
9455
10025
|
*/
|
|
9456
10026
|
function startsProduct(server, facts, platform) {
|
|
10027
|
+
if (registeredLauncher(server, platform) !== null) return stableLauncherOf(server, facts, platform) !== null;
|
|
9457
10028
|
const { words: args } = withoutPathOptions(server.args);
|
|
9458
10029
|
const packageName = commandLinePackage(server.command, args);
|
|
9459
10030
|
return isProductServer({
|
|
@@ -9479,6 +10050,24 @@ function isNpxRegistration(server, facts, platform) {
|
|
|
9479
10050
|
* program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
|
|
9480
10051
|
*/
|
|
9481
10052
|
function registrationFiles(server, facts, platform) {
|
|
10053
|
+
const { packageName } = facts;
|
|
10054
|
+
const stable = packageName === void 0 ? null : stableLauncherOf(server, { packageName }, platform);
|
|
10055
|
+
if (stable !== null) {
|
|
10056
|
+
let pointer;
|
|
10057
|
+
try {
|
|
10058
|
+
pointer = readPointerSync(stable.dataDir, stable.name);
|
|
10059
|
+
} catch {
|
|
10060
|
+
return {
|
|
10061
|
+
scripts: [],
|
|
10062
|
+
command: null
|
|
10063
|
+
};
|
|
10064
|
+
}
|
|
10065
|
+
const runtime = pointer === void 0 ? void 0 : launcherCanStartSync(pointer.current, stable.name) || pointer.previous === void 0 ? pointer.current : pointer.previous;
|
|
10066
|
+
return {
|
|
10067
|
+
scripts: runtime === void 0 ? [] : [launcherCli(runtime, stable.name)],
|
|
10068
|
+
command: null
|
|
10069
|
+
};
|
|
10070
|
+
}
|
|
9482
10071
|
const { words } = withoutPathOptions(server.args);
|
|
9483
10072
|
const sentinel = words.indexOf("--");
|
|
9484
10073
|
const options = sentinel < 0 ? words : words.slice(0, sentinel);
|
|
@@ -9492,7 +10081,7 @@ function folding(platform) {
|
|
|
9492
10081
|
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
9493
10082
|
}
|
|
9494
10083
|
/** The package version, read from package.json at build time. */
|
|
9495
|
-
const VERSION = "0.
|
|
10084
|
+
const VERSION = "0.20.0";
|
|
9496
10085
|
/**
|
|
9497
10086
|
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
9498
10087
|
* to decide whether the release it last heard of is newer than the one running.
|
|
@@ -9563,8 +10152,13 @@ function isBehind(version, latest) {
|
|
|
9563
10152
|
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
9564
10153
|
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
9565
10154
|
*/
|
|
9566
|
-
|
|
9567
|
-
|
|
10155
|
+
/**
|
|
10156
|
+
* A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
|
|
10157
|
+
* `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
|
|
10158
|
+
* text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
|
|
10159
|
+
*/
|
|
10160
|
+
function isPlainVersion(value) {
|
|
10161
|
+
return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
|
|
9568
10162
|
}
|
|
9569
10163
|
/**
|
|
9570
10164
|
* Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
|
|
@@ -10738,11 +11332,13 @@ const SETTING_LABELS = {
|
|
|
10738
11332
|
"defaults.downloadsDir": "downloads folder",
|
|
10739
11333
|
"defaults.confirm.elicitationClients": "clients trusted to ask for a send approval",
|
|
10740
11334
|
"defaults.updateCheck": "daily update check",
|
|
11335
|
+
"defaults.updates": "how this machine updates",
|
|
10741
11336
|
"secrets.store": "where credentials are kept",
|
|
10742
11337
|
"agentLabel.word": "the word after your name in the agent label",
|
|
10743
11338
|
"agentLabel.name": "the name in the agent label",
|
|
10744
11339
|
"agentLabel.rules": "who each kind of message appears to come from",
|
|
10745
|
-
"agentLabel.slackIcons": "the icon of the agent’s Slack posts"
|
|
11340
|
+
"agentLabel.slackIcons": "the icon of the agent’s Slack posts",
|
|
11341
|
+
"agentLabel.slackStyles": "how the agent’s Slack messages look"
|
|
10746
11342
|
};
|
|
10747
11343
|
/**
|
|
10748
11344
|
* What the loosening means for the person, in their terms.
|
|
@@ -10765,12 +11361,14 @@ function meaning(field, after, scope) {
|
|
|
10765
11361
|
case "defaults.attachDeny": return "files the removed entries protected can be attached";
|
|
10766
11362
|
case "defaults.downloadsDir": return "files from other people will be saved here";
|
|
10767
11363
|
case "defaults.confirm.elicitationClients": return "these clients may ask you to approve a send in their own window";
|
|
10768
|
-
case "defaults.updateCheck": return "
|
|
11364
|
+
case "defaults.updateCheck": return "a release before 0.20 on this machine will no longer ask npm for a newer release";
|
|
11365
|
+
case "defaults.updates": return after === "every-14-days" ? "this machine will ask npm for a newer release at most once every 14 days, say one is out at most once every 14 days, and install nothing in the background" : after === "never" ? "nothing on this machine will ask npm for a newer release, say one is out, or install one" : "this release cannot tell how that level updates, so it counts as updating least";
|
|
10769
11366
|
case "secrets.store": return "credentials will move out of the system keychain into files on this disk";
|
|
10770
11367
|
case "agentLabel.word": return "what an agent sends will carry this word after your name";
|
|
10771
11368
|
case "agentLabel.name": return after === null ? "where a channel has no name of its own for you, an agent’s message will be refused until a name is set" : "where a channel has no name of its own for you, what an agent sends will carry this name";
|
|
10772
11369
|
case "agentLabel.rules": return "what a rule sends as `me` goes out as you, with no label: it will look as if you wrote it yourself";
|
|
10773
11370
|
case "agentLabel.slackIcons": return "the app’s Slack posts will show this icon beside your name";
|
|
11371
|
+
case "agentLabel.slackStyles": return "every agent message there goes out as you, labelled in its words, even where the app could post: it trades Slack’s own APP tag, which no one can fake, for words in the message";
|
|
10774
11372
|
default: return "";
|
|
10775
11373
|
}
|
|
10776
11374
|
}
|
|
@@ -10912,7 +11510,7 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
10912
11510
|
if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
|
|
10913
11511
|
} else if (options.answered !== true) {
|
|
10914
11512
|
streams.stdout.write(`${prepared.preview}\n\n`);
|
|
10915
|
-
if ((await askLine
|
|
11513
|
+
if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
|
|
10916
11514
|
await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
|
|
10917
11515
|
surface: "cli",
|
|
10918
11516
|
disposition: "person"
|
|
@@ -10977,7 +11575,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
10977
11575
|
}
|
|
10978
11576
|
const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
|
|
10979
11577
|
streams.stdout.write(`${prompt.preview}\n\n`);
|
|
10980
|
-
const answer = await askLine
|
|
11578
|
+
const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
|
|
10981
11579
|
if (!answer.trim()) {
|
|
10982
11580
|
await revokeChange(core, approvalId, "cancelled at the terminal", {
|
|
10983
11581
|
surface: "cli",
|
|
@@ -10997,7 +11595,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
10997
11595
|
function cancelled() {
|
|
10998
11596
|
return new CommsError("USAGE", "cancelled: nothing was changed");
|
|
10999
11597
|
}
|
|
11000
|
-
async function askLine
|
|
11598
|
+
async function askLine(streams, question) {
|
|
11001
11599
|
const { createInterface } = await import("node:readline/promises");
|
|
11002
11600
|
const rl = createInterface({
|
|
11003
11601
|
input: streams.stdin,
|
|
@@ -11363,80 +11961,6 @@ var InboxStateStore = class {
|
|
|
11363
11961
|
});
|
|
11364
11962
|
}
|
|
11365
11963
|
};
|
|
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
11964
|
/**
|
|
11441
11965
|
* A Google client id as an organisation profile has to write it (design 2026-10-02 §D2): the project number, a hyphen,
|
|
11442
11966
|
* the client's own part, and Google's suffix — and nothing else.
|
|
@@ -11507,6 +12031,142 @@ strictObject({
|
|
|
11507
12031
|
})
|
|
11508
12032
|
}).optional()
|
|
11509
12033
|
});
|
|
12034
|
+
/**
|
|
12035
|
+
* A string that came from a profile, as it is shown anywhere: chat-template tokens, role markers and envelope look-alikes
|
|
12036
|
+
* neutralised (`neutralise`), every control and invisible character made visible, tabs and line breaks flattened to a
|
|
12037
|
+
* space, and cut to `width`. `neutralise` keeps line feeds and tabs on purpose — they are legitimate in a message body —
|
|
12038
|
+
* so the flattening is what keeps a label from printing a second line of a preview.
|
|
12039
|
+
*/
|
|
12040
|
+
function shownText(value, width = 120) {
|
|
12041
|
+
return truncateDisplay(neutralise(typeof value === "string" ? value : String(value ?? "")).text, width);
|
|
12042
|
+
}
|
|
12043
|
+
/**
|
|
12044
|
+
* A profile's path as it is shown — in a preview, an error, a command's output: neutralised and on one line, as every
|
|
12045
|
+
* other string a person did not write themselves. The path is chosen by whoever named the file — a repository, a
|
|
12046
|
+
* message with an attachment — and its name is text like any other.
|
|
12047
|
+
*/
|
|
12048
|
+
function shownPath(path) {
|
|
12049
|
+
return shownText(path, 400);
|
|
12050
|
+
}
|
|
12051
|
+
/**
|
|
12052
|
+
* The bytes at an absolute path, in one bounded read: open once, check it is a regular file, read at most one byte more
|
|
12053
|
+
* than `PROFILE_MAX_BYTES` from the same handle. A path somebody typed can name a FIFO that never ends, a device, a
|
|
12054
|
+
* directory; none of them is a profile, and none may be how a command hangs. A symlink is followed: the path is one the
|
|
12055
|
+
* person chose. `unopened` says what a file that cannot be opened is: a refusal for the profile, whatever the reason;
|
|
12056
|
+
* nothing for a file beside it (`readSideFile`) when it is not there, and a refusal when it is there and cannot be
|
|
12057
|
+
* opened.
|
|
12058
|
+
*/
|
|
12059
|
+
async function readBounded(path, what) {
|
|
12060
|
+
let handle;
|
|
12061
|
+
try {
|
|
12062
|
+
handle = await open(path, constants$1.O_RDONLY | (constants$1.O_NONBLOCK ?? 0));
|
|
12063
|
+
} catch (error) {
|
|
12064
|
+
return what.unopened(error);
|
|
12065
|
+
}
|
|
12066
|
+
try {
|
|
12067
|
+
if (!(await handle.stat()).isFile()) throw new CommsError("USAGE", `${shownPath(path)} is not a file`);
|
|
12068
|
+
const buffer = Buffer.allocUnsafe(65537);
|
|
12069
|
+
const { bytesRead } = await handle.read(buffer, 0, 65537, 0);
|
|
12070
|
+
if (bytesRead > 65536) throw new CommsError("BAD_DATA", `${shownPath(path)} is larger than ${what.size} can be (64 KiB)`, { hint: `${what.size.charAt(0).toUpperCase()}${what.size.slice(1)} is a few hundred bytes of JSON. Check this is the file your organisation sent.` });
|
|
12071
|
+
return Buffer.from(buffer.subarray(0, bytesRead));
|
|
12072
|
+
} catch (error) {
|
|
12073
|
+
if (error instanceof CommsError) throw error;
|
|
12074
|
+
throw new CommsError("NOT_FOUND", `${what.noun} at ${shownPath(path)} could not be read`, { cause: error });
|
|
12075
|
+
} finally {
|
|
12076
|
+
await handle.close();
|
|
12077
|
+
}
|
|
12078
|
+
}
|
|
12079
|
+
/** The most symbolic links followed from a profile's path to its file: as many as the system itself follows. */
|
|
12080
|
+
const LINK_HOPS = 40;
|
|
12081
|
+
/**
|
|
12082
|
+
* The file a profile's path names, through every symbolic link at its end: the path itself when it is not a link, or
|
|
12083
|
+
* cannot be looked at — what reading it then finds is the bounded read's to say. A link's target is read against the
|
|
12084
|
+
* folder the link is in. The folders on the way are left as they are spelt: the file beside the result is in the same
|
|
12085
|
+
* folder whatever they resolve to.
|
|
12086
|
+
*
|
|
12087
|
+
* What the files beside a profile are looked for beside (`readProfileFile`): an organisation keeps them beside its
|
|
12088
|
+
* profile, in its own checkout, and a person who links to that profile from somewhere else has linked to the profile
|
|
12089
|
+
* alone. Looked for beside the link, a caps file is not there, and the caps it names would be lifted.
|
|
12090
|
+
*/
|
|
12091
|
+
function profileTarget(path) {
|
|
12092
|
+
let at = path;
|
|
12093
|
+
for (let hops = 0; hops < LINK_HOPS; hops += 1) {
|
|
12094
|
+
let link;
|
|
12095
|
+
try {
|
|
12096
|
+
if (!lstatSync(at).isSymbolicLink()) return at;
|
|
12097
|
+
link = readlinkSync(at);
|
|
12098
|
+
} catch {
|
|
12099
|
+
return at;
|
|
12100
|
+
}
|
|
12101
|
+
at = resolve(dirname(at), link);
|
|
12102
|
+
}
|
|
12103
|
+
throw new CommsError("USAGE", `${shownPath(path)} is a chain of more than ${LINK_HOPS} symbolic links`, { hint: "Pass the path of the profile file itself." });
|
|
12104
|
+
}
|
|
12105
|
+
/** A side file's path for a profile: in the same folder, the profile's name with `.<suffix>.json` for its `.json`. */
|
|
12106
|
+
function sidePathFor(profilePath, suffix) {
|
|
12107
|
+
return /\.json$/i.test(profilePath) ? `${profilePath.slice(0, -5)}.${suffix}.json` : `${profilePath}.${suffix}.json`;
|
|
12108
|
+
}
|
|
12109
|
+
/**
|
|
12110
|
+
* Reads one file beside a profile, if there is one, and holds it to the profile: the same organisation, and whatever
|
|
12111
|
+
* else `agrees` asks. Read in one bounded read as the profile is (`readBounded`), strict as the profile is, and refused
|
|
12112
|
+
* in the same words — naming each problem by where it is, never by the value found there — but naming this file, so
|
|
12113
|
+
* the person knows which of the two to fix. Only a file that is not there is none: one that is there and cannot be
|
|
12114
|
+
* opened is refused, since read as none it would lift what it says — the caps, or the one app `workspace bot` takes.
|
|
12115
|
+
*/
|
|
12116
|
+
async function readSideFile(path, profile, side) {
|
|
12117
|
+
const where = shownPath(path);
|
|
12118
|
+
const bytes = await readBounded(path, {
|
|
12119
|
+
unopened: (error) => {
|
|
12120
|
+
if (error.code === "ENOENT") return null;
|
|
12121
|
+
throw new CommsError("NOT_FOUND", `${side.noun} at ${where} could not be opened`, { cause: error });
|
|
12122
|
+
},
|
|
12123
|
+
noun: side.noun,
|
|
12124
|
+
size: side.size
|
|
12125
|
+
});
|
|
12126
|
+
if (bytes === null) return void 0;
|
|
12127
|
+
const refuse = (problem) => {
|
|
12128
|
+
throw new CommsError("BAD_DATA", `${side.noun} ${where} is not valid: ${problem}`, { hint: side.hint });
|
|
12129
|
+
};
|
|
12130
|
+
let text;
|
|
12131
|
+
try {
|
|
12132
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
12133
|
+
} catch {
|
|
12134
|
+
return refuse("it is not UTF-8 text");
|
|
12135
|
+
}
|
|
12136
|
+
let raw;
|
|
12137
|
+
try {
|
|
12138
|
+
raw = JSON.parse(text.startsWith("") ? text.slice(1) : text);
|
|
12139
|
+
} catch {
|
|
12140
|
+
return refuse("it is not valid JSON");
|
|
12141
|
+
}
|
|
12142
|
+
const parsed = side.schema.safeParse(raw);
|
|
12143
|
+
if (!parsed.success) return refuse(parsed.error.issues.slice(0, 6).map((issue) => {
|
|
12144
|
+
const at = issue.path.length > 0 ? issue.path.map(String).join(".") : "the file";
|
|
12145
|
+
return issue.code === "unrecognized_keys" ? `${at}: ${issue.keys.map((key) => `"${shownText(key, 40)}"`).join(", ")} ${issue.keys.length === 1 ? "is not a key" : "are not keys"} it has` : `${at}: ${issue.message}`;
|
|
12146
|
+
}).join("; "));
|
|
12147
|
+
const document = parsed.data;
|
|
12148
|
+
if (document.organisation !== profile.organisation) return refuse(`it names the organisation ${shownText(document.organisation, 40)}, not ${profile.organisation}, whose profile it sits beside`);
|
|
12149
|
+
const problem = side.agrees?.(document, profile);
|
|
12150
|
+
if (problem !== void 0) return refuse(problem);
|
|
12151
|
+
return {
|
|
12152
|
+
path,
|
|
12153
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
12154
|
+
document
|
|
12155
|
+
};
|
|
12156
|
+
}
|
|
12157
|
+
strictObject({
|
|
12158
|
+
agentcomms: literal("organisation-agent-app"),
|
|
12159
|
+
version: literal(1),
|
|
12160
|
+
organisation: string(),
|
|
12161
|
+
slack: strictObject({
|
|
12162
|
+
workspace: string().regex(SLACK_WORKSPACE_ID_PATTERN, "a Slack workspace id is T followed by capital letters or digits"),
|
|
12163
|
+
appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits")
|
|
12164
|
+
})
|
|
12165
|
+
});
|
|
12166
|
+
/** The organisation profiles this configuration records — none on version 1, which cannot hold them. */
|
|
12167
|
+
function organisationsOf(config) {
|
|
12168
|
+
return hasNames(config) ? config.organisations ?? {} : {};
|
|
12169
|
+
}
|
|
11510
12170
|
strictObject({
|
|
11511
12171
|
organisation: string().refine((word) => parseOrganisation(word) !== null, "an organisation is a name’s first half"),
|
|
11512
12172
|
role: _enum(["read", "send"]),
|
|
@@ -11519,6 +12179,802 @@ strictObject({
|
|
|
11519
12179
|
sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
|
|
11520
12180
|
});
|
|
11521
12181
|
/**
|
|
12182
|
+
* An organisation's update caps (design 2026-10-16, U8): the most its members' machines may update — `max`,
|
|
12183
|
+
* `every-14-days` or `never` — and `background: false`, no background job. They only ever tighten: a member's level is
|
|
12184
|
+
* capped at `max`, and nothing a member sets goes past it.
|
|
12185
|
+
*
|
|
12186
|
+
* **They are named in a file beside the profile, not in it** (`readSideFile`): a key such as `updates` in the profile
|
|
12187
|
+
* would make every release from 0.13.0 to 0.19.0 refuse it. They go in `<profile>.updates.json`, recorded as the
|
|
12188
|
+
* organisation's `updates`.
|
|
12189
|
+
*/
|
|
12190
|
+
const UPDATE_CAPS = ["every-14-days", "never"];
|
|
12191
|
+
/** The updates file for a profile: in the same folder, the profile's name with `.updates.json` for its `.json`. */
|
|
12192
|
+
function updatesPathFor(profilePath) {
|
|
12193
|
+
return sidePathFor(profilePath, "updates");
|
|
12194
|
+
}
|
|
12195
|
+
const updatesSchema = strictObject({
|
|
12196
|
+
organisation: string(),
|
|
12197
|
+
max: _enum(UPDATE_CAPS, { message: "max is every-14-days or never: a cap only ever makes updates less frequent" }).optional(),
|
|
12198
|
+
background: literal(false, { message: "background is false or left out: a cap can only turn background updates off" }).optional()
|
|
12199
|
+
});
|
|
12200
|
+
/** The updates file, held to the profile's organisation (`readSideFile`). */
|
|
12201
|
+
async function readUpdatesFile(path, profile) {
|
|
12202
|
+
const read = await readSideFile(path, profile, {
|
|
12203
|
+
noun: "the updates file",
|
|
12204
|
+
size: "an updates file",
|
|
12205
|
+
schema: updatesSchema,
|
|
12206
|
+
hint: "It names the organisation, and the caps on how its members update and nothing else: `max` (every-14-days or never) and `background` (false). Ask your organisation for its current one."
|
|
12207
|
+
});
|
|
12208
|
+
if (read === void 0) return void 0;
|
|
12209
|
+
const { max, background } = read.document;
|
|
12210
|
+
return {
|
|
12211
|
+
path: read.path,
|
|
12212
|
+
sha256: read.sha256,
|
|
12213
|
+
...max === void 0 ? {} : { max },
|
|
12214
|
+
...background === void 0 ? {} : { background: false }
|
|
12215
|
+
};
|
|
12216
|
+
}
|
|
12217
|
+
/**
|
|
12218
|
+
* The caps file beside a profile, read with no profile to hold it to but the organisation's word: what stands in for
|
|
12219
|
+
* the caps the configuration records when `config.json` cannot be read (`effectiveUpdates`, failing closed). Beside the
|
|
12220
|
+
* file the path names (`profileTarget`), in the same bounded, strict read, held to the same organisation. Refused as
|
|
12221
|
+
* `readProfileFile` refuses it; undefined when there is none.
|
|
12222
|
+
*/
|
|
12223
|
+
async function readUpdatesBeside(profilePath, organisation) {
|
|
12224
|
+
return readUpdatesFile(updatesPathFor(profileTarget(profilePath)), { organisation });
|
|
12225
|
+
}
|
|
12226
|
+
/** The systemd units' and the Windows task's name. */
|
|
12227
|
+
const UPDATE_JOB_NAME = "agentcomms-update";
|
|
12228
|
+
Object.freeze(["update", "--background"]);
|
|
12229
|
+
function updateJobSupported(platform) {
|
|
12230
|
+
return platform === "darwin" || platform === "linux" || platform === "win32";
|
|
12231
|
+
}
|
|
12232
|
+
function updateJobMarkerPath(stateDir) {
|
|
12233
|
+
return join(stateDir, "update-job.json");
|
|
12234
|
+
}
|
|
12235
|
+
const isTime$1 = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
12236
|
+
/** The record, or null when there is none — or none this release can read, which is treated as none. */
|
|
12237
|
+
async function readUpdateJobMarker(stateDir) {
|
|
12238
|
+
let parsed;
|
|
12239
|
+
try {
|
|
12240
|
+
parsed = JSON.parse(await readFile(updateJobMarkerPath(stateDir), "utf8"));
|
|
12241
|
+
} catch {
|
|
12242
|
+
return null;
|
|
12243
|
+
}
|
|
12244
|
+
const record = parsed;
|
|
12245
|
+
if (typeof record !== "object" || record === null || !updateJobSupported(String(record.platform)) || typeof record.installedAt !== "string" || typeof record.label !== "string") return null;
|
|
12246
|
+
return {
|
|
12247
|
+
platform: record.platform,
|
|
12248
|
+
installedAt: record.installedAt,
|
|
12249
|
+
label: record.label,
|
|
12250
|
+
...isTime$1(record.lastRun) ? { lastRun: record.lastRun } : {}
|
|
12251
|
+
};
|
|
12252
|
+
}
|
|
12253
|
+
function within$1(at, now, intervalMs) {
|
|
12254
|
+
if (at === void 0 || !isTime$1(at)) return false;
|
|
12255
|
+
const since = now.getTime() - Date.parse(at);
|
|
12256
|
+
return since >= -3e5 && since < intervalMs;
|
|
12257
|
+
}
|
|
12258
|
+
/** The job's health, from its record; null when there is none (`readUpdateJobMarker`). */
|
|
12259
|
+
async function updateJobHealth(stateDir, now = /* @__PURE__ */ new Date()) {
|
|
12260
|
+
const marker = await readUpdateJobMarker(stateDir);
|
|
12261
|
+
if (marker === null) return null;
|
|
12262
|
+
return {
|
|
12263
|
+
marker,
|
|
12264
|
+
working: within$1(marker.lastRun, now, 1728e5) || within$1(marker.installedAt, now, 1728e5),
|
|
12265
|
+
since: marker.lastRun ?? marker.installedAt
|
|
12266
|
+
};
|
|
12267
|
+
}
|
|
12268
|
+
Object.freeze(["/usr/bin", "/bin"]);
|
|
12269
|
+
/** A PowerShell single-quoted string: nothing inside is special but a single quote — the curly ones too — doubled. */
|
|
12270
|
+
function powerShellLiteral(text) {
|
|
12271
|
+
return `'${text.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
|
|
12272
|
+
}
|
|
12273
|
+
`${powerShellLiteral(UPDATE_JOB_NAME)}`;
|
|
12274
|
+
Object.freeze(["every-14-days", "never"]);
|
|
12275
|
+
/**
|
|
12276
|
+
* The daily update check, as every server and command reads it (design 2026-09-28, and 2026-10-16 for the notice that
|
|
12277
|
+
* replaced its stop): one file per machine, `update-check.json` in the state directory, saying when the registry was
|
|
12278
|
+
* last asked, the latest release it named, whether this machine's registrations were behind it, any "not now" with when
|
|
12279
|
+
* it runs out, and when a command last said so.
|
|
12280
|
+
*
|
|
12281
|
+
* This is the reader, and it has **no network code at all**: it reads and writes that file, and says what it means.
|
|
12282
|
+
* Asking the registry is the checker's (`update-check.ts`), which every server and command but WhatsApp's also
|
|
12283
|
+
* imports. WhatsApp's package promises it reaches no network — `packages/whatsapp/test/no-network.test.ts` holds it to
|
|
12284
|
+
* that — so it imports only this, and learns of an update once any other server or command on the machine has asked.
|
|
12285
|
+
*/
|
|
12286
|
+
/** The file, in the state directory: one per machine, shared by every server and command on it. */
|
|
12287
|
+
const UPDATE_CHECK_FILE = "update-check.json";
|
|
12288
|
+
/**
|
|
12289
|
+
* The switch that turns the whole thing off for one process — no check, no notice — whatever the setting says. Every
|
|
12290
|
+
* test harness and every script here that runs a command or a server sets it, so no test and no verify step asks the
|
|
12291
|
+
* real registry.
|
|
12292
|
+
*/
|
|
12293
|
+
const UPDATE_CHECK_ENV = "AGENT_COMMS_UPDATE_CHECK";
|
|
12294
|
+
/**
|
|
12295
|
+
* The level for one process, whatever the machine's (design 2026-10-16, U2): `never` switches it all off as
|
|
12296
|
+
* `AGENT_COMMS_UPDATE_CHECK=off` does. Any other word leaves the machine's level as it is.
|
|
12297
|
+
*/
|
|
12298
|
+
const UPDATES_ENV = "AGENT_COMMS_UPDATES";
|
|
12299
|
+
/**
|
|
12300
|
+
* Set by what starts a server from a release it pins itself — the Claude Code plugin's launcher, the Gemini
|
|
12301
|
+
* extension's manifest — to say so: `claude-code-plugin`, `gemini-extension`. No registration here names that server,
|
|
12302
|
+
* and `comms_update` never moves it; it changes when the plugin or the extension is updated. So its notice always says
|
|
12303
|
+
* where it is updated, whatever the channel's registrations say: restarting would start the release the plugin pins.
|
|
12304
|
+
*/
|
|
12305
|
+
const UPDATE_STARTED_BY_ENV = "AGENT_COMMS_STARTED_BY";
|
|
12306
|
+
const DAY_MS = 864e5;
|
|
12307
|
+
/** How long a check is good for on `auto`: the registry is asked at most once in this long, by the whole machine. */
|
|
12308
|
+
const UPDATE_CHECK_INTERVAL_MS = DAY_MS;
|
|
12309
|
+
/** How long a check, and a notice, is good for on `every-14-days`. */
|
|
12310
|
+
const SPACED_INTERVAL_MS = 14 * DAY_MS;
|
|
12311
|
+
Object.freeze([
|
|
12312
|
+
1,
|
|
12313
|
+
7,
|
|
12314
|
+
14
|
|
12315
|
+
]);
|
|
12316
|
+
function readSecurityShown(value) {
|
|
12317
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12318
|
+
const { version, approvals } = value;
|
|
12319
|
+
if (!isPlainVersion(version) || !Array.isArray(approvals)) return null;
|
|
12320
|
+
return {
|
|
12321
|
+
version,
|
|
12322
|
+
approvals: approvals.filter((id) => typeof id === "string" && APPROVAL_ID_PATTERN.test(id)).slice(-32)
|
|
12323
|
+
};
|
|
12324
|
+
}
|
|
12325
|
+
const NOTICE_OUTCOMES = [
|
|
12326
|
+
"installed",
|
|
12327
|
+
"skipped",
|
|
12328
|
+
"refused",
|
|
12329
|
+
"failed",
|
|
12330
|
+
"job-removed"
|
|
12331
|
+
];
|
|
12332
|
+
function readBackgroundNotice(value) {
|
|
12333
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12334
|
+
const { at, outcome, version, text } = value;
|
|
12335
|
+
if (!isTime(at) || !NOTICE_OUTCOMES.includes(outcome) || typeof text !== "string") return null;
|
|
12336
|
+
return {
|
|
12337
|
+
at,
|
|
12338
|
+
outcome,
|
|
12339
|
+
version: isVersion(version) ? version : null,
|
|
12340
|
+
text: text.slice(0, 600)
|
|
12341
|
+
};
|
|
12342
|
+
}
|
|
12343
|
+
const EMPTY_UPDATE_CHECK = Object.freeze({
|
|
12344
|
+
lastChecked: null,
|
|
12345
|
+
latest: null,
|
|
12346
|
+
behind: null,
|
|
12347
|
+
current: null,
|
|
12348
|
+
lastError: null,
|
|
12349
|
+
snoozedUntil: null,
|
|
12350
|
+
checking: null,
|
|
12351
|
+
commandNoticeAt: null,
|
|
12352
|
+
noticeAt: null,
|
|
12353
|
+
pausedAt: null,
|
|
12354
|
+
pausedUntil: null,
|
|
12355
|
+
release: null,
|
|
12356
|
+
securityAsked: null,
|
|
12357
|
+
securityShown: null,
|
|
12358
|
+
backgroundNotice: null,
|
|
12359
|
+
moves: null
|
|
12360
|
+
});
|
|
12361
|
+
function updateCheckPath(stateDir) {
|
|
12362
|
+
return join(stateDir, UPDATE_CHECK_FILE);
|
|
12363
|
+
}
|
|
12364
|
+
const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
12365
|
+
const isWords = (value) => Array.isArray(value) && value.every((word) => typeof word === "string");
|
|
12366
|
+
function readRelease(value) {
|
|
12367
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12368
|
+
const { version, security, needsDecision } = value;
|
|
12369
|
+
if (!isPlainVersion(version)) return null;
|
|
12370
|
+
return {
|
|
12371
|
+
version,
|
|
12372
|
+
security: security === true,
|
|
12373
|
+
needsDecision: needsDecision === true
|
|
12374
|
+
};
|
|
12375
|
+
}
|
|
12376
|
+
function readCurrent(value) {
|
|
12377
|
+
if (typeof value !== "object" || value === null) return null;
|
|
12378
|
+
const { registered, global } = value;
|
|
12379
|
+
return isWords(registered) && isWords(global) ? {
|
|
12380
|
+
registered: [...registered],
|
|
12381
|
+
global: [...global]
|
|
12382
|
+
} : null;
|
|
12383
|
+
}
|
|
12384
|
+
/**
|
|
12385
|
+
* The record as the file holds it, each field checked on its own: one that is missing or not what it should be reads
|
|
12386
|
+
* as not known. A file that is missing, unreadable or not JSON is an empty record — never a reason to stop anything.
|
|
12387
|
+
*/
|
|
12388
|
+
async function readUpdateCheck(stateDir) {
|
|
12389
|
+
let parsed;
|
|
12390
|
+
try {
|
|
12391
|
+
parsed = JSON.parse(await readFile(updateCheckPath(stateDir), "utf8"));
|
|
12392
|
+
} catch {
|
|
12393
|
+
return { ...EMPTY_UPDATE_CHECK };
|
|
12394
|
+
}
|
|
12395
|
+
const held = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
12396
|
+
return {
|
|
12397
|
+
lastChecked: isTime(held.lastChecked) ? held.lastChecked : null,
|
|
12398
|
+
latest: isPlainVersion(held.latest) ? held.latest : null,
|
|
12399
|
+
behind: typeof held.behind === "boolean" ? held.behind : null,
|
|
12400
|
+
current: readCurrent(held.current),
|
|
12401
|
+
lastError: typeof held.lastError === "string" ? held.lastError.slice(0, 300) : null,
|
|
12402
|
+
snoozedUntil: isTime(held.snoozedUntil) ? held.snoozedUntil : null,
|
|
12403
|
+
checking: isTime(held.checking) ? held.checking : null,
|
|
12404
|
+
commandNoticeAt: isTime(held.commandNoticeAt) ? held.commandNoticeAt : null,
|
|
12405
|
+
noticeAt: isTime(held.noticeAt) ? held.noticeAt : null,
|
|
12406
|
+
pausedAt: isTime(held.pausedAt) ? held.pausedAt : null,
|
|
12407
|
+
pausedUntil: isTime(held.pausedUntil) ? held.pausedUntil : null,
|
|
12408
|
+
release: readRelease(held.release),
|
|
12409
|
+
securityAsked: isPlainVersion(held.securityAsked) ? held.securityAsked : null,
|
|
12410
|
+
securityShown: readSecurityShown(held.securityShown),
|
|
12411
|
+
backgroundNotice: readBackgroundNotice(held.backgroundNotice),
|
|
12412
|
+
moves: typeof held.moves === "number" && Number.isInteger(held.moves) && held.moves >= 0 ? held.moves : null
|
|
12413
|
+
};
|
|
12414
|
+
}
|
|
12415
|
+
/**
|
|
12416
|
+
* Reads the record, changes it and writes it back, under a lock beside it, atomically — so a server recording a check
|
|
12417
|
+
* and a person's "not now" arriving at the same moment each keep what the other wrote. Returns what `change` returned
|
|
12418
|
+
* alongside the record written; `change` returning `null` writes nothing.
|
|
12419
|
+
*/
|
|
12420
|
+
async function changeUpdateCheck(stateDir, change) {
|
|
12421
|
+
return withFileLock(join(stateDir, ".update-check.lock"), async () => {
|
|
12422
|
+
const current = await readUpdateCheck(stateDir);
|
|
12423
|
+
const next = change(current);
|
|
12424
|
+
if (next === null) return {
|
|
12425
|
+
record: current,
|
|
12426
|
+
result: void 0,
|
|
12427
|
+
written: false
|
|
12428
|
+
};
|
|
12429
|
+
await writeFileAtomic(updateCheckPath(stateDir), `${JSON.stringify(next.record, null, 2)}\n`);
|
|
12430
|
+
return {
|
|
12431
|
+
record: next.record,
|
|
12432
|
+
result: next.result,
|
|
12433
|
+
written: true
|
|
12434
|
+
};
|
|
12435
|
+
}, { timeoutMs: 2e3 });
|
|
12436
|
+
}
|
|
12437
|
+
/**
|
|
12438
|
+
* Whether the check is due: never asked, asked a day or more ago, or — a clock that went back — asked more than a few
|
|
12439
|
+
* minutes in the future. A few minutes are allowed for, because two processes' clocks are read at slightly different
|
|
12440
|
+
* moments, and a check just claimed by one must not look due to the other.
|
|
12441
|
+
*/
|
|
12442
|
+
function updateCheckDue(record, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
|
|
12443
|
+
return !within(record.lastChecked, now, intervalMs);
|
|
12444
|
+
}
|
|
12445
|
+
/** Whether `at` is less than `intervalMs` ago — a time from more than a few minutes in the future is not trusted. */
|
|
12446
|
+
function within(at, now, intervalMs) {
|
|
12447
|
+
if (at === null) return false;
|
|
12448
|
+
const since = now.getTime() - Date.parse(at);
|
|
12449
|
+
return since >= -CLOCK_SKEW_MS && since < intervalMs;
|
|
12450
|
+
}
|
|
12451
|
+
const CLOCK_SKEW_MS = 3e5;
|
|
12452
|
+
/** What turned updates off for this process, from its environment: `CI`, or a switch. Null when nothing did. */
|
|
12453
|
+
function updateCheckSwitchedOff(env) {
|
|
12454
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return "CI";
|
|
12455
|
+
const value = env[UPDATE_CHECK_ENV]?.trim().toLowerCase();
|
|
12456
|
+
if (value !== void 0 && [
|
|
12457
|
+
"off",
|
|
12458
|
+
"0",
|
|
12459
|
+
"false",
|
|
12460
|
+
"no"
|
|
12461
|
+
].includes(value)) return UPDATE_CHECK_ENV;
|
|
12462
|
+
if (env["AGENT_COMMS_UPDATES"]?.trim().toLowerCase() === "never") return UPDATES_ENV;
|
|
12463
|
+
return null;
|
|
12464
|
+
}
|
|
12465
|
+
/** How much a level updates: `auto` 2, `every-14-days` 1, `never` 0. */
|
|
12466
|
+
const rankOf = (level) => UPDATE_LEVELS.length - 1 - UPDATE_LEVELS.indexOf(level);
|
|
12467
|
+
/** `profileTarget`, for a path read back from the configuration: one it cannot follow is shown as it is. */
|
|
12468
|
+
function safeTarget(path) {
|
|
12469
|
+
try {
|
|
12470
|
+
return profileTarget(path);
|
|
12471
|
+
} catch {
|
|
12472
|
+
return path;
|
|
12473
|
+
}
|
|
12474
|
+
}
|
|
12475
|
+
/**
|
|
12476
|
+
* The caps the organisation profiles here name (design 2026-10-16, U8): the strictest `max`, and the first profile
|
|
12477
|
+
* with `background: false`, each with where it comes from. A `max` word this release does not know counts as `never`,
|
|
12478
|
+
* the strictest it could mean: caps only ever tighten.
|
|
12479
|
+
*/
|
|
12480
|
+
function organisationCaps(config) {
|
|
12481
|
+
let cap;
|
|
12482
|
+
let backgroundOff;
|
|
12483
|
+
for (const [organisation, record] of Object.entries(organisationsOf(config)).sort(([a], [b]) => a.localeCompare(b))) {
|
|
12484
|
+
const caps = record.updates;
|
|
12485
|
+
if (caps === void 0) continue;
|
|
12486
|
+
const where = {
|
|
12487
|
+
organisation,
|
|
12488
|
+
source: updatesPathFor(safeTarget(record.source.path))
|
|
12489
|
+
};
|
|
12490
|
+
if (caps.max !== void 0) {
|
|
12491
|
+
const level = caps.max === "every-14-days" ? "every-14-days" : "never";
|
|
12492
|
+
if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
|
|
12493
|
+
...where,
|
|
12494
|
+
level
|
|
12495
|
+
};
|
|
12496
|
+
}
|
|
12497
|
+
if (caps.background === false && backgroundOff === void 0) backgroundOff = where;
|
|
12498
|
+
}
|
|
12499
|
+
return {
|
|
12500
|
+
cap,
|
|
12501
|
+
backgroundOff
|
|
12502
|
+
};
|
|
12503
|
+
}
|
|
12504
|
+
/**
|
|
12505
|
+
* The level in force by the configuration alone (design 2026-10-16, U2, U8): the level set, capped by its
|
|
12506
|
+
* organisations' caps, and the organisation that turns background updates off. What `effectiveUpdates` starts from,
|
|
12507
|
+
* and what a background run and the background job's setup read — neither of which runs where the environment
|
|
12508
|
+
* switched updates off, and neither of which counts a configuration it cannot read as anything.
|
|
12509
|
+
*/
|
|
12510
|
+
function configuredUpdates(config) {
|
|
12511
|
+
const set = updateLevelOf(config);
|
|
12512
|
+
const caps = organisationCaps(config);
|
|
12513
|
+
const cap = caps.cap !== void 0 && rankOf(caps.cap.level) < rankOf(set) ? caps.cap : void 0;
|
|
12514
|
+
return {
|
|
12515
|
+
level: cap?.level ?? set,
|
|
12516
|
+
set,
|
|
12517
|
+
...cap ? { cap } : {},
|
|
12518
|
+
...caps.backgroundOff ? { backgroundOff: caps.backgroundOff } : {}
|
|
12519
|
+
};
|
|
12520
|
+
}
|
|
12521
|
+
/**
|
|
12522
|
+
* The caps that stand in for the configuration's when `config.json` cannot be read (U8, failing closed): for every
|
|
12523
|
+
* organisation the file still names — read loosely, as JSON, whatever its schema says — the caps it records, and the
|
|
12524
|
+
* caps file beside its profile, read again (`readUpdatesBeside`). The strictest `max` of them all, and the first
|
|
12525
|
+
* `background: false`. A caps file that cannot be read is passed over; what the configuration recorded of it stands.
|
|
12526
|
+
* A file that is not JSON at all names no organisation, and so no cap.
|
|
12527
|
+
*/
|
|
12528
|
+
async function capsWithoutConfig(core) {
|
|
12529
|
+
let raw;
|
|
12530
|
+
try {
|
|
12531
|
+
raw = JSON.parse(await readFile(core.config.path, "utf8"));
|
|
12532
|
+
} catch {
|
|
12533
|
+
return { read: 0 };
|
|
12534
|
+
}
|
|
12535
|
+
const organisations = raw?.organisations;
|
|
12536
|
+
if (typeof organisations !== "object" || organisations === null) return { read: 0 };
|
|
12537
|
+
let cap;
|
|
12538
|
+
let backgroundOff;
|
|
12539
|
+
let read = 0;
|
|
12540
|
+
const take = (organisation, source, caps) => {
|
|
12541
|
+
read += 1;
|
|
12542
|
+
if (caps.max !== void 0) {
|
|
12543
|
+
const level = caps.max === "every-14-days" ? "every-14-days" : "never";
|
|
12544
|
+
if (cap === void 0 || rankOf(level) < rankOf(cap.level)) cap = {
|
|
12545
|
+
organisation,
|
|
12546
|
+
source,
|
|
12547
|
+
level
|
|
12548
|
+
};
|
|
12549
|
+
}
|
|
12550
|
+
if (caps.background === false && backgroundOff === void 0) backgroundOff = {
|
|
12551
|
+
organisation,
|
|
12552
|
+
source
|
|
12553
|
+
};
|
|
12554
|
+
};
|
|
12555
|
+
for (const [organisation, record] of Object.entries(organisations).sort(([a], [b]) => a.localeCompare(b))) {
|
|
12556
|
+
const held = typeof record === "object" && record !== null ? record : {};
|
|
12557
|
+
const path = typeof held.source?.path === "string" && isAbsolute(held.source.path) ? held.source.path : void 0;
|
|
12558
|
+
const source = path === void 0 ? core.config.path : updatesPathFor(safeTarget(path));
|
|
12559
|
+
if (typeof held.updates === "object" && held.updates !== null) take(organisation, source, held.updates);
|
|
12560
|
+
if (path === void 0) continue;
|
|
12561
|
+
try {
|
|
12562
|
+
const file = await readUpdatesBeside(path, organisation);
|
|
12563
|
+
if (file !== void 0) take(organisation, file.path, file);
|
|
12564
|
+
} catch {}
|
|
12565
|
+
}
|
|
12566
|
+
return {
|
|
12567
|
+
...cap ? { cap } : {},
|
|
12568
|
+
...backgroundOff ? { backgroundOff } : {},
|
|
12569
|
+
read
|
|
12570
|
+
};
|
|
12571
|
+
}
|
|
12572
|
+
/**
|
|
12573
|
+
* The level in force here, read from the environment first, then the configuration and its organisations' caps.
|
|
12574
|
+
*
|
|
12575
|
+
* A configuration that cannot be read sets the default, `auto` — what is wrong with it is for the doctor and the tools
|
|
12576
|
+
* to say, and it must not quietly turn updates off — but it never lifts an organisation's cap: the strictest cap that
|
|
12577
|
+
* can still be read in its place holds (`capsWithoutConfig`), and nothing installs in the background, which reads the
|
|
12578
|
+
* configuration too.
|
|
12579
|
+
*/
|
|
12580
|
+
async function effectiveUpdates(core, env, now = /* @__PURE__ */ new Date()) {
|
|
12581
|
+
let configured;
|
|
12582
|
+
try {
|
|
12583
|
+
configured = configuredUpdates(await core.config.load());
|
|
12584
|
+
} catch (error) {
|
|
12585
|
+
const { read, cap, backgroundOff } = await capsWithoutConfig(core);
|
|
12586
|
+
configured = {
|
|
12587
|
+
level: cap?.level ?? "auto",
|
|
12588
|
+
set: "auto",
|
|
12589
|
+
...cap ? { cap } : {},
|
|
12590
|
+
...backgroundOff ? { backgroundOff } : {},
|
|
12591
|
+
unreadableConfig: {
|
|
12592
|
+
reason: shownText(error instanceof Error ? error.message : String(error), 200),
|
|
12593
|
+
read
|
|
12594
|
+
}
|
|
12595
|
+
};
|
|
12596
|
+
}
|
|
12597
|
+
const { level, set, ...extra } = configured;
|
|
12598
|
+
const switched = updateCheckSwitchedOff(env);
|
|
12599
|
+
if (switched !== null) return {
|
|
12600
|
+
level: "never",
|
|
12601
|
+
set,
|
|
12602
|
+
by: switched,
|
|
12603
|
+
background: false,
|
|
12604
|
+
...extra
|
|
12605
|
+
};
|
|
12606
|
+
if (level !== "auto" || extra.backgroundOff !== void 0 || extra.unreadableConfig !== void 0) return {
|
|
12607
|
+
level,
|
|
12608
|
+
set,
|
|
12609
|
+
by: "setting",
|
|
12610
|
+
background: false,
|
|
12611
|
+
...extra
|
|
12612
|
+
};
|
|
12613
|
+
const job = await updateJobHealth(core.paths.stateDir, now);
|
|
12614
|
+
return {
|
|
12615
|
+
level,
|
|
12616
|
+
set,
|
|
12617
|
+
by: "setting",
|
|
12618
|
+
background: job?.working === true,
|
|
12619
|
+
...extra,
|
|
12620
|
+
...job !== null && !job.working ? { jobSilent: { since: job.since } } : {}
|
|
12621
|
+
};
|
|
12622
|
+
}
|
|
12623
|
+
/** Whether a check may run at all, how often, and if not, what turned it off: the environment, then the level. */
|
|
12624
|
+
async function updateCheckEnabled(core, env) {
|
|
12625
|
+
const updates = await effectiveUpdates(core, env);
|
|
12626
|
+
if (updates.level === "never") return {
|
|
12627
|
+
on: false,
|
|
12628
|
+
by: updates.by
|
|
12629
|
+
};
|
|
12630
|
+
return {
|
|
12631
|
+
on: true,
|
|
12632
|
+
intervalMs: updates.level === "every-14-days" ? SPACED_INTERVAL_MS : UPDATE_CHECK_INTERVAL_MS
|
|
12633
|
+
};
|
|
12634
|
+
}
|
|
12635
|
+
/**
|
|
12636
|
+
* When a pause ends, while one holds at `now` — or null. A pause holds from when it was asked until its end, and only
|
|
12637
|
+
* while that end is at most 14 days after it was asked: a clock gone back to before it was asked, or an end written by
|
|
12638
|
+
* hand further off, holds nothing, so no pause ever lasts longer than 14 days from when it was asked.
|
|
12639
|
+
*/
|
|
12640
|
+
function pauseEnd(record, now) {
|
|
12641
|
+
if (record.pausedAt === null || record.pausedUntil === null) return null;
|
|
12642
|
+
const asked = Date.parse(record.pausedAt);
|
|
12643
|
+
const ends = Date.parse(record.pausedUntil);
|
|
12644
|
+
if (ends - asked > 12099e5) return null;
|
|
12645
|
+
if (now.getTime() < asked - CLOCK_SKEW_MS || now.getTime() >= ends) return null;
|
|
12646
|
+
return new Date(ends);
|
|
12647
|
+
}
|
|
12648
|
+
/**
|
|
12649
|
+
* The security fix this record says is out, for a process running `running`: the latest release, when its manifest
|
|
12650
|
+
* declares one and `running` is behind it. Null otherwise.
|
|
12651
|
+
*/
|
|
12652
|
+
function securityRelease(record, running) {
|
|
12653
|
+
const latest = countedLatest(record);
|
|
12654
|
+
if (latest === null || record.release?.version !== latest || !record.release.security) return null;
|
|
12655
|
+
return isVersion(running) && isBehind(running, latest) ? latest : null;
|
|
12656
|
+
}
|
|
12657
|
+
/**
|
|
12658
|
+
* The person answered the security line (design 2026-10-16, U7): the send or post `approvalId` was approved, or claimed
|
|
12659
|
+
* to be sent. When its preview carried the line, that release counts as asked from now on, and the next preview is as
|
|
12660
|
+
* it always was. An approval no preview carried it on answers nothing. Called by the approval store
|
|
12661
|
+
* (`ApprovalStoreOptions.answered`); it never throws, and a send never waits long on it.
|
|
12662
|
+
*/
|
|
12663
|
+
async function securityAnswered(stateDir, approvalId) {
|
|
12664
|
+
const carried = (record) => record.securityShown?.approvals.includes(approvalId) === true;
|
|
12665
|
+
try {
|
|
12666
|
+
if (!carried(await readUpdateCheck(stateDir))) return;
|
|
12667
|
+
await changeUpdateCheck(stateDir, (record) => {
|
|
12668
|
+
if (!carried(record) || record.securityShown === null || record.securityShown === void 0) return null;
|
|
12669
|
+
return { record: {
|
|
12670
|
+
...record,
|
|
12671
|
+
securityAsked: record.securityShown.version,
|
|
12672
|
+
securityShown: null
|
|
12673
|
+
} };
|
|
12674
|
+
});
|
|
12675
|
+
} catch {}
|
|
12676
|
+
}
|
|
12677
|
+
/** A local time as a person reads it in a preview: `2026-09-29 00:00`. */
|
|
12678
|
+
function localStamp(at) {
|
|
12679
|
+
const two = (value) => String(value).padStart(2, "0");
|
|
12680
|
+
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
12681
|
+
}
|
|
12682
|
+
/**
|
|
12683
|
+
* The latest release as the check counts it: a plain `MAJOR.MINOR.PATCH` — 0.8.0-rc.1 is never an update anybody is
|
|
12684
|
+
* told of, and 0.8.1+anything is not a release at all. Null when the file names none.
|
|
12685
|
+
*/
|
|
12686
|
+
function countedLatest(record) {
|
|
12687
|
+
return isPlainVersion(record.latest) ? record.latest : null;
|
|
12688
|
+
}
|
|
12689
|
+
/**
|
|
12690
|
+
* What the record means for a process running `running`, ignoring any "not now": null when there is nothing newer.
|
|
12691
|
+
*
|
|
12692
|
+
* `restart` only on the check's positive finding about this very server or command (`current`): a server whose every
|
|
12693
|
+
* registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
|
|
12694
|
+
* registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
|
|
12695
|
+
* configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
|
|
12696
|
+
* code, and a notice that said to restart would give advice that cannot work.
|
|
12697
|
+
*/
|
|
12698
|
+
function updateVerdict(record, running, where) {
|
|
12699
|
+
const latest = countedLatest(record);
|
|
12700
|
+
if (latest === null || !isVersion(running) || !isBehind(running, latest)) return null;
|
|
12701
|
+
return {
|
|
12702
|
+
kind: (where.surface === "server" ? record.current?.registered : record.current?.global)?.includes(where.channel) === true ? "restart" : "update",
|
|
12703
|
+
running,
|
|
12704
|
+
latest
|
|
12705
|
+
};
|
|
12706
|
+
}
|
|
12707
|
+
function openCore(options = {}) {
|
|
12708
|
+
const { paths, pathOverrides } = resolvePathIdentity(options);
|
|
12709
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
12710
|
+
const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
|
|
12711
|
+
caller: options.caller,
|
|
12712
|
+
paths,
|
|
12713
|
+
platform: options.platform,
|
|
12714
|
+
env: options.env
|
|
12715
|
+
});
|
|
12716
|
+
const config = new ConfigStore(paths.configDir, { handoffs });
|
|
12717
|
+
const audit = new AuditLog(paths.stateDir, now);
|
|
12718
|
+
let cached = null;
|
|
12719
|
+
return {
|
|
12720
|
+
paths,
|
|
12721
|
+
pathOverrides,
|
|
12722
|
+
config,
|
|
12723
|
+
states: new InboxStateStore(paths.stateDir),
|
|
12724
|
+
approvals: new ApprovalStore(paths.stateDir, {
|
|
12725
|
+
now,
|
|
12726
|
+
handoffs,
|
|
12727
|
+
loadConfig: () => config.load(),
|
|
12728
|
+
audit,
|
|
12729
|
+
answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
|
|
12730
|
+
}),
|
|
12731
|
+
ledger: new SendLedger(paths.stateDir, now),
|
|
12732
|
+
plans: new PlanStore(paths.stateDir, now),
|
|
12733
|
+
taint: new TaintStore(paths.stateDir, now),
|
|
12734
|
+
audit,
|
|
12735
|
+
handoffs,
|
|
12736
|
+
async secrets(kind) {
|
|
12737
|
+
const chosen = kind ?? secretsStoreOf(await config.load());
|
|
12738
|
+
if (cached?.kind === chosen) return cached.store;
|
|
12739
|
+
const store = await openSecretStore(chosen, {
|
|
12740
|
+
secretsDir: paths.secretsDir,
|
|
12741
|
+
namespace: keychainNamespace(paths.configDir),
|
|
12742
|
+
handoffs
|
|
12743
|
+
});
|
|
12744
|
+
cached = {
|
|
12745
|
+
kind: chosen,
|
|
12746
|
+
store
|
|
12747
|
+
};
|
|
12748
|
+
return store;
|
|
12749
|
+
}
|
|
12750
|
+
};
|
|
12751
|
+
}
|
|
12752
|
+
/**
|
|
12753
|
+
* A whole number as it was given: a string of digits and nothing else (surrounding spaces aside), or a number that is
|
|
12754
|
+
* whole — and `NaN` for anything else.
|
|
12755
|
+
*
|
|
12756
|
+
* `Number.parseInt` read `abc` as NaN, `12abc` as 12 and `1e2` as 1; `Number` reads `1e2` as 100, `0x10` as 16 and an
|
|
12757
|
+
* empty string as 0. Each of those is a number nobody typed, so none of them is read as one here. A value too large to
|
|
12758
|
+
* hold exactly is not one either: it would be used as some other number.
|
|
12759
|
+
*/
|
|
12760
|
+
function readWholeNumber(raw) {
|
|
12761
|
+
const value = typeof raw === "number" ? raw : typeof raw === "string" && /^[0-9]+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
|
|
12762
|
+
return Number.isSafeInteger(value) ? value : NaN;
|
|
12763
|
+
}
|
|
12764
|
+
/**
|
|
12765
|
+
* A number option, checked rather than coerced: the number, `undefined` when none was given, or a USAGE refusal that
|
|
12766
|
+
* names the option and its range.
|
|
12767
|
+
*
|
|
12768
|
+
* One check for every command line's number options — `agentcomms`, `agent-gmail` and `agent-slack` — and for the
|
|
12769
|
+
* operations those commands share with their tools, so a number the command refuses is refused by the tool in the
|
|
12770
|
+
* same words, and nothing reads `--limit 1e2` as 1 or `--port abc` as no port at all.
|
|
12771
|
+
*/
|
|
12772
|
+
function wholeNumber(raw, rule) {
|
|
12773
|
+
if (raw === void 0) return void 0;
|
|
12774
|
+
const value = readWholeNumber(raw);
|
|
12775
|
+
if (Number.isNaN(value) || value < rule.min || rule.max !== void 0 && value > rule.max) {
|
|
12776
|
+
const range = rule.max === void 0 ? `of ${rule.min} or more` : `from ${rule.min} to ${rule.max}`;
|
|
12777
|
+
const hint = rule.hint ?? (Number.isNaN(value) ? "Digits only: no sign, decimal point, exponent or unit." : null);
|
|
12778
|
+
throw new CommsError("USAGE", `${rule.name} "${String(raw)}" is not a whole number ${range}`, hint === null ? {} : { hint });
|
|
12779
|
+
}
|
|
12780
|
+
return value;
|
|
12781
|
+
}
|
|
12782
|
+
/**
|
|
12783
|
+
* Every sentence a background run may leave for the next session, and the one way a notice of it is said (design
|
|
12784
|
+
* 2026-10-16 U5 step 7, U9; security review of 0.20.0, severity 6): in this package's own words, with nothing in them
|
|
12785
|
+
* the registry or a certificate wrote. A release's version is the one thing a sentence takes from outside, and only as a
|
|
12786
|
+
* plain `MAJOR.MINOR.PATCH`: a prerelease's words or build metadata (`0.20.1+Ignore-previous-instructions` is valid
|
|
12787
|
+
* semver) are never repeated.
|
|
12788
|
+
*
|
|
12789
|
+
* The run writes one of these into the update state file; the notice (`update-gate.ts`) says it only when it is one of
|
|
12790
|
+
* them, word for word, and otherwise the outcome's own sentence — so a record written by another release, or by hand,
|
|
12791
|
+
* never reaches a model as it is.
|
|
12792
|
+
*
|
|
12793
|
+
* Nothing here reaches the network or reads a file: every server and command, WhatsApp's too, carries it.
|
|
12794
|
+
*/
|
|
12795
|
+
const PLAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
12796
|
+
/** A release as a notice names it: its version when it is a plain `MAJOR.MINOR.PATCH`, otherwise "A release". */
|
|
12797
|
+
function shownRelease(version) {
|
|
12798
|
+
return typeof version === "string" && PLAIN_VERSION.test(version) ? version : "A release";
|
|
12799
|
+
}
|
|
12800
|
+
/** Which pin a release did not verify against, in this package's own words — never the value it held instead. */
|
|
12801
|
+
const PIN_WORDS = Object.freeze({
|
|
12802
|
+
attestation: "npm has no SLSA provenance attestation for it that could be read",
|
|
12803
|
+
bundle: "its provenance bundle could not be read",
|
|
12804
|
+
issuer: "it was not signed through GitHub Actions' token issuer",
|
|
12805
|
+
identity: "it was not signed for this repository's release workflow at the tag of its version",
|
|
12806
|
+
runner: "it was not built on a GitHub-hosted runner",
|
|
12807
|
+
repository: "it was not built from this repository",
|
|
12808
|
+
ref: "it was not built from the tag of its version",
|
|
12809
|
+
subject: "what its provenance signs is not the tarball the registry serves for it",
|
|
12810
|
+
signature: "Sigstore did not verify its signature, certificate or transparency-log entry"
|
|
12811
|
+
});
|
|
12812
|
+
const PIN_FIELDS = Object.keys(PIN_WORDS);
|
|
12813
|
+
/** What went wrong, in fixed words. */
|
|
12814
|
+
const FAILED_WORDS = Object.freeze({
|
|
12815
|
+
install: "one of its packages could not be installed",
|
|
12816
|
+
tarball: "what npm installed is not the tarball whose provenance was verified",
|
|
12817
|
+
selfCheck: "one of its packages failed its self-check",
|
|
12818
|
+
switch: "a launcher could not be switched, so every launcher was put back",
|
|
12819
|
+
inUse: "an unfinished runtime of it is in the way"
|
|
12820
|
+
});
|
|
12821
|
+
const RESTART = "restart each MCP client that starts these servers (in Claude Code, /mcp and then Reconnect) to use it: a server already running keeps the code it started with";
|
|
12822
|
+
/** The sentences, by what happened. */
|
|
12823
|
+
const BACKGROUND_WORDS = Object.freeze({
|
|
12824
|
+
installed: (version) => `${shownRelease(version)} is installed; ${RESTART}.`,
|
|
12825
|
+
major: (version) => `${shownRelease(version)} is a new major release: it is never installed in the background. Update when it suits you — comms_update shows each step first.`,
|
|
12826
|
+
decision: (version) => `${shownRelease(version)} needs a decision from you — it, or a release before it, says so — so it was not installed in the background. comms_update shows each step first; its release notes say what it changes.`,
|
|
12827
|
+
downgrade: (version) => `The registry names ${shownRelease(version)} as the latest release, older than what runs here: nothing was changed in the background.`,
|
|
12828
|
+
waits: (version) => `${shownRelease(version)} waits for you: it was not installed in the background. comms_update shows each step first.`,
|
|
12829
|
+
unverified: (version, field) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow — ${PIN_WORDS[field]}. Nothing was changed.`,
|
|
12830
|
+
refused: (version) => `${shownRelease(version)} was not installed: a release could not be verified as built by this repository's release workflow. Nothing was changed.`,
|
|
12831
|
+
failed: (version, what) => `${shownRelease(version)} was not installed in the background: ${FAILED_WORDS[what]}. Nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
|
|
12832
|
+
stopped: (version) => `${shownRelease(version)} was not installed in the background, and nothing was switched; the next run tries again, and comms_audit_tail shows what went wrong.`,
|
|
12833
|
+
partlySwitched: (version) => `${shownRelease(version)} was not installed in the background: a launcher could not be switched, and not every launcher could be put back, so some here start ${shownRelease(version)} from their next start and some the release before. comms_audit_tail says which.`,
|
|
12834
|
+
heldUnreadable: "No update is installed in the background while update-held.json in the state directory cannot be read: it says which releases were rolled back. Delete it to hold none.",
|
|
12835
|
+
jobRemoved: "An organisation profile on this machine turned background updates off, so the background update job was removed. Updates are still checked daily, and a notice says when one is out."
|
|
12836
|
+
});
|
|
12837
|
+
/** Every sentence a run may leave for `outcome` and `version`, the outcome's own first. */
|
|
12838
|
+
function sentencesFor(outcome, version) {
|
|
12839
|
+
const words = BACKGROUND_WORDS;
|
|
12840
|
+
switch (outcome) {
|
|
12841
|
+
case "installed": return [words.installed(version ?? "")];
|
|
12842
|
+
case "skipped": return [
|
|
12843
|
+
words.waits(version),
|
|
12844
|
+
words.major(version ?? ""),
|
|
12845
|
+
words.decision(version ?? ""),
|
|
12846
|
+
words.downgrade(version ?? "")
|
|
12847
|
+
];
|
|
12848
|
+
case "refused": return [words.refused(version), ...PIN_FIELDS.map((field) => words.unverified(version, field))];
|
|
12849
|
+
case "failed": return [
|
|
12850
|
+
words.stopped(version),
|
|
12851
|
+
...Object.keys(FAILED_WORDS).map((what) => words.failed(version, what)),
|
|
12852
|
+
words.partlySwitched(version),
|
|
12853
|
+
words.heldUnreadable
|
|
12854
|
+
];
|
|
12855
|
+
case "job-removed": return [words.jobRemoved];
|
|
12856
|
+
}
|
|
12857
|
+
}
|
|
12858
|
+
/**
|
|
12859
|
+
* What a notice says of the last background run: the run's own sentence when it is one of these, word for word, for
|
|
12860
|
+
* its outcome and release — and otherwise the outcome's own. Never the record's text as it is.
|
|
12861
|
+
*/
|
|
12862
|
+
function backgroundNoticeLine(notice) {
|
|
12863
|
+
const sentences = sentencesFor(notice.outcome, notice.version);
|
|
12864
|
+
return sentences.includes(notice.text) ? notice.text : sentences[0];
|
|
12865
|
+
}
|
|
12866
|
+
/** The SHA-512 in a subresource-integrity string, as hex; null when it holds none. */
|
|
12867
|
+
function sha512Hex(integrity) {
|
|
12868
|
+
for (const part of integrity.trim().split(/\s+/)) {
|
|
12869
|
+
if (!part.startsWith("sha512-")) continue;
|
|
12870
|
+
const bytes = Buffer.from(part.slice(7).split("?")[0] ?? "", "base64");
|
|
12871
|
+
if (bytes.length === 64) return bytes.toString("hex");
|
|
12872
|
+
}
|
|
12873
|
+
return null;
|
|
12874
|
+
}
|
|
12875
|
+
/** The tarball integrity the registry gives for one version, or null when it gives none. */
|
|
12876
|
+
function releaseIntegrity(packument, version) {
|
|
12877
|
+
const integrity = packument.versions?.[version]?.dist?.integrity;
|
|
12878
|
+
return typeof integrity === "string" && integrity.trim() !== "" ? integrity : null;
|
|
12879
|
+
}
|
|
12880
|
+
/** What a release that verified was built by, in one phrase: the preview's and the dry run's words. */
|
|
12881
|
+
const BUILT_BY = "this repository's release workflow (.github/workflows/release.yml) at the tag of its version, on a GitHub-hosted runner, as its Sigstore provenance shows";
|
|
12882
|
+
/** The scope every package of this suite is published under, and so attested for. */
|
|
12883
|
+
const SUITE = "@agentcomms/";
|
|
12884
|
+
/**
|
|
12885
|
+
* Why the runtime at `runtime` is not what was verified, or null when it is (security review of 0.20.0, severity 5).
|
|
12886
|
+
*
|
|
12887
|
+
* Every package of this suite npm installed in it — `packageName` itself, and any of ours it depends on at any depth,
|
|
12888
|
+
* as Gmail depends on core — has to be, by the runtime's own lockfile, a tarball whose provenance verified: the one
|
|
12889
|
+
* whose SHA-512 is `sha512` for `packageName`, and for each other either one already verified (`known`, by
|
|
12890
|
+
* `package@version`, the SHA-512 it signs) or one `verify` verifies now, against the integrity npm recorded. npm checks
|
|
12891
|
+
* each tarball it downloads against the integrity the registry gives it then, and records that; a registry that gave
|
|
12892
|
+
* another one to the install than to the check, or a runtime made some other way, is caught here.
|
|
12893
|
+
*
|
|
12894
|
+
* The packages of others it holds are checked by npm alone, against the registry's integrity: no pin says who builds
|
|
12895
|
+
* them (SECURITY.md says so). No package's install script ran (`installManagedRuntime`).
|
|
12896
|
+
*/
|
|
12897
|
+
async function runtimeIntegrityProblem(runtime, packageName, sha512, options = {}) {
|
|
12898
|
+
let lock;
|
|
12899
|
+
try {
|
|
12900
|
+
lock = JSON.parse(await readFile(join(runtime, "package-lock.json"), "utf8"));
|
|
12901
|
+
} catch {
|
|
12902
|
+
lock = null;
|
|
12903
|
+
}
|
|
12904
|
+
const own = `node_modules/${packageName}`;
|
|
12905
|
+
const integrity = lock?.packages?.[own]?.integrity;
|
|
12906
|
+
if (typeof integrity !== "string") return `what is in ${runtime} cannot be shown to be the tarball whose provenance was verified: its lockfile records no integrity for ${packageName}`;
|
|
12907
|
+
if (sha512Hex(integrity) !== sha512) return `what is in ${runtime} is not the tarball whose provenance was verified: its lockfile records another integrity for ${packageName}`;
|
|
12908
|
+
const known = options.known ?? /* @__PURE__ */ new Map();
|
|
12909
|
+
for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
|
|
12910
|
+
if (key === own) continue;
|
|
12911
|
+
const name = key.slice(key.lastIndexOf("node_modules/") + 13);
|
|
12912
|
+
if (!key.includes("node_modules/") || !name.startsWith(SUITE)) continue;
|
|
12913
|
+
const version = entry?.version;
|
|
12914
|
+
const recorded = entry?.integrity;
|
|
12915
|
+
if (!isPlainVersion(version) || typeof recorded !== "string" || sha512Hex(recorded) === null) return `${name}, which ${packageName} in ${runtime} depends on, cannot be shown to be a verified tarball: its lockfile records no plain release version or no SHA-512 integrity for it`;
|
|
12916
|
+
const hex = sha512Hex(recorded);
|
|
12917
|
+
if (known.get(`${name}@${version}`) === hex) continue;
|
|
12918
|
+
if (!options.verify) return `${name}@${version}, which ${packageName} in ${runtime} depends on, cannot be verified here`;
|
|
12919
|
+
const verdict = await options.verify({
|
|
12920
|
+
packageName: name,
|
|
12921
|
+
version,
|
|
12922
|
+
integrity: recorded
|
|
12923
|
+
});
|
|
12924
|
+
if (!verdict.ok) return `${name}@${version}, which ${packageName} in ${runtime} depends on, is not a tarball built by this repository's release workflow — ${PIN_WORDS[verdict.field]}`;
|
|
12925
|
+
known.set(`${name}@${version}`, hex);
|
|
12926
|
+
}
|
|
12927
|
+
return null;
|
|
12928
|
+
}
|
|
12929
|
+
/**
|
|
12930
|
+
* The refusal of a whole update or install for one release that did not verify: which, and which pin — never what it
|
|
12931
|
+
* held. `hint` replaces the general one where there is more to say: where it can be verified, for a process that
|
|
12932
|
+
* cannot.
|
|
12933
|
+
*/
|
|
12934
|
+
function unverifiedRelease(packageName, version, why, pin, act = "update", hint) {
|
|
12935
|
+
const which = act === "update" ? "An update installs" : "An install fetches";
|
|
12936
|
+
const plain = isPlainVersion(version);
|
|
12937
|
+
return new CommsError("BAD_DATA", `nothing was installed or changed: ${plain ? `${packageName}@${version}` : `${packageName} at a version that is not a plain release`} could not be verified as built by this repository's release workflow — ${why}`, {
|
|
12938
|
+
hint: hint ?? `${which} only a release whose Sigstore provenance shows it was built by this repository's release workflow at the tag of its version, on a GitHub-hosted runner, and nothing skips that check. Nothing was prepared to approve. A release published minutes ago can be without its attestation for a while: try again later. If it still does not verify, do not install it some other way; report it as SECURITY.md says.`,
|
|
12939
|
+
details: {
|
|
12940
|
+
package: packageName,
|
|
12941
|
+
...plain ? { version } : {},
|
|
12942
|
+
pin
|
|
12943
|
+
}
|
|
12944
|
+
});
|
|
12945
|
+
}
|
|
12946
|
+
/**
|
|
12947
|
+
* Verifies every release in `wanted` — its tarball's integrity as the registry gives it, and npm's attestation for it,
|
|
12948
|
+
* held to the pins the background run holds a release to (design 2026-10-16 U5 step 3). The first that does not verify
|
|
12949
|
+
* refuses the whole update or install (`unverifiedRelease`); what verified is returned by package, for the preview
|
|
12950
|
+
* and for the integrity check after install. Reads; changes nothing. With no registry or no verifier, the first
|
|
12951
|
+
* release is refused before anything is asked.
|
|
12952
|
+
*/
|
|
12953
|
+
async function verifyReleaseSet(sources, wanted, act = "update", noVerifierHint) {
|
|
12954
|
+
const verified = /* @__PURE__ */ new Map();
|
|
12955
|
+
if (wanted.size === 0) return verified;
|
|
12956
|
+
const { registry, verify, why } = sources;
|
|
12957
|
+
for (const [packageName, version] of [...wanted].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
12958
|
+
if (!isPlainVersion(version)) throw unverifiedRelease(packageName, version, "it is not a plain release (MAJOR.MINOR.PATCH)", "version", act);
|
|
12959
|
+
if (registry === null || verify === null) throw unverifiedRelease(packageName, version, why, "verifier", act, noVerifierHint);
|
|
12960
|
+
let integrity;
|
|
12961
|
+
try {
|
|
12962
|
+
integrity = releaseIntegrity((await registry(packageName)).packument, version);
|
|
12963
|
+
} catch (error) {
|
|
12964
|
+
throw new CommsError("PROVIDER_UNAVAILABLE", `nothing was installed or changed: the registry's document of ${packageName} could not be read to verify ${version}: ${error instanceof Error ? error.message : String(error)}`, { hint: "Try again once the registry can be reached." });
|
|
12965
|
+
}
|
|
12966
|
+
if (integrity === null) throw unverifiedRelease(packageName, version, "the registry gives no tarball integrity for it", "subject", act);
|
|
12967
|
+
const verdict = await verify({
|
|
12968
|
+
packageName,
|
|
12969
|
+
version,
|
|
12970
|
+
integrity
|
|
12971
|
+
});
|
|
12972
|
+
if (!verdict.ok) throw unverifiedRelease(packageName, version, PIN_WORDS[verdict.field], verdict.field, act);
|
|
12973
|
+
verified.set(packageName, verdict.provenance);
|
|
12974
|
+
}
|
|
12975
|
+
return verified;
|
|
12976
|
+
}
|
|
12977
|
+
/**
|
|
11522
12978
|
* Registering, pruning and listing every declared channel or service MCP server, from the core.
|
|
11523
12979
|
*
|
|
11524
12980
|
* `agentcomms mcp install` registers core or a declared service; `comms_server_install` registers any declared
|
|
@@ -11682,6 +13138,14 @@ function installOptions(request) {
|
|
|
11682
13138
|
};
|
|
11683
13139
|
}
|
|
11684
13140
|
/**
|
|
13141
|
+
* The install receipt a fetched runtime keeps in its folder: which release, whether its provenance was verified, and
|
|
13142
|
+
* by whom it was installed — core, which verifies, or a channel's own command, which cannot (owner's decision,
|
|
13143
|
+
* 2026-10-17). Doctor and a person can read it; nothing else depends on it.
|
|
13144
|
+
*/
|
|
13145
|
+
const INSTALL_RECEIPT = ".agentcomms-install.json";
|
|
13146
|
+
/** A sentence's first letter in capitals: core's command, said after a full stop. */
|
|
13147
|
+
const capitalised = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
13148
|
+
/**
|
|
11685
13149
|
* Registering one declared server with one client, as a change.
|
|
11686
13150
|
*
|
|
11687
13151
|
* The effects are what a person is agreeing to, one sentence each, and everything that decides what the server may
|
|
@@ -11714,6 +13178,101 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11714
13178
|
const launcher = request.launcher ?? "managed";
|
|
11715
13179
|
const productNow = async () => own ?? await serverProduct(request.channel, request.launcher);
|
|
11716
13180
|
let planned;
|
|
13181
|
+
const sources = () => ({
|
|
13182
|
+
registry: deps.registry ?? null,
|
|
13183
|
+
verify: deps.verifyRelease ?? null,
|
|
13184
|
+
why: deps.verifyRelease === void 0 ? "nothing here can verify provenance: run the install from core's own command or server" : "this install was handed a verifier and no registry"
|
|
13185
|
+
});
|
|
13186
|
+
/**
|
|
13187
|
+
* Core's own command for this same install, located — the one that verifies — said by `say`; `instead` where no
|
|
13188
|
+
* command can be named.
|
|
13189
|
+
*/
|
|
13190
|
+
const coreWords = () => {
|
|
13191
|
+
const words = [
|
|
13192
|
+
"mcp",
|
|
13193
|
+
"install",
|
|
13194
|
+
"--server",
|
|
13195
|
+
request.channel,
|
|
13196
|
+
"--client",
|
|
13197
|
+
request.client
|
|
13198
|
+
];
|
|
13199
|
+
if (request.name !== void 0) words.push("--name", request.name);
|
|
13200
|
+
const pin = pinOf(request);
|
|
13201
|
+
const option = pinOption(request.channel);
|
|
13202
|
+
if (pin !== void 0 && option !== void 0 && option !== "readOnly") words.push(`--${option}`, pin);
|
|
13203
|
+
if (request.readOnly) words.push("--read-only");
|
|
13204
|
+
if (request.force) words.push("--force");
|
|
13205
|
+
return words;
|
|
13206
|
+
};
|
|
13207
|
+
const inCore = (say, instead) => {
|
|
13208
|
+
try {
|
|
13209
|
+
return handoffSentence(requireHandoffs(core, deps.platform).core(coreWords()), say, { instead });
|
|
13210
|
+
} catch {
|
|
13211
|
+
return instead;
|
|
13212
|
+
}
|
|
13213
|
+
};
|
|
13214
|
+
/** Where a process that cannot verify — a channel's own, with no Sigstore — sends the person: core, which can. */
|
|
13215
|
+
const verifyInCore = () => inCore((command) => `Core verifies a release's provenance before it installs it: run ${command} in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.`, "Core verifies a release's provenance before it installs it: run core's `mcp install --server` in your own terminal, or call comms_server_install from a chat. Nothing was prepared to approve.");
|
|
13216
|
+
/** Where the release a channel's own command installs unverified is checked: core's command, located. */
|
|
13217
|
+
const checkedInCore = () => inCore((command) => `core's ${command} checks it before it installs it`, "core's `mcp install --server` checks it before it installs it");
|
|
13218
|
+
/** The receipt a runtime this install fetched keeps beside it (`INSTALL_RECEIPT`). */
|
|
13219
|
+
const writeReceipt = (runtime, packageName, version, verified, by) => writeFileAtomic(join(runtime, INSTALL_RECEIPT), `${JSON.stringify({
|
|
13220
|
+
package: packageName,
|
|
13221
|
+
version,
|
|
13222
|
+
verified,
|
|
13223
|
+
by,
|
|
13224
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
13225
|
+
}, null, 2)}\n`);
|
|
13226
|
+
const verifiedRuntime = async (done) => {
|
|
13227
|
+
const { dataDir } = core.paths;
|
|
13228
|
+
const { packageName, version } = done.product;
|
|
13229
|
+
const runtime = managedRuntimeDir(dataDir, packageName, version);
|
|
13230
|
+
const there = await reusableRuntime(dataDir, packageName, version);
|
|
13231
|
+
if (done.verified === void 0) {
|
|
13232
|
+
if (there !== null) return void 0;
|
|
13233
|
+
if (done.unverifiedOwn) {
|
|
13234
|
+
await installManagedRuntime(context, done.product, version).catch(async (error) => {
|
|
13235
|
+
throw await remedyLocated(error, core);
|
|
13236
|
+
});
|
|
13237
|
+
await writeReceipt(runtime, packageName, version, false, `${label}’s own command`);
|
|
13238
|
+
return {
|
|
13239
|
+
package: packageName,
|
|
13240
|
+
version,
|
|
13241
|
+
verified: false,
|
|
13242
|
+
note: `${packageName}@${version} was installed without its provenance verified: this command cannot check provenance, so it installed the release it is running itself. ${capitalised(checkedInCore())}.`
|
|
13243
|
+
};
|
|
13244
|
+
}
|
|
13245
|
+
throw new CommsError("CONFIG", `this registration changed between being planned and being applied: ${runtime} was on this machine when it was planned, and is not now, so it would be fetched from npm without being verified. So nothing was installed or registered`, { hint: "Run the same install again: it is planned afresh from what is there now, verifies the release it would fetch, and asks again." });
|
|
13246
|
+
}
|
|
13247
|
+
if (there === null) await installManagedRuntime(context, done.product, version).catch(async (error) => {
|
|
13248
|
+
throw await remedyLocated(error, core);
|
|
13249
|
+
});
|
|
13250
|
+
const { sha512 } = done.verified;
|
|
13251
|
+
const problem = await runtimeIntegrityProblem(runtime, packageName, sha512, {
|
|
13252
|
+
verify: sources().verify,
|
|
13253
|
+
known: /* @__PURE__ */ new Map([[`${packageName}@${version}`, sha512]])
|
|
13254
|
+
});
|
|
13255
|
+
if (problem === null) {
|
|
13256
|
+
await writeReceipt(runtime, packageName, version, true, "core");
|
|
13257
|
+
return {
|
|
13258
|
+
package: packageName,
|
|
13259
|
+
version,
|
|
13260
|
+
verified: true
|
|
13261
|
+
};
|
|
13262
|
+
}
|
|
13263
|
+
if (there === null) await rm(runtime, {
|
|
13264
|
+
recursive: true,
|
|
13265
|
+
force: true
|
|
13266
|
+
}).catch(() => void 0);
|
|
13267
|
+
const general = "An install registers only a runtime whose packages of this suite were all built by this repository's release workflow, as their Sigstore provenance shows. If it happens again, do not install it some other way; report it as SECURITY.md says.";
|
|
13268
|
+
throw new CommsError("BAD_DATA", `nothing was registered: ${problem}${there === null ? ", so it was removed" : ""}. The launcher was not pointed at it`, {
|
|
13269
|
+
hint: there === null ? general : `To fetch a verified one, remove ${runtime} — whatever starts it stops until it is installed again — and run this install again: it fetches ${packageName}@${version} from npm and verifies it before anything is registered. ${general}`,
|
|
13270
|
+
details: {
|
|
13271
|
+
package: packageName,
|
|
13272
|
+
version
|
|
13273
|
+
}
|
|
13274
|
+
});
|
|
13275
|
+
};
|
|
11717
13276
|
return {
|
|
11718
13277
|
plan: async (config) => {
|
|
11719
13278
|
checkPin(config, request);
|
|
@@ -11723,9 +13282,21 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11723
13282
|
throw await remedyLocated(error, core);
|
|
11724
13283
|
});
|
|
11725
13284
|
const { target, previous, effective, kept, command } = preflight;
|
|
13285
|
+
const managedApply = request.print !== true && launcher === "managed";
|
|
13286
|
+
const fetches = managedApply && await reusableRuntime(core.paths.dataDir, product.packageName, version) === null;
|
|
13287
|
+
const checksDisk = managedApply && !fetches && deps.verifyRelease !== void 0;
|
|
13288
|
+
const from = sources();
|
|
13289
|
+
const running = deps.running ?? (own === void 0 ? void 0 : {
|
|
13290
|
+
packageName: own.packageName,
|
|
13291
|
+
version: own.version
|
|
13292
|
+
});
|
|
13293
|
+
const unverifiedOwn = fetches && deps.verifyRelease === void 0 && running !== void 0 && running.packageName === product.packageName && running.version === version;
|
|
13294
|
+
const verified = fetches && !unverifiedOwn || checksDisk ? (await verifyReleaseSet(from, /* @__PURE__ */ new Map([[product.packageName, version]]), "install", from.registry === null || from.verify === null ? verifyInCore() : void 0)).get(product.packageName) : void 0;
|
|
11726
13295
|
planned = {
|
|
11727
13296
|
product,
|
|
11728
|
-
install: plannedInstall(preflight)
|
|
13297
|
+
install: plannedInstall(preflight),
|
|
13298
|
+
verified,
|
|
13299
|
+
unverifiedOwn
|
|
11729
13300
|
};
|
|
11730
13301
|
const effects = [];
|
|
11731
13302
|
if (target.writes) {
|
|
@@ -11742,7 +13313,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11742
13313
|
if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
|
|
11743
13314
|
else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
|
|
11744
13315
|
}
|
|
11745
|
-
if (
|
|
13316
|
+
if (fetches) effects.push(`installs ${facts.packageName}@${version} from npm into ${managedRuntimeDir(core.paths.dataDir, facts.packageName, version)}`);
|
|
13317
|
+
if (request.print !== true && launcher === "managed") effects.push(launcherSwitchEffect(core.paths.dataDir, facts.packageName, label, version));
|
|
11746
13318
|
if (request.print !== true && launcher === "managed" && managedShimsSupported(context)) {
|
|
11747
13319
|
const path = managedShimPath({
|
|
11748
13320
|
env,
|
|
@@ -11751,6 +13323,8 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11751
13323
|
}, facts.binary);
|
|
11752
13324
|
effects.push(`writes or repoints the owned CLI shim at ${path}; a foreign file there is left unchanged`);
|
|
11753
13325
|
}
|
|
13326
|
+
if (verified !== void 0 && fetches) effects.push(`${verified.package}@${verified.version} was verified before this preview: built by ${BUILT_BY}`);
|
|
13327
|
+
else if (unverifiedOwn) effects.push(`${product.packageName}@${version} will not be verified: this command cannot check provenance, so it installs the release it is running itself; core's \`${coreWords().join(" ")}\` checks it before it installs it`);
|
|
11754
13328
|
return {
|
|
11755
13329
|
before: config,
|
|
11756
13330
|
after: config,
|
|
@@ -11760,18 +13334,30 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11760
13334
|
},
|
|
11761
13335
|
apply: async () => {
|
|
11762
13336
|
if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
|
|
13337
|
+
const provenance = request.print !== true && launcher === "managed" ? await verifiedRuntime(planned) : void 0;
|
|
11763
13338
|
const installed = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
|
|
11764
13339
|
throw await remedyLocated(error, core);
|
|
11765
13340
|
});
|
|
11766
13341
|
const result = request.print === true ? installed : await withManagedShim(context, planned.product, installed);
|
|
11767
13342
|
return {
|
|
11768
13343
|
...result,
|
|
13344
|
+
...provenance?.note === void 0 ? {} : { warnings: [...result.warnings, provenance.note] },
|
|
13345
|
+
...provenance === void 0 ? {} : { provenance },
|
|
11769
13346
|
restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
|
|
11770
13347
|
};
|
|
11771
13348
|
}
|
|
11772
13349
|
};
|
|
11773
13350
|
}
|
|
11774
13351
|
/**
|
|
13352
|
+
* What a managed install or update does to the product's stable launcher, as the preview says it (design 2026-10-16,
|
|
13353
|
+
* U6): it points it at the release's runtime, and every registration of the product that starts it — in any client —
|
|
13354
|
+
* runs that release from its next start.
|
|
13355
|
+
*/
|
|
13356
|
+
function launcherSwitchEffect(dataDir, packageName, label, version) {
|
|
13357
|
+
const { launcher } = launcherPaths(dataDir, launcherName(packageName));
|
|
13358
|
+
return `points the stable launcher ${launcher} at ${managedRuntimeDir(dataDir, packageName, version)}: every ${label} registration on this machine that starts it runs ${version} from its next start`;
|
|
13359
|
+
}
|
|
13360
|
+
/**
|
|
11775
13361
|
* A managed install's CLI shim (CUE-406), once the registration's outcome is known: the command the locator finds in
|
|
11776
13362
|
* the runtime the entry starts, written where nothing is or over this installation's own unchanged shim, and never
|
|
11777
13363
|
* over anything else. A shim that cannot be written leaves the registration as it went, and says how to repair both.
|
|
@@ -11779,8 +13365,6 @@ function serverInstallChange(core, env, request, own, deps = {}) {
|
|
|
11779
13365
|
*/
|
|
11780
13366
|
async function withManagedShim(context, product, installed) {
|
|
11781
13367
|
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
13368
|
const shimContext = {
|
|
11785
13369
|
env: context.env,
|
|
11786
13370
|
dataDir: context.core.paths.dataDir,
|
|
@@ -11794,6 +13378,11 @@ async function withManagedShim(context, product, installed) {
|
|
|
11794
13378
|
let shim;
|
|
11795
13379
|
let shimFailure;
|
|
11796
13380
|
try {
|
|
13381
|
+
const name = launcherName(product.packageName);
|
|
13382
|
+
const { launcher } = launcherPaths(context.core.paths.dataDir, name);
|
|
13383
|
+
const pointer = await readPointer(context.core.paths.dataDir, name);
|
|
13384
|
+
if (pointer === void 0) throw new CommsError("CONFIG", `the stable launcher ${launcher} has no pointer, so it starts nothing yet`);
|
|
13385
|
+
const cliEntry = launcherCli(pointer.current, name);
|
|
11797
13386
|
const located = locateCliCommand({
|
|
11798
13387
|
caller: {
|
|
11799
13388
|
url: pathToFileURL(cliEntry).href,
|
|
@@ -11818,8 +13407,8 @@ async function withManagedShim(context, product, installed) {
|
|
|
11818
13407
|
if (!located.ok) throw new CommsError("CONFIG", `the managed CLI command could not be located: ${located.message}`);
|
|
11819
13408
|
shim = await (context.writeShim ?? writeManagedShim)(shimContext, {
|
|
11820
13409
|
binary: product.binary,
|
|
11821
|
-
command:
|
|
11822
|
-
entry:
|
|
13410
|
+
command: [launcher],
|
|
13411
|
+
entry: launcher
|
|
11823
13412
|
});
|
|
11824
13413
|
} catch (error) {
|
|
11825
13414
|
shimFailure = error instanceof Error ? error.message : String(error);
|
|
@@ -11832,334 +13421,88 @@ async function withManagedShim(context, product, installed) {
|
|
|
11832
13421
|
warnings,
|
|
11833
13422
|
...shim === void 0 ? {} : { shim },
|
|
11834
13423
|
...shimFailure === void 0 ? {} : { shimFailure }
|
|
11835
|
-
};
|
|
11836
|
-
}
|
|
11837
|
-
/**
|
|
11838
|
-
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
11839
|
-
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
11840
|
-
* installer says only the words; it may not import the locator.
|
|
11841
|
-
*/
|
|
11842
|
-
async function remedyLocated(error, core) {
|
|
11843
|
-
const remedy = installRemedyOf(error);
|
|
11844
|
-
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
11845
|
-
const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
|
|
11846
|
-
if (server === void 0) return error;
|
|
11847
|
-
const maker = await requireHandoffs(core).registered();
|
|
11848
|
-
const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
|
|
11849
|
-
"mcp",
|
|
11850
|
-
"install",
|
|
11851
|
-
"--server",
|
|
11852
|
-
server,
|
|
11853
|
-
...remedy.words.slice(2)
|
|
11854
|
-
]);
|
|
11855
|
-
return new CommsError(error.code, error.message, {
|
|
11856
|
-
hint: handoffSentence(handoff, remedy.say),
|
|
11857
|
-
...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;
|
|
13424
|
+
};
|
|
12075
13425
|
}
|
|
12076
13426
|
/**
|
|
12077
|
-
*
|
|
12078
|
-
*
|
|
12079
|
-
*
|
|
12080
|
-
* registration names `latest`, or a command whose package is installed globally at `latest`. Anything else — no
|
|
12081
|
-
* registration the scan could see (a plugin's, an extension's, one written by hand), one that pins no release, a
|
|
12082
|
-
* configuration it could not read, a check that found out nothing — is `update`: restarting would start the same old
|
|
12083
|
-
* code, and a reply that said to restart would stop every call with advice that cannot work.
|
|
13427
|
+
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
13428
|
+
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
13429
|
+
* installer says only the words; it may not import the locator.
|
|
12084
13430
|
*/
|
|
12085
|
-
function
|
|
12086
|
-
const
|
|
12087
|
-
if (
|
|
12088
|
-
|
|
12089
|
-
|
|
12090
|
-
|
|
12091
|
-
|
|
12092
|
-
|
|
13431
|
+
async function remedyLocated(error, core) {
|
|
13432
|
+
const remedy = installRemedyOf(error);
|
|
13433
|
+
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
13434
|
+
const server = SERVERS.find((each) => serverFacts(each).binary === remedy.binary);
|
|
13435
|
+
if (server === void 0) return error;
|
|
13436
|
+
const maker = await requireHandoffs(core).registered();
|
|
13437
|
+
const handoff = isChannel(server) ? maker.of(server, remedy.words) : maker.core([
|
|
13438
|
+
"mcp",
|
|
13439
|
+
"install",
|
|
13440
|
+
"--server",
|
|
13441
|
+
server,
|
|
13442
|
+
...remedy.words.slice(2)
|
|
13443
|
+
]);
|
|
13444
|
+
return new CommsError(error.code, error.message, {
|
|
13445
|
+
hint: handoffSentence(handoff, remedy.say),
|
|
13446
|
+
...error.details === void 0 ? {} : { details: error.details },
|
|
13447
|
+
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
13448
|
+
});
|
|
12093
13449
|
}
|
|
12094
13450
|
/**
|
|
12095
|
-
*
|
|
12096
|
-
*
|
|
13451
|
+
* Removing a declared server's unused managed runtimes, as a change.
|
|
13452
|
+
*
|
|
13453
|
+
* A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
|
|
13454
|
+
* one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
|
|
13455
|
+
* then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
|
|
12097
13456
|
*
|
|
12098
|
-
*
|
|
12099
|
-
* of
|
|
12100
|
-
* restarting started the release the plugin pins. It is told to update.
|
|
13457
|
+
* `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
|
|
13458
|
+
* release of the command that was run; checked to be that channel's, as for an install.
|
|
12101
13459
|
*/
|
|
12102
|
-
|
|
12103
|
-
if (!(
|
|
12104
|
-
const
|
|
12105
|
-
|
|
12106
|
-
|
|
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"
|
|
13460
|
+
function serverPruneChange(core, env, request, own) {
|
|
13461
|
+
if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
|
|
13462
|
+
const context = {
|
|
13463
|
+
env,
|
|
13464
|
+
core
|
|
12111
13465
|
};
|
|
12112
|
-
|
|
12113
|
-
|
|
12114
|
-
|
|
12115
|
-
const handoffs = requireHandoffs(core, platform);
|
|
12116
|
-
return {
|
|
12117
|
-
update: handoffs.core(["update"]),
|
|
12118
|
-
later: handoffs.core(["update", "--later"])
|
|
13466
|
+
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
13467
|
+
packageName: serverFacts(request.channel).packageName,
|
|
13468
|
+
version: "0.20.0"
|
|
12119
13469
|
};
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
12125
|
-
|
|
13470
|
+
const product = {
|
|
13471
|
+
packageName,
|
|
13472
|
+
version
|
|
13473
|
+
};
|
|
13474
|
+
const label = serverLabel(request.channel);
|
|
13475
|
+
const base = {
|
|
13476
|
+
includePrinted: request.includePrinted === true,
|
|
13477
|
+
...request.processes ? { processes: request.processes } : {}
|
|
13478
|
+
};
|
|
13479
|
+
let approved = [];
|
|
12126
13480
|
return {
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
13481
|
+
plan: async (config) => {
|
|
13482
|
+
if (request.dryRun) return {
|
|
13483
|
+
before: config,
|
|
13484
|
+
after: config,
|
|
13485
|
+
summary: `Show which ${label} runtimes are unused`
|
|
13486
|
+
};
|
|
13487
|
+
const preview = await pruneManagedRuntimes(context, product, {
|
|
13488
|
+
...base,
|
|
13489
|
+
dryRun: true
|
|
13490
|
+
});
|
|
13491
|
+
approved = preview.removed.map((item) => item.path);
|
|
13492
|
+
return {
|
|
13493
|
+
before: config,
|
|
13494
|
+
after: config,
|
|
13495
|
+
effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
|
|
13496
|
+
summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
|
|
13497
|
+
};
|
|
12130
13498
|
},
|
|
12131
|
-
|
|
12132
|
-
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
}
|
|
13499
|
+
apply: () => pruneManagedRuntimes(context, product, {
|
|
13500
|
+
...base,
|
|
13501
|
+
dryRun: request.dryRun === true,
|
|
13502
|
+
...request.dryRun ? {} : { only: approved }
|
|
13503
|
+
})
|
|
12136
13504
|
};
|
|
12137
13505
|
}
|
|
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
13506
|
/**
|
|
12164
13507
|
* Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
|
|
12165
13508
|
*
|
|
@@ -12174,6 +13517,11 @@ function updateStopMessage(pending, where, commands) {
|
|
|
12174
13517
|
* The check reads and asks nobody. The update is a change whose effects are every step, one sentence each, so the
|
|
12175
13518
|
* approval is bound to exactly that list — the versions in it included: a release published between the question and
|
|
12176
13519
|
* the yes is a different change, and the claim is refused. Nothing behind, nothing is prepared.
|
|
13520
|
+
*
|
|
13521
|
+
* A registration that starts the stable launcher (design 2026-10-16, U6) is not registered again: the update installs
|
|
13522
|
+
* the latest runtime and switches the launcher's pointer, and no client's configuration is touched. A managed
|
|
13523
|
+
* registration from before the launcher names a versioned runtime; the update registers it again on the launcher —
|
|
13524
|
+
* the one update that edits it — even when it is already at the latest release.
|
|
12177
13525
|
*/
|
|
12178
13526
|
/**
|
|
12179
13527
|
* The packages this suite publishes, whose latest releases an update reads: every declared server owner's, from the
|
|
@@ -12185,149 +13533,253 @@ const SERVER_PACKAGES = Object.freeze(SERVERS.map((server) => serverFacts(server
|
|
|
12185
13533
|
Object.freeze([.../* @__PURE__ */ new Set([...OWNER_PACKAGES, ...SERVER_PACKAGES])].sort());
|
|
12186
13534
|
serverFacts("core").packageName;
|
|
12187
13535
|
/**
|
|
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.
|
|
13536
|
+
* The update notice, where every server and every command meets it (design 2026-10-16, U1). It replaces the stop of
|
|
13537
|
+
* the daily update check (design 2026-09-28): nothing is refused for being behind any more. A server's first tool
|
|
13538
|
+
* result of its process carries one line beside the result; a command prints the same line once on stderr, at most
|
|
13539
|
+
* once a day for the machine, and runs.
|
|
12248
13540
|
*
|
|
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.
|
|
13541
|
+
* Reader only, like `update-state.ts`: what asks the registry is handed in — `refresh` to a server's notice, `check` to
|
|
13542
|
+
* a command's — by every package but WhatsApp, which has no network code and hands in nothing.
|
|
12258
13543
|
*/
|
|
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
|
-
}
|
|
13544
|
+
/** The product, as a notice names it: never a command's name, which a sentence would read as one to run (CUE-403). */
|
|
13545
|
+
const PRODUCT = "agent-communications";
|
|
12267
13546
|
/**
|
|
12268
|
-
* A server's
|
|
12269
|
-
*
|
|
12270
|
-
*
|
|
13547
|
+
* A server's notice. After a tool answers: if a newer release is out, the machine's level says so (`decideNotice`),
|
|
13548
|
+
* and the tool is not exempt, the result carries one line, and no later result of this process carries it again.
|
|
13549
|
+
* Nothing is ever refused, and nothing waits for the check: the file is read as it is.
|
|
12271
13550
|
*
|
|
12272
|
-
*
|
|
12273
|
-
*
|
|
12274
|
-
* from the next new request (§2, as agreed).
|
|
13551
|
+
* At most once per process, on the first result there is something to say on: a server started on a day the check is
|
|
13552
|
+
* due learns of the release a moment after its first call, and says so on the next.
|
|
12275
13553
|
*/
|
|
12276
|
-
function
|
|
13554
|
+
function updateToolNotice(options) {
|
|
12277
13555
|
const exempt = new Set(options.exempt);
|
|
12278
13556
|
let running = null;
|
|
12279
|
-
|
|
12280
|
-
|
|
13557
|
+
let given = false;
|
|
13558
|
+
let turn = Promise.resolve();
|
|
13559
|
+
return (tool) => {
|
|
13560
|
+
if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
|
|
12281
13561
|
if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
|
|
12282
13562
|
running = null;
|
|
12283
13563
|
});
|
|
12284
|
-
if (exempt.has(tool)) return null;
|
|
12285
|
-
const
|
|
12286
|
-
|
|
12287
|
-
|
|
13564
|
+
if (given || exempt.has(tool)) return Promise.resolve(null);
|
|
13565
|
+
const decided = turn.then(async () => {
|
|
13566
|
+
if (given) return null;
|
|
13567
|
+
const decision = await decideNotice(options, {
|
|
13568
|
+
channel: options.channel,
|
|
13569
|
+
surface: "server"
|
|
13570
|
+
});
|
|
13571
|
+
if (decision === null) return null;
|
|
13572
|
+
given = true;
|
|
13573
|
+
return serverDecisionLine(decision, options.env);
|
|
12288
13574
|
});
|
|
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));
|
|
13575
|
+
turn = decided.catch(() => null);
|
|
13576
|
+
return decided.catch(() => null);
|
|
12297
13577
|
};
|
|
12298
13578
|
}
|
|
13579
|
+
/** How long a command's notice is good for, by level: a day, or 14 days on `every-14-days`. */
|
|
13580
|
+
function spacingOf(updates, surface) {
|
|
13581
|
+
if (updates.level === "every-14-days") return SPACED_INTERVAL_MS;
|
|
13582
|
+
return surface === "command" ? COMMAND_NOTICE_INTERVAL_MS : null;
|
|
13583
|
+
}
|
|
13584
|
+
const majorOf = (version) => Number(version.split(".")[0]);
|
|
12299
13585
|
/**
|
|
12300
|
-
*
|
|
12301
|
-
*
|
|
12302
|
-
*
|
|
13586
|
+
* What the record means for this process's notice now — or null, with nothing written — decided and recorded under the
|
|
13587
|
+
* file's lock, so two processes deciding together say it once where the level says once (design 2026-10-16, U2, U3,
|
|
13588
|
+
* U7):
|
|
13589
|
+
*
|
|
13590
|
+
* - switched off for the process — `CI`, `AGENT_COMMS_UPDATE_CHECK=off`, `AGENT_COMMS_UPDATES=never` — nothing;
|
|
13591
|
+
* - a security fix (the latest release's manifest says so) is said on `every-14-days`, on `never`, and on `auto` while
|
|
13592
|
+
* its background job is not set up, whatever the pause or the 14-day spacing: the job installs one within a day;
|
|
13593
|
+
* - otherwise nothing on `never`, nothing during a pause, and on `auto` with its job set up only what needs the person:
|
|
13594
|
+
* a restart, a major release, a release needing a decision;
|
|
13595
|
+
* - `every-14-days` says it at most once in 14 days for the machine, and a command at most once a day.
|
|
13596
|
+
*
|
|
13597
|
+
* Beside it, by the same rules of level and pause: what the last background run left (U5 step 7), said by the first
|
|
13598
|
+
* process it applies to and then cleared from the file — "installed" applies only to a process behind what was
|
|
13599
|
+
* installed — and the registrations still naming a versioned runtime (U6), spaced as the notice is.
|
|
12303
13600
|
*/
|
|
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
|
-
|
|
13601
|
+
async function decideNotice(options, where) {
|
|
13602
|
+
if (updateCheckSwitchedOff(options.env) !== null) return null;
|
|
13603
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
13604
|
+
const updates = await effectiveUpdates(options.core, options.env, now());
|
|
13605
|
+
const startedBy = options.env[UPDATE_STARTED_BY_ENV]?.trim();
|
|
13606
|
+
const offerJob = updates.level === "auto" && !updates.background && await jobCanBeSetUp(options.core, updates);
|
|
13607
|
+
const spacing = spacingOf(updates, where.surface);
|
|
13608
|
+
const reach = updates.background ? await jobReach(options.core, options.env, where.channel) : null;
|
|
13609
|
+
/** What the level and the spacing let this process say now, or null: decided once as read, then under the lock. */
|
|
13610
|
+
const due = (record, at) => {
|
|
13611
|
+
let notice = noticeOf(record, options.running, where, updates, at, reach);
|
|
13612
|
+
const last = where.surface === "command" && updates.level !== "every-14-days" ? record.commandNoticeAt : record.noticeAt;
|
|
13613
|
+
const spaced = spacing !== null && within(last, at, spacing);
|
|
13614
|
+
if (notice !== null) {
|
|
13615
|
+
if (!notice.security) {
|
|
13616
|
+
if (spaced) notice = null;
|
|
13617
|
+
} else if (where.surface === "command" && within(record.commandNoticeAt, at, 864e5)) notice = null;
|
|
13618
|
+
}
|
|
13619
|
+
const quiet = updates.level === "never" || pauseEnd(record, at) !== null;
|
|
13620
|
+
const background = quiet ? null : backgroundFor(record.backgroundNotice, options.running);
|
|
13621
|
+
const moves = quiet || spaced ? 0 : record.moves ?? 0;
|
|
13622
|
+
const jobSilent = quiet || spaced ? null : updates.jobSilent?.since ?? null;
|
|
13623
|
+
if (notice === null && background === null && moves === 0 && jobSilent === null) return null;
|
|
13624
|
+
return {
|
|
13625
|
+
notice,
|
|
13626
|
+
background,
|
|
13627
|
+
moves,
|
|
13628
|
+
jobSilent,
|
|
13629
|
+
offerJob: offerJob && (notice !== null || jobSilent !== null)
|
|
13630
|
+
};
|
|
13631
|
+
};
|
|
13632
|
+
if (due(await readUpdateCheck(options.core.paths.stateDir), now()) === null) return null;
|
|
13633
|
+
return (await changeUpdateCheck(options.core.paths.stateDir, (record) => {
|
|
13634
|
+
const at = now();
|
|
13635
|
+
const decision = due(record, at);
|
|
13636
|
+
if (decision === null) return null;
|
|
13637
|
+
const stamp = at.toISOString();
|
|
13638
|
+
const spaces = decision.notice !== null || decision.moves > 0 || (decision.jobSilent ?? null) !== null;
|
|
13639
|
+
const written = {
|
|
13640
|
+
...record,
|
|
13641
|
+
...spaces ? { noticeAt: stamp } : {},
|
|
13642
|
+
...spaces && where.surface === "command" ? { commandNoticeAt: stamp } : {},
|
|
13643
|
+
...decision.background !== null ? { backgroundNotice: null } : {}
|
|
13644
|
+
};
|
|
13645
|
+
const { notice } = decision;
|
|
13646
|
+
const kind = notice?.kind === "restart" && where.surface === "server" && startedBy ? "update" : notice?.kind;
|
|
13647
|
+
return {
|
|
13648
|
+
record: written,
|
|
13649
|
+
result: {
|
|
13650
|
+
...decision,
|
|
13651
|
+
notice: notice === null || kind === void 0 ? null : {
|
|
13652
|
+
...notice,
|
|
13653
|
+
kind
|
|
13654
|
+
}
|
|
12326
13655
|
}
|
|
12327
|
-
}
|
|
13656
|
+
};
|
|
13657
|
+
})).result ?? null;
|
|
13658
|
+
}
|
|
13659
|
+
/**
|
|
13660
|
+
* What a background run left, when it is this process's to say: an install only to a process running a release before
|
|
13661
|
+
* it — one already running it has nothing to restart into — and anything else, a refusal, a skip, a removed job, to
|
|
13662
|
+
* any process.
|
|
13663
|
+
*/
|
|
13664
|
+
function backgroundFor(left, running) {
|
|
13665
|
+
if (left === null || left === void 0) return null;
|
|
13666
|
+
if (left.outcome !== "installed") return left;
|
|
13667
|
+
return left.version !== null && isVersion(running) && isBehind(running, left.version) ? left : null;
|
|
13668
|
+
}
|
|
13669
|
+
/**
|
|
13670
|
+
* Whether the background job could be set up here, so offering it is not offering a refusal (design 2026-10-16 U4):
|
|
13671
|
+
* a platform it is written for, no organisation turning background updates off, and the stable core launcher it runs.
|
|
13672
|
+
*/
|
|
13673
|
+
async function jobCanBeSetUp(core, updates) {
|
|
13674
|
+
if (updates.backgroundOff !== void 0 || !updateJobSupported(process.platform)) return false;
|
|
13675
|
+
try {
|
|
13676
|
+
return (await stat(launcherPaths(core.paths.dataDir, "core").launcher)).isFile();
|
|
13677
|
+
} catch {
|
|
13678
|
+
return false;
|
|
13679
|
+
}
|
|
13680
|
+
}
|
|
13681
|
+
/** Whether the job reaches a process of `name`'s launcher (`JobReach`); null where it does not, or it cannot be read. */
|
|
13682
|
+
async function jobReach(core, env, name) {
|
|
13683
|
+
const launched = env[LAUNCHER_ENV]?.trim();
|
|
13684
|
+
if (name === void 0 || launched !== name) return null;
|
|
13685
|
+
try {
|
|
13686
|
+
const pointer = await readPointer(core.paths.dataDir, name);
|
|
13687
|
+
if (pointer === void 0) return null;
|
|
13688
|
+
const folder = basename(pointer.current);
|
|
13689
|
+
const version = folder.endsWith(`-${name}`) ? folder.slice(0, -(name.length + 1)) : "";
|
|
13690
|
+
return { pointed: isPlainVersion(version) ? version : null };
|
|
13691
|
+
} catch {
|
|
13692
|
+
return null;
|
|
13693
|
+
}
|
|
13694
|
+
}
|
|
13695
|
+
/**
|
|
13696
|
+
* Whether the job covers a security fix for this process: it reaches it (`jobReach`), it installs the fix on its own —
|
|
13697
|
+
* not a new major, nor one needing a decision, which it leaves for the person — and its launcher does not start the
|
|
13698
|
+
* fix yet, so the job installs it within a day. Once it does, the process still runs the release before: it is told.
|
|
13699
|
+
*/
|
|
13700
|
+
function coveredByJob(reach, fix, record, running) {
|
|
13701
|
+
if (reach === null) return false;
|
|
13702
|
+
const from = reach.pointed ?? running;
|
|
13703
|
+
if (!isVersion(from) || majorOf(fix) > majorOf(from) || record.release?.needsDecision === true) return false;
|
|
13704
|
+
return !(reach.pointed !== null && !isBehind(reach.pointed, fix));
|
|
13705
|
+
}
|
|
13706
|
+
/** The notice the record makes for this process, before any spacing: see `decideNotice`. */
|
|
13707
|
+
function noticeOf(record, running, where, updates, at, reach) {
|
|
13708
|
+
const pending = updateVerdict(record, running, where);
|
|
13709
|
+
if (pending === null) return null;
|
|
13710
|
+
const background = updates.background && reach !== null;
|
|
13711
|
+
const fix = securityRelease(record, running);
|
|
13712
|
+
const security = fix !== null && !(background && coveredByJob(reach, fix, record, running));
|
|
13713
|
+
const pausedUntil = pauseEnd(record, at);
|
|
13714
|
+
const facts = record.release?.version === pending.latest ? record.release : null;
|
|
13715
|
+
const notice = {
|
|
13716
|
+
...pending,
|
|
13717
|
+
security,
|
|
13718
|
+
major: background && isVersion(running) && majorOf(pending.latest) > majorOf(running),
|
|
13719
|
+
needsDecision: background && facts?.needsDecision === true,
|
|
13720
|
+
pausedUntil
|
|
12328
13721
|
};
|
|
13722
|
+
if (security) return notice;
|
|
13723
|
+
if (updates.level === "never" || pausedUntil !== null) return null;
|
|
13724
|
+
if (background && pending.kind !== "restart" && !notice.major && !notice.needsDecision) return null;
|
|
13725
|
+
return notice;
|
|
13726
|
+
}
|
|
13727
|
+
/** Who started a server from a release it pins itself, in words: the plugin's, the extension's. */
|
|
13728
|
+
function startedByWords(startedBy) {
|
|
13729
|
+
if (startedBy === "claude-code-plugin") return "the Claude Code plugin";
|
|
13730
|
+
if (startedBy === "gemini-extension") return "the Gemini extension";
|
|
13731
|
+
return "a plugin or an extension";
|
|
13732
|
+
}
|
|
13733
|
+
/**
|
|
13734
|
+
* The server's line. "Installed" only on the check's positive finding that restarting starts `latest`; a server a
|
|
13735
|
+
* plugin or an extension started is updated where it was installed, which `comms_update` never moves.
|
|
13736
|
+
*/
|
|
13737
|
+
function serverNoticeLine(notice, env = {}) {
|
|
13738
|
+
const paused = pausedWords(notice);
|
|
13739
|
+
if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; restart the client (in Claude Code, /mcp → Reconnect) to use it.${paused}`;
|
|
13740
|
+
const startedBy = env[UPDATE_STARTED_BY_ENV]?.trim();
|
|
13741
|
+
if (startedBy) return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; this server came with ${startedByWords(startedBy)}, and is updated there ${notice.security ? "soon" : "when it suits you"}.${paused}`;
|
|
13742
|
+
return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)}; say "update" ${notice.security ? "soon" : "when it suits you"}.${paused}`;
|
|
13743
|
+
}
|
|
13744
|
+
/**
|
|
13745
|
+
* The line for a background job that has not run in 48 hours (U4, U7): as though there were none, every notice and the
|
|
13746
|
+
* security line are said again, and this says why, and where to look.
|
|
13747
|
+
*/
|
|
13748
|
+
function jobSilentLine(since) {
|
|
13749
|
+
const at = new Date(since);
|
|
13750
|
+
const day = Number.isFinite(at.getTime()) ? localStamp(at).slice(0, 10) : "it was set up";
|
|
13751
|
+
return `${PRODUCT}: background updates have not run since ${day}; check Login Items (macOS) or run doctor.`;
|
|
13752
|
+
}
|
|
13753
|
+
/** How many registrations, in words: "one registration", "2 registrations". */
|
|
13754
|
+
const registrations = (count) => count === 1 ? "one registration" : `${count} registrations`;
|
|
13755
|
+
/**
|
|
13756
|
+
* A server's one line, from everything `decideNotice` decided: the background run's word first, then the release this
|
|
13757
|
+
* server is behind — unless the run's word is about that same release — then the move to the stable launcher, and the
|
|
13758
|
+
* offer of the background job.
|
|
13759
|
+
*/
|
|
13760
|
+
function serverDecisionLine(decision, env = {}) {
|
|
13761
|
+
const { notice, background, moves } = decision;
|
|
13762
|
+
const parts = [];
|
|
13763
|
+
if (background !== null) parts.push(backgroundNoticeLine(background));
|
|
13764
|
+
if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(serverNoticeLine(notice, env));
|
|
13765
|
+
if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
|
|
13766
|
+
if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; one approved update (say "update") moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`);
|
|
13767
|
+
if (decision.offerJob) parts.push("To have releases install in the background instead, comms_update with backgroundJob \"on\" sets up a daily job: its preview shows the job, when it runs and how to remove it, and sets nothing up without your yes.");
|
|
13768
|
+
return parts.join(" ");
|
|
12329
13769
|
}
|
|
12330
|
-
/**
|
|
13770
|
+
/** What is particular about the release, after "is out": a security fix, a major release, a decision it needs. */
|
|
13771
|
+
function whatItIs(notice) {
|
|
13772
|
+
if (notice.security) return ", and fixes a security problem";
|
|
13773
|
+
if (notice.major) return ", a major release, which waits for you";
|
|
13774
|
+
if (notice.needsDecision) return ", and needs a decision from you before it installs";
|
|
13775
|
+
return "";
|
|
13776
|
+
}
|
|
13777
|
+
/** Every notice during a pause says when it ends (U3): only a security fix is said then. */
|
|
13778
|
+
function pausedWords(notice) {
|
|
13779
|
+
if (!notice.pausedUntil) return "";
|
|
13780
|
+
return ` Updates are paused until ${localStamp(notice.pausedUntil)}; a security fix is not held.`;
|
|
13781
|
+
}
|
|
13782
|
+
/** The commands that never print the notice, by their first word; `mcp` alone runs a server, which has its own. */
|
|
12331
13783
|
const EXEMPT_COMMANDS = Object.freeze([
|
|
12332
13784
|
"update",
|
|
12333
13785
|
"doctor",
|
|
@@ -12337,201 +13789,105 @@ const EXEMPT_COMMANDS = Object.freeze([
|
|
|
12337
13789
|
"help"
|
|
12338
13790
|
]);
|
|
12339
13791
|
/**
|
|
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.
|
|
13792
|
+
* Whether a command is left alone by the update notice: `update` in every form, `doctor`, `paths`, `approve`,
|
|
13793
|
+
* `approvals`, help, and `mcp` alone — the server, which gives its own notice. `mcp install` and `mcp prune` are not.
|
|
13794
|
+
* `also` adds a channel's own — WhatsApp's `status`, its doctor. `--help` and `--version` never reach a command.
|
|
12344
13795
|
*/
|
|
12345
|
-
function
|
|
13796
|
+
function exemptFromUpdateNotice(path, also = []) {
|
|
12346
13797
|
const [first] = path;
|
|
12347
13798
|
if (first === void 0) return true;
|
|
12348
13799
|
if (EXEMPT_COMMANDS.includes(first) || also.includes(first)) return true;
|
|
12349
13800
|
return first === "mcp" && path.length === 1;
|
|
12350
13801
|
}
|
|
12351
|
-
/** A Commander command's path below the program, `['mcp', 'install']`: what `
|
|
13802
|
+
/** A Commander command's path below the program, `['mcp', 'install']`: what `exemptFromUpdateNotice` reads. */
|
|
12352
13803
|
function commandPathOf(command) {
|
|
12353
13804
|
const path = [];
|
|
12354
13805
|
for (let at = command; at?.parent; at = at.parent) path.unshift(at.name());
|
|
12355
13806
|
return path;
|
|
12356
13807
|
}
|
|
13808
|
+
/** How often a command says it, for the whole machine: once a day. */
|
|
13809
|
+
const COMMAND_NOTICE_INTERVAL_MS = 864e5;
|
|
12357
13810
|
/**
|
|
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:
|
|
13811
|
+
* The update notice at a terminal, before a command runs. It never stops the command, and never throws.
|
|
12381
13812
|
*
|
|
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.
|
|
13813
|
+
* It reads the file first. If the check is due for the machine's level it asks the registry — through `check` — waiting
|
|
13814
|
+
* at most about three seconds and going on without it after that. Then, when the level says so (`decideNotice`), it
|
|
13815
|
+
* prints one line on stderr — never on stdout, where a result or `--json` is — and the command runs.
|
|
12393
13816
|
*/
|
|
12394
|
-
async function
|
|
12395
|
-
const { core, env, streams
|
|
13817
|
+
async function updateNoticeAtTerminal(options) {
|
|
13818
|
+
const { core, env, streams } = options;
|
|
12396
13819
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
timer
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
|
|
13820
|
+
try {
|
|
13821
|
+
if (updateCheckSwitchedOff(env) !== null) return;
|
|
13822
|
+
const enabled = await updateCheckEnabled(core, env);
|
|
13823
|
+
if (enabled.on && options.check && updateCheckDue(await readUpdateCheck(core.paths.stateDir), now(), enabled.intervalMs)) {
|
|
13824
|
+
let timer;
|
|
13825
|
+
const gaveUp = new Promise((resolve) => {
|
|
13826
|
+
timer = setTimeout(resolve, options.waitMs ?? 3e3);
|
|
13827
|
+
});
|
|
13828
|
+
try {
|
|
13829
|
+
await Promise.race([options.check().catch(() => void 0), gaveUp]);
|
|
13830
|
+
} finally {
|
|
13831
|
+
clearTimeout(timer);
|
|
13832
|
+
}
|
|
12407
13833
|
}
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
13834
|
+
const line = await commandNotice(options, now);
|
|
13835
|
+
if (line !== null) streams.stderr.write(`${line}\n`);
|
|
13836
|
+
} catch {}
|
|
13837
|
+
}
|
|
13838
|
+
/** The command's line, decided and recorded under the file's lock: see `decideNotice`. */
|
|
13839
|
+
async function commandNotice(options, now) {
|
|
13840
|
+
const decision = await decideNotice({
|
|
13841
|
+
...options,
|
|
13842
|
+
now
|
|
13843
|
+
}, {
|
|
12411
13844
|
channel: options.channel,
|
|
12412
13845
|
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
13846
|
});
|
|
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}.`) });
|
|
13847
|
+
if (decision === null) return null;
|
|
13848
|
+
return commandDecisionLine(options.core, decision, options.channel, options.output.platform);
|
|
12455
13849
|
}
|
|
12456
13850
|
/**
|
|
12457
|
-
* The CLI a
|
|
12458
|
-
*
|
|
13851
|
+
* The CLI a notice names, in product words — "Gmail CLI", "core CLI" — never its binary, which is no command most
|
|
13852
|
+
* people can run, and which a sentence would read as one (CUE-403).
|
|
12459
13853
|
*/
|
|
12460
13854
|
function cliOf(channel) {
|
|
12461
13855
|
if (channel === "core") return "core CLI";
|
|
12462
13856
|
return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
|
|
12463
13857
|
}
|
|
12464
13858
|
/**
|
|
12465
|
-
* The
|
|
12466
|
-
*
|
|
13859
|
+
* The command's line: the update, as core's own command located from what is printing — or why there is none here —
|
|
13860
|
+
* or, when this command's package is installed globally at `latest` and an older copy is running, to run that one.
|
|
12467
13861
|
*/
|
|
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)));
|
|
13862
|
+
function commandNoticeLine(core, notice, channel, platform) {
|
|
13863
|
+
const paused = pausedWords(notice);
|
|
13864
|
+
if (notice.kind === "restart") return `${PRODUCT} ${notice.latest} is installed; this ${cliOf(channel)} is ${notice.running}, started from somewhere else — npx's cache, a project's own install, a checkout: run it from the installed one.${paused}`;
|
|
13865
|
+
const update = requireHandoffs(core, platform).core(["update"]);
|
|
13866
|
+
const when = notice.security ? "soon" : "when it suits you";
|
|
13867
|
+
return `${PRODUCT} ${notice.latest} is out${whatItIs(notice)} (this is ${notice.running}); ${handoffSentence(update, (command) => `run ${command} ${when}.`)}${paused}`;
|
|
12489
13868
|
}
|
|
12490
13869
|
/**
|
|
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.
|
|
13870
|
+
* A command's one line, from everything `decideNotice` decided, as `serverDecisionLine` puts it together — every
|
|
13871
|
+
* command named located for this installation, or why there is none here.
|
|
12495
13872
|
*/
|
|
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
|
-
}
|
|
13873
|
+
function commandDecisionLine(core, decision, channel, platform) {
|
|
13874
|
+
const { notice, background, moves } = decision;
|
|
13875
|
+
const handoffs = requireHandoffs(core, platform);
|
|
13876
|
+
const parts = [];
|
|
13877
|
+
if (background !== null) parts.push(backgroundNoticeLine(background));
|
|
13878
|
+
if (notice !== null && !(background !== null && background.version === notice.latest)) parts.push(commandNoticeLine(core, notice, channel, platform));
|
|
13879
|
+
if (decision.jobSilent) parts.push(jobSilentLine(decision.jobSilent));
|
|
13880
|
+
if (moves > 0) parts.push(notice !== null ? `The update also moves ${registrations(moves)} onto the stable launcher, the last update that edits a client's configuration.` : `${PRODUCT}: ${moves === 1 ? "one registration still starts" : `${moves} registrations still start`} a versioned runtime; ${handoffSentence(handoffs.core(["update"]), (update) => `one approved update, ${update}, moves ${moves === 1 ? "it" : "them"} onto the stable launcher, after which updates never edit a client's configuration again.`)}`);
|
|
13881
|
+
if (decision.offerJob) parts.push(handoffSentence(handoffs.core([
|
|
13882
|
+
"update",
|
|
13883
|
+
"--background-job",
|
|
13884
|
+
"on"
|
|
13885
|
+
]), (command) => `To have releases install in the background instead, run ${command}: it shows the job, when it runs and how to remove it, and sets nothing up without your yes.`));
|
|
13886
|
+
return parts.join(" ");
|
|
12531
13887
|
}
|
|
12532
13888
|
channelServer("core").packageName;
|
|
12533
13889
|
//#endregion
|
|
12534
|
-
//#region ../core/dist/tool-arguments-
|
|
13890
|
+
//#region ../core/dist/tool-arguments-4c3WR2Gy.mjs
|
|
12535
13891
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
12536
13892
|
const TARGET = "draft-2020-12";
|
|
12537
13893
|
/**
|
|
@@ -12543,13 +13899,14 @@ const TARGET = "draft-2020-12";
|
|
|
12543
13899
|
* `refuse` is handed a `USAGE` `CommsError` and returns the tool result, so the refusal is in the same envelope as
|
|
12544
13900
|
* every other refusal that server makes.
|
|
12545
13901
|
*
|
|
12546
|
-
* `
|
|
12547
|
-
*
|
|
12548
|
-
*
|
|
12549
|
-
*
|
|
12550
|
-
*
|
|
13902
|
+
* `notice` is asked once the handler has answered: the update notice (design 2026-10-16, U1), which every server builds
|
|
13903
|
+
* with `updateToolNotice`. Here because this is the one place every tool of every server already passes through, so no
|
|
13904
|
+
* tool can be registered without it; given to the wrapper rather than built in, because what it asks the registry
|
|
13905
|
+
* with is each server's own — and WhatsApp's is nothing at all. It never stops a call: a line it gives is carried
|
|
13906
|
+
* beside the result (`withUpdateNotice`), and a tool's output schema is published with room for it
|
|
13907
|
+
* (`withUpdateField`), so a client that checks a result against its schema takes the one that carries it.
|
|
12551
13908
|
*/
|
|
12552
|
-
function strictToolArguments(server, refuse,
|
|
13909
|
+
function strictToolArguments(server, refuse, notice, retired = {}) {
|
|
12553
13910
|
const target = server;
|
|
12554
13911
|
if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
|
|
12555
13912
|
if (target[APPLIED]) return;
|
|
@@ -12560,15 +13917,21 @@ function strictToolArguments(server, refuse, gate, retired = {}) {
|
|
|
12560
13917
|
const declared = config.inputSchema !== void 0;
|
|
12561
13918
|
const schema = strictSchemaOf(name, config.inputSchema);
|
|
12562
13919
|
const published = schema["~standard"].jsonSchema.input({ target: TARGET });
|
|
12563
|
-
|
|
13920
|
+
const output = notice && config.outputSchema !== void 0 ? withUpdateField(config.outputSchema) : null;
|
|
13921
|
+
const room = config.outputSchema === void 0 || output !== null;
|
|
13922
|
+
const registered = output === null ? config : {
|
|
12564
13923
|
...config,
|
|
13924
|
+
outputSchema: output
|
|
13925
|
+
};
|
|
13926
|
+
return register(name, {
|
|
13927
|
+
...registered,
|
|
12565
13928
|
inputSchema: passThrough(schema)
|
|
12566
13929
|
}, async (input, context) => {
|
|
12567
13930
|
const parsed = await schema.safeParseAsync(input ?? {});
|
|
12568
13931
|
if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
|
|
12569
|
-
const
|
|
12570
|
-
|
|
12571
|
-
return
|
|
13932
|
+
const result = await (declared ? handler(parsed.data, context) : handler(context));
|
|
13933
|
+
const line = notice ? await notice(name).catch(() => null) : null;
|
|
13934
|
+
return line === null ? result : withUpdateNotice(result, line, room);
|
|
12572
13935
|
});
|
|
12573
13936
|
};
|
|
12574
13937
|
Object.defineProperty(target, APPLIED, { value: true });
|
|
@@ -12608,6 +13971,83 @@ function passThrough(schema) {
|
|
|
12608
13971
|
jsonSchema: schema["~standard"].jsonSchema
|
|
12609
13972
|
} };
|
|
12610
13973
|
}
|
|
13974
|
+
/** The field a result carries the update notice in, as every output schema publishes it. */
|
|
13975
|
+
const UPDATE_FIELD = "update";
|
|
13976
|
+
const UPDATE_PROPERTY = {
|
|
13977
|
+
type: "string",
|
|
13978
|
+
description: "Once per server process at most: a newer agent-communications release is out, or installed and waiting for a restart. Pass it on to the person; nothing was refused."
|
|
13979
|
+
};
|
|
13980
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13981
|
+
/**
|
|
13982
|
+
* A result with the notice beside it: a text block of its own after the result's — a client that shows the model only
|
|
13983
|
+
* text reads it there — and, where the structured content is an object with room for it, its `update` field, which is
|
|
13984
|
+
* all Claude Code and Codex show. Nothing of the result is changed or taken away.
|
|
13985
|
+
*/
|
|
13986
|
+
function withUpdateNotice(result, line, room = true) {
|
|
13987
|
+
if (!isPlainObject(result)) return result;
|
|
13988
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
13989
|
+
const next = {
|
|
13990
|
+
...result,
|
|
13991
|
+
content: [...content, {
|
|
13992
|
+
type: "text",
|
|
13993
|
+
text: line
|
|
13994
|
+
}]
|
|
13995
|
+
};
|
|
13996
|
+
const structured = result.structuredContent;
|
|
13997
|
+
if (room && isPlainObject(structured) && !Object.hasOwn(structured, UPDATE_FIELD)) next.structuredContent = {
|
|
13998
|
+
...structured,
|
|
13999
|
+
[UPDATE_FIELD]: line
|
|
14000
|
+
};
|
|
14001
|
+
return next;
|
|
14002
|
+
}
|
|
14003
|
+
/** A JSON Schema with the `update` field among its properties — each side of a union — or null with no room for it. */
|
|
14004
|
+
function publishedWithUpdate(json) {
|
|
14005
|
+
const alternatives = json.anyOf ?? json.oneOf;
|
|
14006
|
+
if (Array.isArray(alternatives) && json.properties === void 0) {
|
|
14007
|
+
const each = alternatives.map(publishedWithUpdate);
|
|
14008
|
+
if (each.some((alternative) => alternative === null)) return null;
|
|
14009
|
+
return {
|
|
14010
|
+
...json,
|
|
14011
|
+
...json.anyOf ? { anyOf: each } : { oneOf: each }
|
|
14012
|
+
};
|
|
14013
|
+
}
|
|
14014
|
+
if (!(Array.isArray(json.type) ? json.type : [json.type]).includes("object") || json.properties === void 0) return null;
|
|
14015
|
+
if (Object.hasOwn(json.properties, UPDATE_FIELD)) return null;
|
|
14016
|
+
return {
|
|
14017
|
+
...json,
|
|
14018
|
+
properties: {
|
|
14019
|
+
...json.properties,
|
|
14020
|
+
[UPDATE_FIELD]: UPDATE_PROPERTY
|
|
14021
|
+
}
|
|
14022
|
+
};
|
|
14023
|
+
}
|
|
14024
|
+
/**
|
|
14025
|
+
* A tool's output schema, published with room for the `update` field: the same schema, with that one property added
|
|
14026
|
+
* — a zod object publishes `additionalProperties: false`, and a client that checks a result against it would refuse
|
|
14027
|
+
* the one carrying the notice — and checked as it always was, without the field. Null when the schema has no room for
|
|
14028
|
+
* it: one that declares `update` itself, or is not an object; that tool's notice is a text block alone.
|
|
14029
|
+
*/
|
|
14030
|
+
function withUpdateField(outputSchema) {
|
|
14031
|
+
const standard = (isPlainObject(outputSchema) && !("~standard" in outputSchema) && Object.values(outputSchema).every(isZodSchema) ? object(outputSchema) : outputSchema)?.["~standard"];
|
|
14032
|
+
if (typeof standard?.validate !== "function" || standard.jsonSchema === void 0) return null;
|
|
14033
|
+
const { jsonSchema } = standard;
|
|
14034
|
+
const input = publishedWithUpdate(jsonSchema.input({ target: TARGET }));
|
|
14035
|
+
const output = publishedWithUpdate(jsonSchema.output({ target: TARGET }));
|
|
14036
|
+
if (input === null || output === null) return null;
|
|
14037
|
+
return { "~standard": {
|
|
14038
|
+
version: 1,
|
|
14039
|
+
vendor: "agentcomms",
|
|
14040
|
+
validate: (value) => {
|
|
14041
|
+
if (!isPlainObject(value) || !Object.hasOwn(value, UPDATE_FIELD)) return standard.validate(value);
|
|
14042
|
+
const { [UPDATE_FIELD]: _notice, ...rest } = value;
|
|
14043
|
+
return standard.validate(rest);
|
|
14044
|
+
},
|
|
14045
|
+
jsonSchema: {
|
|
14046
|
+
input: () => input,
|
|
14047
|
+
output: () => output
|
|
14048
|
+
}
|
|
14049
|
+
} };
|
|
14050
|
+
}
|
|
12611
14051
|
/** A USAGE refusal naming what was wrong and what the tool takes. */
|
|
12612
14052
|
function refusal(tool, published, input, issues, retired) {
|
|
12613
14053
|
const properties = published.properties ?? {};
|
|
@@ -16240,4 +17680,4 @@ async function syncAccount(context, request) {
|
|
|
16240
17680
|
});
|
|
16241
17681
|
}
|
|
16242
17682
|
//#endregion
|
|
16243
|
-
export {
|
|
17683
|
+
export { gatedChangeAtTerminal as $, requireAccount as A, PATH_OPTIONS as B, chatKindOf as C, CALLER as Ct, text as D, numeric as E, expandSource as F, approveChangeAtTerminal as G, absoluteSearchPath as H, strictToolArguments as I, cliHandoffs as J, canPrompt as K, CHANNEL_SERVERS as L, storeIdentity as M, checkStorePath as N, PLATFORM as O, defaultStorePath as P, exemptFromUpdateNotice as Q, CommsError as R, chatRefOf as S, writeResult as St, kindOf as T, VERSION$2 as Tt, agentMarker as U, UNTRUSTED_NOTICE as V, approveAndWaitSentence as W, commandPathOf as X, colorEnabled as Y, escapeForDisplay as Z, WhatsAppContext as _, truncateDisplay as _t, CHAT_KINDS as a, nameAvailable as at, requireSupportedNode as b, windowsSystemProgram as bt, searchMessages as c, paint as ct, composeDraft as d, renderPrune as dt, handoffSentence as et, draftMessage as f, resolvePaths as ft, visibilityUnitKey as g, toCommsError as gt, statusAuthor as h, serverPruneChange as ht, probeStore as i, lookupName as it, requireNamedConfig as j, newWhatsAppAccount as k, Presenter as l, pathOverridesFromCliOptions as lt, listKey as m, serverInstallChange as mt, withCheckedCopy as n, installExitStatus as nt, listChats as o, nameShapeProblem as ot, Visibility as p, runCommand as pt, childEnvironment as q, whatsappStatus as r, isCommsError as rt, readChat as s, openCore as st, syncAccount as t, handoffText as tt, innerText as u, renderInstall as ut, WhatsAppIndex as v, updateNoticeAtTerminal as vt, coreDataToIso as w, PACKAGE_NAME as wt, inspectSchema as x, withFileLock as xt, rebuildIndex as y, updateToolNotice as yt, EXIT_CODES as z };
|