@agentcomms/whatsapp 0.13.0 → 0.13.1
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/README.md +1 -1
- package/dist/cli.mjs +97 -82
- package/dist/index.d.mts +2893 -1654
- package/dist/index.mjs +2 -3
- package/dist/{server-DIlBV3Wu.mjs → server-DNyMO_pq.mjs} +5 -5
- package/dist/{stdio-entry-BCIfuv69.mjs → stdio-entry-vXXVDpnA.mjs} +1 -1
- package/dist/{sync-fzUQKMxy.mjs → sync-DrWJVoXp.mjs} +2189 -757
- package/package.json +6 -4
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { C as unknown, S as union, b as strictObject, c as boolean, h as number, i as _enum, 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, utimes } from "node:fs/promises";
|
|
3
|
-
import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } 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
|
-
import { styleText } from "node:util";
|
|
8
7
|
import { homedir, platform, userInfo } from "node:os";
|
|
9
|
-
import {
|
|
8
|
+
import { styleText } from "node:util";
|
|
9
|
+
import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
|
|
10
10
|
import { execFile, spawn } from "node:child_process";
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/version.ts
|
|
13
13
|
/** The package version, read from package.json at build time. */
|
|
14
|
-
const VERSION$1 = "0.13.
|
|
14
|
+
const VERSION$1 = "0.13.1";
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/caller.ts
|
|
17
|
+
/** This package's name, as its manifest has it. */
|
|
18
|
+
const PACKAGE_NAME = "@agentcomms/whatsapp";
|
|
19
|
+
/**
|
|
20
|
+
* This package as core locates its commands from (CONTRIBUTING.md, "Telling a person what to run"; design 2026-10-04,
|
|
21
|
+
* D1): a module of its own, whose nearest manifest is this package's. Run from a checkout's source it is this file, and
|
|
22
|
+
* a command runs `src/cli.ts`; built, it is a file of `dist`, and a command runs the manifest's bin. Either way the
|
|
23
|
+
* program is this process's Node, which has to be in this package's own `engines.node` range — narrower than core's —
|
|
24
|
+
* or there is no command, and the reason names the range.
|
|
25
|
+
*/
|
|
26
|
+
const CALLER = Object.freeze({
|
|
27
|
+
url: import.meta.url,
|
|
28
|
+
packageName: PACKAGE_NAME
|
|
29
|
+
});
|
|
15
30
|
//#endregion
|
|
16
|
-
//#region ../core/dist/update-check-
|
|
31
|
+
//#region ../core/dist/update-check-BPwwmUCi.mjs
|
|
17
32
|
/**
|
|
18
33
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
19
34
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -804,7 +819,7 @@ const CHANNEL_SNAPSHOT = [
|
|
|
804
819
|
guarantee: {
|
|
805
820
|
ceiling: "code",
|
|
806
821
|
floor: "code",
|
|
807
|
-
why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by
|
|
822
|
+
why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by this package's own code. A sending-access key can only send, and Resend enforces that."
|
|
808
823
|
}
|
|
809
824
|
},
|
|
810
825
|
narrowing: [{
|
|
@@ -924,25 +939,6 @@ function manifestOf(channel) {
|
|
|
924
939
|
function accountChannels() {
|
|
925
940
|
return MANIFESTS.filter((manifest) => manifest.accounts !== void 0);
|
|
926
941
|
}
|
|
927
|
-
/**
|
|
928
|
-
* Words as a list a person reads: `a`, `a or b`, `a, b or c` — with `oxford`, `a, b, or c` once there are three.
|
|
929
|
-
*/
|
|
930
|
-
function listed(words, conjunction, options = {}) {
|
|
931
|
-
if (words.length <= 1) return words.join("");
|
|
932
|
-
const last = words.at(-1);
|
|
933
|
-
const rest = words.slice(0, -1);
|
|
934
|
-
return `${rest.join(", ")}${options.oxford && rest.length > 1 ? "," : ""} ${conjunction} ${last}`;
|
|
935
|
-
}
|
|
936
|
-
/**
|
|
937
|
-
* Every channel's own approve command, quoted: "`agent-gmail approve` or `agent-slack approve`".
|
|
938
|
-
*
|
|
939
|
-
* `sending` keeps only the channels whose accounts can be in `send`: the commands that can have prepared a send. A
|
|
940
|
-
* channel that never sends — WhatsApp — has an approve command for the changes its `mcp install` and `mcp prune`
|
|
941
|
-
* make, and naming it where the approval in hand is a send would send a person to a command that never prepared one.
|
|
942
|
-
*/
|
|
943
|
-
function channelApproveCommands(options = {}) {
|
|
944
|
-
return listed(accountChannels().filter((manifest) => options.sending !== true || manifest.accounts?.modes.includes("send") === true).flatMap((manifest) => manifest.approve ? [`\`${manifest.approve}\``] : []), "or");
|
|
945
|
-
}
|
|
946
942
|
/** The noun for one account on `channel` — `mailbox`, `workspace` — or `account` for a channel that names none. */
|
|
947
943
|
function accountNoun(channel) {
|
|
948
944
|
return manifestOf(channel)?.accounts?.noun ?? "account";
|
|
@@ -960,106 +956,20 @@ function narrowingOwner(option) {
|
|
|
960
956
|
return MANIFESTS.find((manifest) => manifest.narrowing?.some((narrowing) => narrowing.option === option));
|
|
961
957
|
}
|
|
962
958
|
/**
|
|
963
|
-
*
|
|
964
|
-
*
|
|
959
|
+
* How a mailbox is connected, for a hint about there being none: the `inbox add` of the channel whose accounts are
|
|
960
|
+
* mailboxes, in the `inboxes` map — named in words, "Gmail's inbox add", never as a command. Core does not have that
|
|
961
|
+
* channel's CLI, and finds it only among this machine's registrations (CUE-403); a hint made where none were read must
|
|
962
|
+
* not invent the command from the manifest's binary.
|
|
965
963
|
*/
|
|
966
|
-
function
|
|
964
|
+
function connectMailboxWords() {
|
|
967
965
|
const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
|
|
968
|
-
return mail ? `${mail.
|
|
969
|
-
}
|
|
970
|
-
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
971
|
-
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
972
|
-
function randomString(length) {
|
|
973
|
-
const bytes = randomBytes(length);
|
|
974
|
-
let out = "";
|
|
975
|
-
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
976
|
-
return out;
|
|
977
|
-
}
|
|
978
|
-
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
979
|
-
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
980
|
-
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
981
|
-
function newApprovalId() {
|
|
982
|
-
return `ap_${randomString(26)}`;
|
|
983
|
-
}
|
|
984
|
-
function newPlanToken() {
|
|
985
|
-
return `pl_${randomString(26)}`;
|
|
986
|
-
}
|
|
987
|
-
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
988
|
-
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
989
|
-
function newChallenge(length = 4) {
|
|
990
|
-
let out = "";
|
|
991
|
-
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
992
|
-
return out;
|
|
993
|
-
}
|
|
994
|
-
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
995
|
-
function hashChallenge(challenge) {
|
|
996
|
-
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
997
|
-
}
|
|
998
|
-
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
999
|
-
function challengeMatches(answer, storedHash) {
|
|
1000
|
-
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1001
|
-
const b = Buffer.from(storedHash, "hex");
|
|
1002
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
1003
|
-
}
|
|
1004
|
-
function okEnvelope(data) {
|
|
1005
|
-
return {
|
|
1006
|
-
ok: true,
|
|
1007
|
-
schemaVersion: 1,
|
|
1008
|
-
data
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
function errorEnvelope(error) {
|
|
1012
|
-
const body = {
|
|
1013
|
-
code: error.code,
|
|
1014
|
-
message: error.message
|
|
1015
|
-
};
|
|
1016
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
1017
|
-
if (error.details !== void 0) body.details = error.details;
|
|
1018
|
-
return {
|
|
1019
|
-
ok: false,
|
|
1020
|
-
schemaVersion: 1,
|
|
1021
|
-
error: body
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
const defaultStreams = {
|
|
1025
|
-
stdout: process.stdout,
|
|
1026
|
-
stderr: process.stderr,
|
|
1027
|
-
stdin: process.stdin
|
|
1028
|
-
};
|
|
1029
|
-
function colorEnabled(env, stream, flag) {
|
|
1030
|
-
if (flag === false) return false;
|
|
1031
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1032
|
-
if (env.TERM === "dumb") return false;
|
|
1033
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1034
|
-
return Boolean(stream.isTTY);
|
|
1035
|
-
}
|
|
1036
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1037
|
-
function canPrompt(env, streams, options) {
|
|
1038
|
-
if (options.json || options.noInput) return false;
|
|
1039
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1040
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1041
|
-
}
|
|
1042
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1043
|
-
const AGENT_MARKERS = [
|
|
1044
|
-
"CLAUDECODE",
|
|
1045
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
1046
|
-
"CODEX_SANDBOX",
|
|
1047
|
-
"CODEX_HOME",
|
|
1048
|
-
"CURSOR_AGENT",
|
|
1049
|
-
"GEMINI_CLI",
|
|
1050
|
-
"AGENT_COMMS_AGENT"
|
|
1051
|
-
];
|
|
1052
|
-
function agentMarker(env) {
|
|
1053
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1054
|
-
return null;
|
|
1055
|
-
}
|
|
1056
|
-
function paint(color, format, text) {
|
|
1057
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
966
|
+
return mail ? `${mail.label}'s inbox add` : void 0;
|
|
1058
967
|
}
|
|
1059
968
|
/**
|
|
1060
969
|
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
1061
|
-
* Every command this
|
|
1062
|
-
*
|
|
970
|
+
* Every command this suite prints to be run — a change to run again with its approval, a folder to take out, an entry
|
|
971
|
+
* to register again or remove — is quoted here, when the locator or `externalCommand` makes it, so no printer quotes
|
|
972
|
+
* for a shell of its own.
|
|
1063
973
|
*
|
|
1064
974
|
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
1065
975
|
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
@@ -1098,31 +1008,58 @@ function paint(color, format, text) {
|
|
|
1098
1008
|
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
1099
1009
|
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
1100
1010
|
*
|
|
1011
|
+
* - The program itself has to be left as it is. Quoted, it is a string to PowerShell rather than a command — PowerShell
|
|
1012
|
+
* runs a quoted program only after `&`, which cmd.exe reads as the end of a command — so a program whose path needs
|
|
1013
|
+
* quotes, as `C:\Program Files\nodejs\node.exe` does, leaves the command with no line either (CUE-403).
|
|
1014
|
+
*
|
|
1101
1015
|
* Everywhere else there is always a line: single quotes make any word safe.
|
|
1016
|
+
*
|
|
1017
|
+
* Not a way to make a command for a person: what it returns is not one, and nothing that shows a command takes it. One
|
|
1018
|
+
* of this suite's own commands comes from `locateCliCommand`, any other program's from `externalCommand`, and each of
|
|
1019
|
+
* them quotes its words here.
|
|
1102
1020
|
*/
|
|
1103
|
-
function
|
|
1021
|
+
function quoteCommand(words, platform) {
|
|
1104
1022
|
if (platform !== "win32") return {
|
|
1105
1023
|
words,
|
|
1106
1024
|
line: words.map(posixShellWord).join(" "),
|
|
1107
1025
|
platform
|
|
1108
1026
|
};
|
|
1109
1027
|
const printed = words.map(windowsShellWord);
|
|
1028
|
+
const programBare = words.length === 0 || printed[0] === words[0];
|
|
1110
1029
|
return {
|
|
1111
1030
|
words,
|
|
1112
|
-
line: printed.includes(null) ? null : printed.join(" "),
|
|
1031
|
+
line: printed.includes(null) || !programBare ? null : printed.join(" "),
|
|
1113
1032
|
platform
|
|
1114
1033
|
};
|
|
1115
1034
|
}
|
|
1116
|
-
/**
|
|
1117
|
-
|
|
1118
|
-
|
|
1035
|
+
/**
|
|
1036
|
+
* A printed command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written,
|
|
1037
|
+
* never quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
|
|
1038
|
+
* command has no line.
|
|
1039
|
+
*/
|
|
1040
|
+
function quotedLineWithWordsToFill(command, ...toFill) {
|
|
1041
|
+
if (command.line === null) return null;
|
|
1042
|
+
const sentinel = command.words.indexOf("--");
|
|
1043
|
+
if (sentinel < 0) return [command.line, ...toFill].join(" ");
|
|
1044
|
+
const word = command.platform === "win32" ? windowsShellWord : posixShellWord;
|
|
1045
|
+
const printed = command.words.map((each) => word(each));
|
|
1046
|
+
return [
|
|
1047
|
+
...printed.slice(0, sentinel),
|
|
1048
|
+
...toFill,
|
|
1049
|
+
...printed.slice(sentinel)
|
|
1050
|
+
].join(" ");
|
|
1051
|
+
}
|
|
1052
|
+
/** Whether a Windows command has no line because its program's path needs quotes, which PowerShell would not run. */
|
|
1053
|
+
function programNeedsQuotes(command) {
|
|
1054
|
+
const [program] = command.words;
|
|
1055
|
+
return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
|
|
1119
1056
|
}
|
|
1120
1057
|
function posixShellWord(word) {
|
|
1121
1058
|
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1122
1059
|
}
|
|
1123
1060
|
/**
|
|
1124
1061
|
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
1125
|
-
* does (see `
|
|
1062
|
+
* does (see `quoteCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
1126
1063
|
*/
|
|
1127
1064
|
function windowsShellWord(word) {
|
|
1128
1065
|
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
@@ -1160,140 +1097,189 @@ function commandAsJson(words) {
|
|
|
1160
1097
|
}
|
|
1161
1098
|
/** What is said beside a command shown as JSON. */
|
|
1162
1099
|
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
1163
|
-
/**
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1168
|
-
function commandText(command) {
|
|
1169
|
-
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
1100
|
+
/** What is said beside a command shown as JSON because its program's path needs quotes. */
|
|
1101
|
+
const TO_TYPE_PROGRAM = "the command's words, written as JSON: the program's path needs quotes, and PowerShell runs a quoted program only after `&`, which cmd.exe does not accept, so type the command yourself, with each word quoted for the shell you use and, in PowerShell, `&` before the program";
|
|
1102
|
+
function toType(command) {
|
|
1103
|
+
return programNeedsQuotes(command) ? TO_TYPE_PROGRAM : TO_TYPE;
|
|
1170
1104
|
}
|
|
1171
|
-
/**
|
|
1172
|
-
function
|
|
1173
|
-
|
|
1174
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
const text = human(data);
|
|
1178
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1179
|
-
}
|
|
1180
|
-
/** Writes an error and returns the exit code to use. */
|
|
1181
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
1182
|
-
const commsError = toCommsError(error);
|
|
1183
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1184
|
-
else {
|
|
1185
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1186
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1187
|
-
}
|
|
1188
|
-
return commsError.exitCode;
|
|
1105
|
+
/** Quoted words in backticks, for a sentence — as words in JSON, saying it has to be typed, when there is no line. */
|
|
1106
|
+
function inlineQuoted(command) {
|
|
1107
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${toType(command)})` : `\`${command.line}\``;
|
|
1189
1108
|
}
|
|
1190
|
-
/**
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
await body();
|
|
1194
|
-
return EXIT_CODES.OK;
|
|
1195
|
-
} catch (error) {
|
|
1196
|
-
return writeError(error, options, streams);
|
|
1197
|
-
}
|
|
1109
|
+
/** Quoted words as text of their own — a list's line, a field's value — or as words in JSON, saying to type them. */
|
|
1110
|
+
function quotedText(command) {
|
|
1111
|
+
return command.line === null ? `${commandAsJson(command.words)} (${toType(command)})` : command.line;
|
|
1198
1112
|
}
|
|
1199
1113
|
/**
|
|
1200
|
-
* The
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1204
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1114
|
+
* The same command with words the agent is to fill in — a placeholder such as `<folder>` — before its first `--`: for
|
|
1115
|
+
* showing as words in JSON when the command has no line. With a line, `quotedLineWithWordsToFill` prints them as they
|
|
1116
|
+
* are written instead, never quoted as a word.
|
|
1205
1117
|
*/
|
|
1206
|
-
function
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1215
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
1118
|
+
function quotedWithWordsToFill(command, toFill) {
|
|
1119
|
+
const sentinel = command.words.indexOf("--");
|
|
1120
|
+
const at = sentinel < 0 ? command.words.length : sentinel;
|
|
1121
|
+
return quoteCommand([
|
|
1122
|
+
...command.words.slice(0, at),
|
|
1123
|
+
...toFill,
|
|
1124
|
+
...command.words.slice(at)
|
|
1125
|
+
], command.platform);
|
|
1216
1126
|
}
|
|
1217
|
-
/**
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
[847, 847],
|
|
1225
|
-
[1564, 1564],
|
|
1226
|
-
[4447, 4448],
|
|
1227
|
-
[6068, 6069],
|
|
1228
|
-
[6155, 6159],
|
|
1229
|
-
[8203, 8207],
|
|
1230
|
-
[8232, 8233],
|
|
1231
|
-
[8234, 8238],
|
|
1232
|
-
[8288, 8292],
|
|
1233
|
-
[8294, 8303],
|
|
1234
|
-
[12644, 12644],
|
|
1235
|
-
[65024, 65039],
|
|
1236
|
-
[65279, 65279],
|
|
1237
|
-
[65440, 65440],
|
|
1238
|
-
[917504, 917631]
|
|
1239
|
-
];
|
|
1240
|
-
function isInvisible(codePoint) {
|
|
1241
|
-
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
1242
|
-
return false;
|
|
1127
|
+
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1128
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1129
|
+
function randomString(length) {
|
|
1130
|
+
const bytes = randomBytes(length);
|
|
1131
|
+
let out = "";
|
|
1132
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1133
|
+
return out;
|
|
1243
1134
|
}
|
|
1244
|
-
/**
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
if (codePoint === 9 || codePoint === 10) return false;
|
|
1250
|
-
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
1135
|
+
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1136
|
+
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1137
|
+
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1138
|
+
function newApprovalId() {
|
|
1139
|
+
return `ap_${randomString(26)}`;
|
|
1251
1140
|
}
|
|
1252
|
-
function
|
|
1253
|
-
return
|
|
1141
|
+
function newPlanToken() {
|
|
1142
|
+
return `pl_${randomString(26)}`;
|
|
1254
1143
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
*
|
|
1259
|
-
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
1260
|
-
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
1261
|
-
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
1262
|
-
*/
|
|
1263
|
-
function stripInvisible(text) {
|
|
1264
|
-
let removed = 0;
|
|
1144
|
+
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1145
|
+
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1146
|
+
function newChallenge(length = 4) {
|
|
1265
1147
|
let out = "";
|
|
1266
|
-
for (
|
|
1267
|
-
|
|
1148
|
+
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1152
|
+
function hashChallenge(challenge) {
|
|
1153
|
+
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1154
|
+
}
|
|
1155
|
+
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1156
|
+
function challengeMatches(answer, storedHash) {
|
|
1157
|
+
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1158
|
+
const b = Buffer.from(storedHash, "hex");
|
|
1159
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1160
|
+
}
|
|
1161
|
+
function okEnvelope(data) {
|
|
1268
1162
|
return {
|
|
1269
|
-
|
|
1270
|
-
|
|
1163
|
+
ok: true,
|
|
1164
|
+
schemaVersion: 1,
|
|
1165
|
+
data
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function errorEnvelope(error) {
|
|
1169
|
+
const body = {
|
|
1170
|
+
code: error.code,
|
|
1171
|
+
message: error.message
|
|
1172
|
+
};
|
|
1173
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
1174
|
+
if (error.details !== void 0) body.details = error.details;
|
|
1175
|
+
return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
schemaVersion: 1,
|
|
1178
|
+
error: body
|
|
1271
1179
|
};
|
|
1272
1180
|
}
|
|
1273
1181
|
const APP_DIR_NAME = "agent-communications";
|
|
1274
|
-
/**
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1182
|
+
/** The five suite directories a CLI can pin independently. */
|
|
1183
|
+
const PATH_OPTIONS = [
|
|
1184
|
+
{
|
|
1185
|
+
key: "configDir",
|
|
1186
|
+
option: "config-dir",
|
|
1187
|
+
flag: "--config-dir"
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
key: "stateDir",
|
|
1191
|
+
option: "state-dir",
|
|
1192
|
+
flag: "--state-dir"
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
key: "dataDir",
|
|
1196
|
+
option: "data-dir",
|
|
1197
|
+
flag: "--data-dir"
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
key: "secretsDir",
|
|
1201
|
+
option: "secrets-dir",
|
|
1202
|
+
flag: "--secrets-dir"
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
key: "downloadsDir",
|
|
1206
|
+
option: "downloads-dir",
|
|
1207
|
+
flag: "--downloads-dir"
|
|
1208
|
+
}
|
|
1209
|
+
];
|
|
1210
|
+
/**
|
|
1211
|
+
* Words with the five path options and their values taken out where an option parser would read them — before the
|
|
1212
|
+
* first `--`, in both the `--x value` and `--x=value` forms — and every word from `--` on kept as it is.
|
|
1213
|
+
*
|
|
1214
|
+
* What a registered entry is recognised by. A pinned folder is a folder: `/tmp/@agentcomms/slack`, or one ending in
|
|
1215
|
+
* `packages/slack/src/cli.ts`, read as a package or an entry, made a Gmail registration Slack's. `dangling` says a
|
|
1216
|
+
* spaced option had no value before `--` or the end, which is a broken entry rather than one to guess about.
|
|
1217
|
+
*/
|
|
1218
|
+
function withoutPathOptions(words) {
|
|
1219
|
+
const flags = PATH_OPTIONS.map(({ flag }) => flag);
|
|
1220
|
+
const kept = [];
|
|
1221
|
+
let dangling = false;
|
|
1222
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
1223
|
+
const word = words[index];
|
|
1224
|
+
if (word === "--") {
|
|
1225
|
+
kept.push(...words.slice(index));
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
if (flags.includes(word)) {
|
|
1229
|
+
const value = words[index + 1];
|
|
1230
|
+
if (value === void 0 || value === "--") dangling = true;
|
|
1231
|
+
else index += 1;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
|
|
1235
|
+
kept.push(word);
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
words: kept,
|
|
1239
|
+
dangling
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
|
|
1244
|
+
* where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
|
|
1245
|
+
*
|
|
1246
|
+
* On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
|
|
1247
|
+
* the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
|
|
1248
|
+
* exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
|
|
1249
|
+
* someone who names a directory means that directory.
|
|
1250
|
+
*/
|
|
1283
1251
|
function resolvePaths(options = {}) {
|
|
1252
|
+
return resolvePathIdentity(options).paths;
|
|
1253
|
+
}
|
|
1254
|
+
/** Resolves the environment/default identity, then overlays each explicit pin without re-deriving any sibling. */
|
|
1255
|
+
function resolvePathIdentity(options = {}) {
|
|
1284
1256
|
const env = options.env ?? process.env;
|
|
1285
1257
|
const platform = options.platform ?? process.platform;
|
|
1286
1258
|
const home = options.home ?? homeOf(env, platform);
|
|
1287
1259
|
const configDir = resolve(env.AGENT_COMMS_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "agent-communications") : platform === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "agent-communications") : join(home, ".config", "agent-communications")));
|
|
1288
1260
|
const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
|
|
1289
1261
|
const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
|
|
1290
|
-
|
|
1262
|
+
const derived = {
|
|
1291
1263
|
configDir,
|
|
1292
1264
|
stateDir: resolve(env.AGENT_COMMS_STATE_DIR || join(localRoot, "state")),
|
|
1293
1265
|
secretsDir: resolve(join(localRoot, "secrets")),
|
|
1294
1266
|
dataDir: resolve(env.AGENT_COMMS_DATA_DIR || (platform === "win32" ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "agent-communications") : env.XDG_DATA_HOME ? join(env.XDG_DATA_HOME, "agent-communications") : join(home, ".local", "share", "agent-communications"))),
|
|
1295
1267
|
downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
|
|
1296
1268
|
};
|
|
1269
|
+
const pathOverrides = {};
|
|
1270
|
+
for (const { key } of PATH_OPTIONS) {
|
|
1271
|
+
const value = options.pathOverrides?.[key];
|
|
1272
|
+
if (value === void 0) continue;
|
|
1273
|
+
if (value.length === 0) throw new TypeError(`${key} cannot be empty`);
|
|
1274
|
+
pathOverrides[key] = resolve(value);
|
|
1275
|
+
}
|
|
1276
|
+
return {
|
|
1277
|
+
paths: {
|
|
1278
|
+
...derived,
|
|
1279
|
+
...pathOverrides
|
|
1280
|
+
},
|
|
1281
|
+
pathOverrides: Object.freeze(pathOverrides)
|
|
1282
|
+
};
|
|
1297
1283
|
}
|
|
1298
1284
|
/**
|
|
1299
1285
|
* The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
|
|
@@ -1363,6 +1349,294 @@ function shortenHome(path, home, platform = process.platform) {
|
|
|
1363
1349
|
function homeDirectory(env = process.env) {
|
|
1364
1350
|
return env.HOME || env.USERPROFILE || homedir();
|
|
1365
1351
|
}
|
|
1352
|
+
const defaultStreams = {
|
|
1353
|
+
stdout: process.stdout,
|
|
1354
|
+
stderr: process.stderr,
|
|
1355
|
+
stdin: process.stdin
|
|
1356
|
+
};
|
|
1357
|
+
/** Converts the shared parser's five global option values into independently applied core path pins. */
|
|
1358
|
+
function pathOverridesFromCliOptions(values) {
|
|
1359
|
+
const overrides = {};
|
|
1360
|
+
for (const { key, option, flag } of PATH_OPTIONS) {
|
|
1361
|
+
const value = values[option];
|
|
1362
|
+
if (value === void 0) continue;
|
|
1363
|
+
if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
|
|
1364
|
+
overrides[key] = value;
|
|
1365
|
+
}
|
|
1366
|
+
return overrides;
|
|
1367
|
+
}
|
|
1368
|
+
/** Inserts generated option words immediately before the first end-of-options sentinel. */
|
|
1369
|
+
function insertWordsBeforeSentinel(words, ...inserted) {
|
|
1370
|
+
const sentinel = words.indexOf("--");
|
|
1371
|
+
const at = sentinel < 0 ? words.length : sentinel;
|
|
1372
|
+
return [
|
|
1373
|
+
...words.slice(0, at),
|
|
1374
|
+
...inserted,
|
|
1375
|
+
...words.slice(at)
|
|
1376
|
+
];
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Removes the named options only where the CLI parser can see them: before the first `--`. Both `--x value` and
|
|
1380
|
+
* `--x=value` are removed. The sentinel and every positional word after it are byte-for-byte data.
|
|
1381
|
+
*/
|
|
1382
|
+
function withoutOptionsBeforeSentinel(words, flags) {
|
|
1383
|
+
const kept = [];
|
|
1384
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
1385
|
+
const word = words[index];
|
|
1386
|
+
if (word === "--") {
|
|
1387
|
+
kept.push(...words.slice(index));
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
const spaced = flags.find((flag) => word === flag);
|
|
1391
|
+
if (spaced !== void 0) {
|
|
1392
|
+
const value = words[index + 1];
|
|
1393
|
+
if (value === void 0 || value === "--") throw new CommsError("USAGE", `${spaced} needs a value before --`);
|
|
1394
|
+
index += 1;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
|
|
1398
|
+
kept.push(word);
|
|
1399
|
+
}
|
|
1400
|
+
return kept;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Replaces every pre-sentinel suite path option with one canonical pin per supplied directory. `insertAt` counts
|
|
1404
|
+
* words after the old pins are removed: zero puts global options after the executable held elsewhere, one puts them
|
|
1405
|
+
* after an entry/package word. Downloads are included only when the caller supplies that pin.
|
|
1406
|
+
*/
|
|
1407
|
+
function normalizePathOptionWords(words, pins, insertAt) {
|
|
1408
|
+
const stripped = withoutOptionsBeforeSentinel(words, PATH_OPTIONS.map(({ flag }) => flag));
|
|
1409
|
+
const sentinel = stripped.indexOf("--");
|
|
1410
|
+
const optionsEnd = sentinel < 0 ? stripped.length : sentinel;
|
|
1411
|
+
if (!Number.isSafeInteger(insertAt) || insertAt < 0 || insertAt > optionsEnd) throw new TypeError(`path-option insertion point ${insertAt} is outside the option words`);
|
|
1412
|
+
const pathWords = PATH_OPTIONS.flatMap(({ key, flag }) => {
|
|
1413
|
+
const value = pins[key];
|
|
1414
|
+
if (value === void 0) return [];
|
|
1415
|
+
if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
|
|
1416
|
+
return [flag, resolve(value)];
|
|
1417
|
+
});
|
|
1418
|
+
return [
|
|
1419
|
+
...stripped.slice(0, insertAt),
|
|
1420
|
+
...pathWords,
|
|
1421
|
+
...stripped.slice(insertAt)
|
|
1422
|
+
];
|
|
1423
|
+
}
|
|
1424
|
+
function colorEnabled(env, stream, flag) {
|
|
1425
|
+
if (flag === false) return false;
|
|
1426
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1427
|
+
if (env.TERM === "dumb") return false;
|
|
1428
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1429
|
+
return Boolean(stream.isTTY);
|
|
1430
|
+
}
|
|
1431
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1432
|
+
function canPrompt(env, streams, options) {
|
|
1433
|
+
if (options.json || options.noInput) return false;
|
|
1434
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1435
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1436
|
+
}
|
|
1437
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1438
|
+
const AGENT_MARKERS = [
|
|
1439
|
+
"CLAUDECODE",
|
|
1440
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1441
|
+
"CODEX_SANDBOX",
|
|
1442
|
+
"CODEX_HOME",
|
|
1443
|
+
"CURSOR_AGENT",
|
|
1444
|
+
"GEMINI_CLI",
|
|
1445
|
+
"AGENT_COMMS_AGENT"
|
|
1446
|
+
];
|
|
1447
|
+
function agentMarker(env) {
|
|
1448
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
function paint(color, format, text) {
|
|
1452
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1453
|
+
}
|
|
1454
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
1455
|
+
function inlineCommand(command) {
|
|
1456
|
+
return inlineQuoted(command);
|
|
1457
|
+
}
|
|
1458
|
+
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1459
|
+
function commandText(command) {
|
|
1460
|
+
return quotedText(command);
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* A command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written, never
|
|
1464
|
+
* quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
|
|
1465
|
+
* command has no line.
|
|
1466
|
+
*/
|
|
1467
|
+
function lineWithWordsToFill(command, ...toFill) {
|
|
1468
|
+
return quotedLineWithWordsToFill(command, ...toFill);
|
|
1469
|
+
}
|
|
1470
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
1471
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
1472
|
+
if (options.json) {
|
|
1473
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const text = human(data);
|
|
1477
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1478
|
+
}
|
|
1479
|
+
/** Writes an error and returns the exit code to use. */
|
|
1480
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
1481
|
+
const commsError = toCommsError(error);
|
|
1482
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1483
|
+
else {
|
|
1484
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1485
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1486
|
+
}
|
|
1487
|
+
return commsError.exitCode;
|
|
1488
|
+
}
|
|
1489
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
1490
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
1491
|
+
try {
|
|
1492
|
+
await body();
|
|
1493
|
+
return EXIT_CODES.OK;
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
return writeError(error, options, streams);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
1500
|
+
* whose code is not its own to make up.
|
|
1501
|
+
*
|
|
1502
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1503
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1504
|
+
*/
|
|
1505
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
1506
|
+
const marker = agentMarker(env);
|
|
1507
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
1508
|
+
hint: gateSentence(gate.command, (command) => `Ask the user to run ${command} in their own terminal.`),
|
|
1509
|
+
details: { marker }
|
|
1510
|
+
});
|
|
1511
|
+
const prompting = {};
|
|
1512
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
1513
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1514
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: gateSentence(gate.command, (command) => `Run ${command} directly in a terminal.`) });
|
|
1515
|
+
}
|
|
1516
|
+
/** A gate's command in a sentence: in backticks, or the sentence saying why there is none in its place. */
|
|
1517
|
+
function gateSentence(command, say) {
|
|
1518
|
+
return "message" in command ? command.message : say(inlineCommand(command));
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* The handoffs a store or a deep function was given (`core.handoffs`, handed on by `openCore`), for the command one of
|
|
1522
|
+
* its refusals names. A programming error when there are none: the package that opened core did not give it its
|
|
1523
|
+
* caller (`openCore({ caller })`), and there is no command to print in its place — not a bare name, not a guess.
|
|
1524
|
+
*/
|
|
1525
|
+
function requiredHandoffs(handoffs) {
|
|
1526
|
+
if (handoffs === void 0) throw new TypeError("core was opened without its caller, so it cannot locate a command: pass openCore({ caller })");
|
|
1527
|
+
return handoffs;
|
|
1528
|
+
}
|
|
1529
|
+
/** Whether a handoff is a command to run, rather than the sentence saying why there is none. */
|
|
1530
|
+
function isCommand(handoff) {
|
|
1531
|
+
return !("message" in handoff);
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* A handoff as a value of its own — a list's line, a field's value: the command's line, or its words as JSON with what
|
|
1535
|
+
* to do when no line is safe in every Windows shell; or, with no command, the sentence saying why.
|
|
1536
|
+
*/
|
|
1537
|
+
function handoffText(handoff) {
|
|
1538
|
+
return isCommand(handoff) ? commandText(handoff) : handoff.message;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* A remedy, a line per argument: one part, or the parts of one line in order — `[remove, ', then add it again']`.
|
|
1542
|
+
* A command is shown as `handoffText` shows it: its line, its words as JSON with what to do when no Windows line is
|
|
1543
|
+
* safe, or why there is none here.
|
|
1544
|
+
*/
|
|
1545
|
+
function remedy(...lines) {
|
|
1546
|
+
const part = (each) => typeof each === "string" ? each : handoffText(each);
|
|
1547
|
+
return lines.map((line) => Array.isArray(line) ? line.map(part).join("") : part(line)).join("\n");
|
|
1548
|
+
}
|
|
1549
|
+
/** The sentence saying why there is no command, after another way when there is one. */
|
|
1550
|
+
function noCommand(handoff, options) {
|
|
1551
|
+
return options.instead === void 0 ? handoff.message : `${options.instead} ${handoff.message}`;
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* A sentence that gives the command, in backticks, made by `say`; or, with no command, the sentence saying why there is
|
|
1555
|
+
* none — after `instead`, when given — never `say` with something else in the command's place.
|
|
1556
|
+
*/
|
|
1557
|
+
function handoffSentence(handoff, say, options = {}) {
|
|
1558
|
+
return isCommand(handoff) ? say(inlineCommand(handoff)) : noCommand(handoff, options);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* As `handoffSentence`, for a command with words the agent is to fill in — a placeholder such as `<folder>`, printed as
|
|
1562
|
+
* written — before its first `--`. With no line to show them in, the command is shown as its words, placeholders among
|
|
1563
|
+
* them, saying it has to be typed.
|
|
1564
|
+
*/
|
|
1565
|
+
function handoffSentenceToFill(handoff, toFill, say, options = {}) {
|
|
1566
|
+
if (!isCommand(handoff)) return noCommand(handoff, options);
|
|
1567
|
+
const line = lineWithWordsToFill(handoff, ...toFill);
|
|
1568
|
+
if (line !== null) return say(`\`${line}\``);
|
|
1569
|
+
return say(inlineQuoted(quotedWithWordsToFill(handoff, toFill)));
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Commands that each could do it — one per channel, say — as one phrase: the commands joined by "or", then, for each
|
|
1573
|
+
* that has none here, the sentence saying why. `none` is said when not one of them is a command.
|
|
1574
|
+
*/
|
|
1575
|
+
function handoffChoices(handoffs, none, options = {}) {
|
|
1576
|
+
const commands = handoffs.filter((handoff) => isCommand(handoff)).map((handoff) => inlineCommand(handoff));
|
|
1577
|
+
const missing = handoffs.filter((handoff) => !isCommand(handoff));
|
|
1578
|
+
const reasons = [...new Set(missing.map((handoff) => handoff.message))].join(" ");
|
|
1579
|
+
if (commands.length === 0) return `${none} ${reasons}`.trim();
|
|
1580
|
+
const conjunction = options.conjunction ?? "or";
|
|
1581
|
+
const joined = commands.length === 1 ? commands[0] : `${commands.slice(0, -1).join(", ")} ${conjunction} ${commands.at(-1)}`;
|
|
1582
|
+
return reasons === "" ? joined : `${joined} (${reasons.replace(/\.$/, "")})`;
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
1586
|
+
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
1587
|
+
*/
|
|
1588
|
+
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
1589
|
+
const INVISIBLE_RANGES = [
|
|
1590
|
+
[173, 173],
|
|
1591
|
+
[847, 847],
|
|
1592
|
+
[1564, 1564],
|
|
1593
|
+
[4447, 4448],
|
|
1594
|
+
[6068, 6069],
|
|
1595
|
+
[6155, 6159],
|
|
1596
|
+
[8203, 8207],
|
|
1597
|
+
[8232, 8233],
|
|
1598
|
+
[8234, 8238],
|
|
1599
|
+
[8288, 8292],
|
|
1600
|
+
[8294, 8303],
|
|
1601
|
+
[12644, 12644],
|
|
1602
|
+
[65024, 65039],
|
|
1603
|
+
[65279, 65279],
|
|
1604
|
+
[65440, 65440],
|
|
1605
|
+
[917504, 917631]
|
|
1606
|
+
];
|
|
1607
|
+
function isInvisible(codePoint) {
|
|
1608
|
+
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
1613
|
+
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
1614
|
+
*/
|
|
1615
|
+
function isControl(codePoint) {
|
|
1616
|
+
if (codePoint === 9 || codePoint === 10) return false;
|
|
1617
|
+
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
1618
|
+
}
|
|
1619
|
+
function isDangerous(codePoint) {
|
|
1620
|
+
return isControl(codePoint) || isInvisible(codePoint);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
1624
|
+
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
1625
|
+
*
|
|
1626
|
+
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
1627
|
+
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
1628
|
+
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
1629
|
+
*/
|
|
1630
|
+
function stripInvisible(text) {
|
|
1631
|
+
let removed = 0;
|
|
1632
|
+
let out = "";
|
|
1633
|
+
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
1634
|
+
else out += char;
|
|
1635
|
+
return {
|
|
1636
|
+
text: out,
|
|
1637
|
+
removed
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1366
1640
|
/**
|
|
1367
1641
|
* Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
|
|
1368
1642
|
* drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
|
|
@@ -1833,10 +2107,20 @@ function parseConfig$1(text, source = "config.json") {
|
|
|
1833
2107
|
var ConfigStore = class {
|
|
1834
2108
|
path;
|
|
1835
2109
|
#lockPath;
|
|
2110
|
+
#handoffs;
|
|
1836
2111
|
#cache = null;
|
|
1837
|
-
|
|
2112
|
+
/**
|
|
2113
|
+
* @param options.handoffs the printing package's handoffs (`core.handoffs`, CUE-403), for the command a refusal
|
|
2114
|
+
* names; left out, a refusal that has to name one is a programming error (`requiredHandoffs`).
|
|
2115
|
+
*/
|
|
2116
|
+
constructor(configDir, options = {}) {
|
|
1838
2117
|
this.path = join(configDir, "config.json");
|
|
1839
2118
|
this.#lockPath = join(configDir, ".config.lock");
|
|
2119
|
+
this.#handoffs = options.handoffs;
|
|
2120
|
+
}
|
|
2121
|
+
/** "Run the names migration again", as core's command — located from whatever is printing — or the tool. */
|
|
2122
|
+
#namesMigrateAgain(why) {
|
|
2123
|
+
return handoffSentence(requiredHandoffs(this.#handoffs).core(["names", "migrate"]), (command) => `Run ${command} again ${why}.`, { instead: `Call comms_names_migrate again ${why}.` });
|
|
1840
2124
|
}
|
|
1841
2125
|
/** The current config; an empty one when the file does not exist yet. */
|
|
1842
2126
|
async load() {
|
|
@@ -1869,7 +2153,7 @@ var ConfigStore = class {
|
|
|
1869
2153
|
this.#cache = null;
|
|
1870
2154
|
const current = structuredClone(await this.load());
|
|
1871
2155
|
const next = await mutator(structuredClone(current));
|
|
1872
|
-
if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only
|
|
2156
|
+
if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only the names migration changes the version. This is a bug — please report it." });
|
|
1873
2157
|
const parsed = schemaFor(current.version).safeParse(next);
|
|
1874
2158
|
if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, current.version)}`);
|
|
1875
2159
|
if (current.version === 2 && parsed.data.version === 2) {
|
|
@@ -1880,7 +2164,7 @@ var ConfigStore = class {
|
|
|
1880
2164
|
const allowed = new Set(options.consent?.paths ?? []);
|
|
1881
2165
|
const unconsented = loosened.filter((path) => !allowed.has(path));
|
|
1882
2166
|
if (unconsented.length > 0) throw new CommsError("LOOSENING_REFUSED", `this change loosens a safety setting: ${unconsented.join(", ")}`, {
|
|
1883
|
-
hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs
|
|
2167
|
+
hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs the approve command the result gives at their own terminal (`confirm`), and the tool is called again with the id. At a terminal, run the matching command and approve the change it shows.",
|
|
1884
2168
|
details: { paths: unconsented }
|
|
1885
2169
|
});
|
|
1886
2170
|
const approved = options.consent?.changes;
|
|
@@ -1940,13 +2224,13 @@ var ConfigStore = class {
|
|
|
1940
2224
|
const raw = await readFileIfExists(this.path);
|
|
1941
2225
|
const current = raw === null ? emptyConfig() : parseConfig$1(raw, this.path);
|
|
1942
2226
|
if (current.version === 2) {
|
|
1943
|
-
if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: "
|
|
2227
|
+
if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: this.#namesMigrateAgain("to see what they are called now") });
|
|
1944
2228
|
return {
|
|
1945
2229
|
status: "already-migrated",
|
|
1946
2230
|
config: current
|
|
1947
2231
|
};
|
|
1948
2232
|
}
|
|
1949
|
-
if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: "
|
|
2233
|
+
if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: this.#namesMigrateAgain("to see the mapping for the configuration as it is now") });
|
|
1950
2234
|
const parsed = configV2Schema.safeParse(build(structuredClone(current)));
|
|
1951
2235
|
if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, 2)}`);
|
|
1952
2236
|
const unchanged = onlyKeysRenamed(current, parsed.data);
|
|
@@ -2504,13 +2788,40 @@ function cancelledClaim(record) {
|
|
|
2504
2788
|
}
|
|
2505
2789
|
});
|
|
2506
2790
|
}
|
|
2791
|
+
/**
|
|
2792
|
+
* The refusals of a send's approval offered to a change. They name each sending channel's `approve`; another product's
|
|
2793
|
+
* is found only among the registrations read for it, which the store does not read under its lock — so a caller that
|
|
2794
|
+
* can read them says it again with them (`sendApprovesHint`).
|
|
2795
|
+
*/
|
|
2796
|
+
const MISDIRECTED_SENDS = /* @__PURE__ */ new WeakSet();
|
|
2797
|
+
/**
|
|
2798
|
+
* What an agent is told when a change waits for a person at a terminal: the printing package's own `approve` with this
|
|
2799
|
+
* id, located, or why there is none here.
|
|
2800
|
+
*/
|
|
2801
|
+
function approvePendingHint(handoffs, approvalId, platform) {
|
|
2802
|
+
const located = requiredHandoffs(handoffs);
|
|
2803
|
+
return handoffSentence(located.on(platform ?? located.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`);
|
|
2804
|
+
}
|
|
2805
|
+
/** Whether `error` is the refusal of a send's approval offered as a change's. */
|
|
2806
|
+
function isMisdirectedSend(error) {
|
|
2807
|
+
return error instanceof CommsError && MISDIRECTED_SENDS.has(error);
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* What approves the send `approvalId`: the `approve` of every channel that sends, one of which prepared it — a send
|
|
2811
|
+
* record does not say which (D1). Each is a located command, or why it has none here.
|
|
2812
|
+
*/
|
|
2813
|
+
function sendApprovesHint(maker, approvalId) {
|
|
2814
|
+
return `It is approved with the command that prepared it — ${handoffChoices(accountChannels().filter((manifest) => manifest.accounts?.modes.includes("send") === true).map((manifest) => maker.of(manifest.channel, ["approve", approvalId])), "and none is locatable here:")} — and permits only that send.`;
|
|
2815
|
+
}
|
|
2507
2816
|
var ApprovalStore = class {
|
|
2508
2817
|
directory;
|
|
2509
2818
|
#now;
|
|
2510
2819
|
#ttlMs;
|
|
2511
2820
|
#downloadTtlMs;
|
|
2821
|
+
#handoffs;
|
|
2512
2822
|
constructor(stateDir, options = {}) {
|
|
2513
2823
|
this.directory = join(stateDir, "approvals");
|
|
2824
|
+
this.#handoffs = options.handoffs;
|
|
2514
2825
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2515
2826
|
this.#ttlMs = options.ttlMs ?? 6e5;
|
|
2516
2827
|
this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
|
|
@@ -2616,17 +2927,17 @@ var ApprovalStore = class {
|
|
|
2616
2927
|
* Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
|
|
2617
2928
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
2618
2929
|
*/
|
|
2619
|
-
#requireKind(record, kind, platform) {
|
|
2930
|
+
#requireKind(record, kind, platform, handoffs = this.#handoffs) {
|
|
2931
|
+
const maker = () => requiredHandoffs(handoffs).on(platform);
|
|
2620
2932
|
const actual = approvalKind(record);
|
|
2621
2933
|
if (actual === kind) return;
|
|
2622
2934
|
const id = record.approvalId;
|
|
2623
2935
|
if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
2624
2936
|
if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
|
|
2937
|
+
if (kind === "send") throw refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, handoffSentence(maker().own(["approve", id]), (command) => `A person approves it with ${command}, and it permits only the change it was prepared for.`));
|
|
2938
|
+
const misdirected = refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, sendApprovesHint(maker(), id));
|
|
2939
|
+
MISDIRECTED_SENDS.add(misdirected);
|
|
2940
|
+
throw misdirected;
|
|
2630
2941
|
}
|
|
2631
2942
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
2632
2943
|
async issueChallenge(approvalId, kind = "send", platform = process.platform) {
|
|
@@ -2709,7 +3020,7 @@ var ApprovalStore = class {
|
|
|
2709
3020
|
async claimForSend(approvalId, live, options = {}) {
|
|
2710
3021
|
let failure = null;
|
|
2711
3022
|
const result = await this.#transition(approvalId, (current) => {
|
|
2712
|
-
this.#requireKind(current, "send", options.platform ?? process.platform);
|
|
3023
|
+
this.#requireKind(current, "send", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
2713
3024
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2714
3025
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2715
3026
|
const voidWith = (code, reason) => {
|
|
@@ -2821,7 +3132,7 @@ var ApprovalStore = class {
|
|
|
2821
3132
|
const digest = changeDigest(live.change);
|
|
2822
3133
|
let failure = null;
|
|
2823
3134
|
const result = await this.#transition(approvalId, (current) => {
|
|
2824
|
-
this.#requireKind(current, "change", options.platform ?? process.platform);
|
|
3135
|
+
this.#requireKind(current, "change", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
2825
3136
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2826
3137
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2827
3138
|
const voidWith = (reason) => {
|
|
@@ -2837,11 +3148,7 @@ var ApprovalStore = class {
|
|
|
2837
3148
|
};
|
|
2838
3149
|
if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
|
|
2839
3150
|
if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
|
|
2840
|
-
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ??
|
|
2841
|
-
"agentcomms",
|
|
2842
|
-
"approve",
|
|
2843
|
-
approvalId
|
|
2844
|
-
], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
|
|
3151
|
+
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? approvePendingHint(options.handoffs ?? this.#handoffs, approvalId, options.platform));
|
|
2845
3152
|
if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
|
|
2846
3153
|
}
|
|
2847
3154
|
return {
|
|
@@ -2963,7 +3270,7 @@ var ApprovalStore = class {
|
|
|
2963
3270
|
const digest = downloadDigest(live);
|
|
2964
3271
|
let failure = null;
|
|
2965
3272
|
const result = await this.#transition(approvalId, (current) => {
|
|
2966
|
-
this.#requireKind(current, "download", options.platform ?? process.platform);
|
|
3273
|
+
this.#requireKind(current, "download", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
2967
3274
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2968
3275
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2969
3276
|
const voidWith = (reason) => {
|
|
@@ -3114,125 +3421,99 @@ var AuditLog = class {
|
|
|
3114
3421
|
return out.reverse();
|
|
3115
3422
|
}
|
|
3116
3423
|
};
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3424
|
+
/** Every command a manifest declares — each channel's `binary` and its server's other `bins` — lowercased. */
|
|
3425
|
+
const SUITE_COMMANDS = new Set(CHANNEL_SNAPSHOT.flatMap(({ manifest }) => [manifest.binary, ...manifest.server.bins ?? []]).map((name) => name.toLowerCase()));
|
|
3426
|
+
/** The extensions Windows finds an executable by: `agent-gmail.cmd` is `agent-gmail`. */
|
|
3427
|
+
const EXECUTABLE_EXTENSION = /\.(?:cmd|exe|bat|ps1|com)$/i;
|
|
3121
3428
|
/**
|
|
3122
|
-
*
|
|
3123
|
-
*
|
|
3124
|
-
* A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
|
|
3125
|
-
* of an inbox called `constructor`, which version 1 allows, found it.
|
|
3429
|
+
* The suite command a word names as a program — its last path part, without a Windows extension, compared without
|
|
3430
|
+
* case as Windows compares executable names — or null.
|
|
3126
3431
|
*/
|
|
3127
|
-
function
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
function resolveName(config, kind, name, notFound) {
|
|
3131
|
-
if (kind === "inbox") {
|
|
3132
|
-
const inbox = own$1(config.inboxes, name);
|
|
3133
|
-
if (inbox) return {
|
|
3134
|
-
alias: name,
|
|
3135
|
-
inbox
|
|
3136
|
-
};
|
|
3137
|
-
} else {
|
|
3138
|
-
const account = own$1(config.accounts, name);
|
|
3139
|
-
if (account) return {
|
|
3140
|
-
alias: name,
|
|
3141
|
-
account
|
|
3142
|
-
};
|
|
3143
|
-
}
|
|
3144
|
-
const renamed = formerNameRefusal(config, kind, name);
|
|
3145
|
-
if (renamed) throw renamed;
|
|
3146
|
-
throw notFound?.() ?? defaultNotFound(config, kind, name);
|
|
3432
|
+
function suiteCommandOf(word) {
|
|
3433
|
+
const name = (word.split(/[\\/]/).at(-1) ?? "").replace(EXECUTABLE_EXTENSION, "").toLowerCase();
|
|
3434
|
+
return SUITE_COMMANDS.has(name) ? name : null;
|
|
3147
3435
|
}
|
|
3148
|
-
/**
|
|
3149
|
-
function
|
|
3150
|
-
const
|
|
3151
|
-
return
|
|
3436
|
+
/** A parsed `package.json`, as an object, or null when there is none, it cannot be read, or it is not an object. */
|
|
3437
|
+
function readPackageManifest(dir) {
|
|
3438
|
+
const read = readPackageFile(dir);
|
|
3439
|
+
return read === "absent" ? null : read;
|
|
3152
3440
|
}
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3441
|
+
/** `absent` when the directory has no `package.json`; otherwise what it says, or null when that cannot be read. */
|
|
3442
|
+
function readPackageFile(dir) {
|
|
3443
|
+
let text;
|
|
3444
|
+
try {
|
|
3445
|
+
text = readFileSync(join(dir, "package.json"), "utf8");
|
|
3446
|
+
} catch (error) {
|
|
3447
|
+
const code = error.code;
|
|
3448
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : null;
|
|
3449
|
+
}
|
|
3450
|
+
try {
|
|
3451
|
+
const parsed = JSON.parse(text);
|
|
3452
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
3453
|
+
} catch {
|
|
3454
|
+
return null;
|
|
3157
3455
|
}
|
|
3158
|
-
return new CommsError("NOT_FOUND", `no account called "${name}"`);
|
|
3159
|
-
}
|
|
3160
|
-
function lookupName(config, kind, name) {
|
|
3161
|
-
return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
|
|
3162
3456
|
}
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3457
|
+
/**
|
|
3458
|
+
* The nearest package at or above `start`: the first directory with a `package.json`, and what it says (null when it
|
|
3459
|
+
* cannot be read or parsed). Not the nearest one with a particular name — the package a module belongs to is the
|
|
3460
|
+
* nearest one, as Node itself decides, and a caller that names a different one has to be told so.
|
|
3461
|
+
*/
|
|
3462
|
+
function nearestPackage(start) {
|
|
3463
|
+
let dir = resolve(start);
|
|
3464
|
+
for (;;) {
|
|
3465
|
+
const manifest = readPackageFile(dir);
|
|
3466
|
+
if (manifest !== "absent") return {
|
|
3467
|
+
root: dir,
|
|
3468
|
+
manifest
|
|
3168
3469
|
};
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
}
|
|
3173
|
-
return null;
|
|
3470
|
+
const parent = dirname(dir);
|
|
3471
|
+
if (parent === dir) return null;
|
|
3472
|
+
dir = parent;
|
|
3473
|
+
}
|
|
3174
3474
|
}
|
|
3175
3475
|
/**
|
|
3176
|
-
*
|
|
3177
|
-
*
|
|
3178
|
-
* The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
|
|
3179
|
-
* account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
|
|
3180
|
-
* name that now belongs to nothing.
|
|
3476
|
+
* Whether `path` is `root` or inside it, by whole path segments: `/x/core-evil` is not inside `/x/core`, which a
|
|
3477
|
+
* comparison of strings would say it was. Both are taken as given; a caller compares real paths with real paths.
|
|
3181
3478
|
*/
|
|
3182
|
-
function
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
}
|
|
3479
|
+
function isWithin(root, path) {
|
|
3480
|
+
const between = relative(resolve(root), resolve(path));
|
|
3481
|
+
return between === "" || !isAbsolute(between) && between !== ".." && !/^\.\.[\\/]/.test(between);
|
|
3482
|
+
}
|
|
3483
|
+
/** The real path of `path`, or of its deepest ancestor that exists with the rest joined on; null when none does. */
|
|
3484
|
+
function realpathOfExisting(path) {
|
|
3485
|
+
let current = resolve(path);
|
|
3486
|
+
const rest = [];
|
|
3487
|
+
for (;;) try {
|
|
3488
|
+
const real = realpathSync(current);
|
|
3489
|
+
return rest.length === 0 ? real : join(real, ...rest.reverse());
|
|
3490
|
+
} catch {
|
|
3491
|
+
const parent = dirname(current);
|
|
3492
|
+
if (parent === current) return null;
|
|
3493
|
+
rest.push(basename(current));
|
|
3494
|
+
current = parent;
|
|
3495
|
+
}
|
|
3199
3496
|
}
|
|
3200
3497
|
/**
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
*
|
|
3204
|
-
* older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
|
|
3205
|
-
* 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
|
|
3206
|
-
* either kind.
|
|
3207
|
-
*
|
|
3208
|
-
* A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
|
|
3209
|
-
* again for version 2, because anything can happen between asking and writing.
|
|
3498
|
+
* The root of the suite package `path` is in — itself or any ancestor whose `package.json` names a package of this
|
|
3499
|
+
* suite — as written and after resolving links, or null. Every ancestor is looked at, not only the nearest package:
|
|
3500
|
+
* a file in `packages/gmail/node_modules/x` is still inside Gmail's root.
|
|
3210
3501
|
*/
|
|
3211
|
-
function
|
|
3212
|
-
const
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
3225
|
-
for (const map of ["inboxes", "accounts"]) {
|
|
3226
|
-
const former = own$1(config.formerNames[map], name);
|
|
3227
|
-
if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
|
|
3228
|
-
hint: "A former name keeps pointing people at the account that had it. Choose another name.",
|
|
3229
|
-
details: {
|
|
3230
|
-
formerName: name,
|
|
3231
|
-
id: former.id
|
|
3232
|
-
}
|
|
3233
|
-
}));
|
|
3502
|
+
function suitePackageRootOf(path) {
|
|
3503
|
+
const candidates = [resolve(path)];
|
|
3504
|
+
const real = realpathOfExisting(path);
|
|
3505
|
+
if (real !== null && real !== candidates[0]) candidates.push(real);
|
|
3506
|
+
for (const candidate of candidates) {
|
|
3507
|
+
let dir = candidate;
|
|
3508
|
+
for (;;) {
|
|
3509
|
+
const name = readPackageManifest(dir)?.name;
|
|
3510
|
+
if (typeof name === "string" && name.startsWith("@agentcomms/")) return dir;
|
|
3511
|
+
const parent = dirname(dir);
|
|
3512
|
+
if (parent === dir) break;
|
|
3513
|
+
dir = parent;
|
|
3514
|
+
}
|
|
3234
3515
|
}
|
|
3235
|
-
return
|
|
3516
|
+
return null;
|
|
3236
3517
|
}
|
|
3237
3518
|
/**
|
|
3238
3519
|
* Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
|
|
@@ -3868,10 +4149,126 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
|
|
|
3868
4149
|
};
|
|
3869
4150
|
}
|
|
3870
4151
|
function packageFrom(server) {
|
|
3871
|
-
|
|
4152
|
+
return commandLinePackage(server.command, server.args);
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* The npm package an entry's command line launches, when one can be read from it: `@scope/name`, or a bare `…-mcp`.
|
|
4156
|
+
* Read from the words left once the suite's path options are taken out (`withoutPathOptions`): a pinned folder such as
|
|
4157
|
+
* `/tmp/@agentcomms/slack` is not a package, and read as one it made a Gmail entry Slack's.
|
|
4158
|
+
*/
|
|
4159
|
+
function commandLinePackage(command, args) {
|
|
4160
|
+
const line = [command, ...withoutPathOptions(args).words].join(" ");
|
|
3872
4161
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
3873
4162
|
}
|
|
3874
4163
|
/**
|
|
4164
|
+
* A command for a person to run that is not this suite's: `chmod`, or an MCP client's own `claude mcp …` and
|
|
4165
|
+
* `codex mcp …` (design 2026-10-04, D5).
|
|
4166
|
+
*
|
|
4167
|
+
* Every command printed for a person is one of two kinds. One of this suite's own CLIs is a `PrintedCommand`, which
|
|
4168
|
+
* only the locator makes (`cli-command.ts`): it names this Node and a checked file of the product, because a bare
|
|
4169
|
+
* `agent-gmail` is not on the PATH of most people it is printed for (CUE-403). Anything else is an `ExternalCommand`,
|
|
4170
|
+
* made here and nowhere else, with the reason that program is one a person may be told to run.
|
|
4171
|
+
*
|
|
4172
|
+
* So that this is not a way round the locator, `externalCommand` refuses words that would start one of this suite's
|
|
4173
|
+
* products by another route: a word containing one of the suite's commands — in any case, alone, as a path's last
|
|
4174
|
+
* part or inside a wrapper's payload (`sh -c "agent-gmail …"`) — a word naming a package of the suite
|
|
4175
|
+
* (`npx @agentcomms/gmail`), or a path inside the root of a suite package, as written or through a link
|
|
4176
|
+
* (`node …/packages/gmail/dist/cli.mjs`). Every word is looked at, not only the first: the program is often a wrapper.
|
|
4177
|
+
*
|
|
4178
|
+
* This module is a leaf: it reads the manifests' facts and the files a path names, and imports nothing that registers,
|
|
4179
|
+
* scans or locates, so nothing it refuses can be routed back through it.
|
|
4180
|
+
*/
|
|
4181
|
+
/** The gate a constructor call has to pass: only this module holds it. */
|
|
4182
|
+
const GATE$1 = Symbol("externalCommand");
|
|
4183
|
+
/**
|
|
4184
|
+
* One reviewed command of a program that is not this suite's. Rendered as every printed command is — a line to paste,
|
|
4185
|
+
* or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`, `commandText`).
|
|
4186
|
+
*
|
|
4187
|
+
* A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
|
|
4188
|
+
* a spread copy of one with other words in it. Only the type is exported; the class is not.
|
|
4189
|
+
*/
|
|
4190
|
+
var ExternalCommand = class {
|
|
4191
|
+
#reason;
|
|
4192
|
+
/** The words, as the program is to receive them. */
|
|
4193
|
+
words;
|
|
4194
|
+
/** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
|
|
4195
|
+
line;
|
|
4196
|
+
platform;
|
|
4197
|
+
constructor(gate, words, platform, reason) {
|
|
4198
|
+
if (gate !== GATE$1) throw new TypeError("an external command is made by externalCommand, and only there");
|
|
4199
|
+
this.#reason = reason;
|
|
4200
|
+
this.words = Object.freeze([...words]);
|
|
4201
|
+
this.line = quoteCommand(this.words, platform).line;
|
|
4202
|
+
this.platform = platform;
|
|
4203
|
+
Object.freeze(this);
|
|
4204
|
+
}
|
|
4205
|
+
/** Why this program, which is not part of this suite, is one a person may be told to run. */
|
|
4206
|
+
get reason() {
|
|
4207
|
+
return this.#reason;
|
|
4208
|
+
}
|
|
4209
|
+
/** As JSON, the text it always was: its line, or its words as JSON with what to do (as a `PrintedCommand`). */
|
|
4210
|
+
toJSON() {
|
|
4211
|
+
return quotedText(this);
|
|
4212
|
+
}
|
|
4213
|
+
/** Never interpolated: rendered with `commandText` or `inlineCommand`. */
|
|
4214
|
+
[Symbol.toPrimitive]() {
|
|
4215
|
+
throw new TypeError("an external command is rendered with commandText or inlineCommand, never interpolated");
|
|
4216
|
+
}
|
|
4217
|
+
};
|
|
4218
|
+
/** A package specifier of the suite, with either separator: `@agentcomms/gmail`, `node_modules\@agentcomms\gmail`. */
|
|
4219
|
+
const SUITE_PACKAGE = /@agentcomms[\\/]/i;
|
|
4220
|
+
/**
|
|
4221
|
+
* The parts of a word a shell or a wrapper could take as a command name: split wherever a character could not be part
|
|
4222
|
+
* of one, so `sh -c "cd x && agent-gmail approve"`, `--run=agent-gmail` and `C:\npm\agent-gmail.cmd` each yield
|
|
4223
|
+
* `agent-gmail`. Compared without case, as Windows compares executable names; on a case-sensitive system that refuses
|
|
4224
|
+
* a little more than it must, and nothing that it should not.
|
|
4225
|
+
*/
|
|
4226
|
+
function commandNames(word) {
|
|
4227
|
+
return word.split(/[^A-Za-z0-9._-]+/).map((part) => part.replace(/^\.+|\.+$/g, "").replace(EXECUTABLE_EXTENSION, "").toLowerCase()).filter(Boolean);
|
|
4228
|
+
}
|
|
4229
|
+
/**
|
|
4230
|
+
* The paths in a word: the word itself, and each part of it between spaces, quotes, `=` and a shell's separators, that
|
|
4231
|
+
* is absolute, `~/…`, or a `file:` URL. A relative path is not resolved: the folder a person pastes the command in is
|
|
4232
|
+
* not known here, and a server called `7/gmail` is a name, not a path.
|
|
4233
|
+
*/
|
|
4234
|
+
function pathsIn(word) {
|
|
4235
|
+
const paths = [];
|
|
4236
|
+
for (const part of [word, ...word.split(/[\s"'`=;&|<>(),]+/)]) {
|
|
4237
|
+
if (part.length === 0) continue;
|
|
4238
|
+
if (/^file:/i.test(part)) try {
|
|
4239
|
+
paths.push(fileURLToPath(part));
|
|
4240
|
+
} catch {}
|
|
4241
|
+
else if (part === "~" || /^~[\\/]/.test(part)) paths.push(join(homedir(), part.slice(2)));
|
|
4242
|
+
else if (isAbsolute(part)) paths.push(part);
|
|
4243
|
+
}
|
|
4244
|
+
return paths;
|
|
4245
|
+
}
|
|
4246
|
+
/** Why these words must not be an external command, or null when they may be. */
|
|
4247
|
+
function refusal$1(words) {
|
|
4248
|
+
for (const [index, word] of words.entries()) {
|
|
4249
|
+
if (SUITE_PACKAGE.test(word)) return `word ${index} names a package of this suite`;
|
|
4250
|
+
for (const path of pathsIn(word)) {
|
|
4251
|
+
const root = suitePackageRootOf(path);
|
|
4252
|
+
if (root !== null) return `word ${index} is a path inside ${root}, a package of this suite`;
|
|
4253
|
+
}
|
|
4254
|
+
if (commandNames(word).some((name) => SUITE_COMMANDS.has(name))) return `word ${index} names a command of this suite`;
|
|
4255
|
+
}
|
|
4256
|
+
return null;
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* The one way to make an `ExternalCommand`: `words` for a program that is not this suite's, and `reason`, why a person
|
|
4260
|
+
* may be told to run it. Throws a `TypeError` for words that would start a product of this suite (see the module), so
|
|
4261
|
+
* a call site whose words come from somewhere else — a server's name read from a client's file, a folder — says what
|
|
4262
|
+
* to do in prose instead when one is refused. `platform` is the shell it is quoted for.
|
|
4263
|
+
*/
|
|
4264
|
+
function externalCommand(words, reason, platform = process.platform) {
|
|
4265
|
+
if (words.length === 0 || (words[0] ?? "").length === 0) throw new TypeError("an external command needs a program");
|
|
4266
|
+
if (reason.trim().length === 0) throw new TypeError("an external command needs the reason it may be run");
|
|
4267
|
+
const refused = refusal$1(words);
|
|
4268
|
+
if (refused !== null) throw new TypeError(`not an external command: ${refused}; a command of this suite comes from locateCliCommand`);
|
|
4269
|
+
return new ExternalCommand(GATE$1, words, platform, reason);
|
|
4270
|
+
}
|
|
4271
|
+
/**
|
|
3875
4272
|
* The programs this package starts, named so that no folder it happens to be in can stand in for them.
|
|
3876
4273
|
*
|
|
3877
4274
|
* A bare name is looked up, and on Windows the lookup starts in the current folder: libuv tries it before `PATH`, and
|
|
@@ -4002,24 +4399,54 @@ async function resolveNode(env) {
|
|
|
4002
4399
|
return await whichExecutable("node", env) ?? process.execPath;
|
|
4003
4400
|
}
|
|
4004
4401
|
function minimalEnv(context, node) {
|
|
4005
|
-
const env = {
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
"/bin"
|
|
4012
|
-
].join(delimiter)
|
|
4013
|
-
};
|
|
4402
|
+
const env = { PATH: [
|
|
4403
|
+
dirname(node),
|
|
4404
|
+
"/usr/local/bin",
|
|
4405
|
+
"/usr/bin",
|
|
4406
|
+
"/bin"
|
|
4407
|
+
].join(delimiter) };
|
|
4014
4408
|
if (process.platform === "linux") for (const key of ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) {
|
|
4015
4409
|
const value = context.env[key];
|
|
4016
4410
|
if (value) env[key] = value;
|
|
4017
4411
|
}
|
|
4018
4412
|
return env;
|
|
4019
4413
|
}
|
|
4414
|
+
/** The complete suite identity every installer-written server registration carries. Downloads are operation-local. */
|
|
4415
|
+
function registrationPathArgs(context) {
|
|
4416
|
+
const { configDir, stateDir, dataDir, secretsDir } = context.core.paths;
|
|
4417
|
+
return [
|
|
4418
|
+
"--config-dir",
|
|
4419
|
+
configDir,
|
|
4420
|
+
"--state-dir",
|
|
4421
|
+
stateDir,
|
|
4422
|
+
"--data-dir",
|
|
4423
|
+
dataDir,
|
|
4424
|
+
"--secrets-dir",
|
|
4425
|
+
secretsDir
|
|
4426
|
+
];
|
|
4427
|
+
}
|
|
4020
4428
|
function unscoped(packageName) {
|
|
4021
4429
|
return packageName.split("/").at(-1) ?? packageName;
|
|
4022
4430
|
}
|
|
4431
|
+
function escapeRegExp$2(text) {
|
|
4432
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4433
|
+
}
|
|
4434
|
+
/**
|
|
4435
|
+
* An MCP client's own command about one of its entries — `claude mcp get <name>`, `codex mcp remove <name>` — in
|
|
4436
|
+
* backticks for a hint, or, when there is none to give, `instead`.
|
|
4437
|
+
*
|
|
4438
|
+
* The client's program, so an external command (`externalCommand`), and the name is whatever the entry is called. A
|
|
4439
|
+
* name that is also one of this suite's commands — the core server's own default, `agentcomms`, is one — is refused
|
|
4440
|
+
* there, because such a word is how a suite command would get round the locator; the hint then says the same thing in
|
|
4441
|
+
* words rather than printing a command with that name in it.
|
|
4442
|
+
*/
|
|
4443
|
+
function clientCommandHint(words, platform, instead) {
|
|
4444
|
+
try {
|
|
4445
|
+
return inlineCommand(externalCommand(words, "the MCP client's own command reads or removes an entry of its own configuration", platform));
|
|
4446
|
+
} catch {
|
|
4447
|
+
return instead;
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4023
4450
|
/**
|
|
4024
4451
|
* The directory the managed launcher installs one product's exact version into: `<data>/runtime/<version>-<name>`.
|
|
4025
4452
|
*
|
|
@@ -4037,6 +4464,29 @@ function managedRuntimeEntry(dataDir, packageName, version) {
|
|
|
4037
4464
|
return join(managedRuntimeDir(dataDir, packageName, version), "node_modules", ...packageName.split("/"), "dist", "cli.mjs");
|
|
4038
4465
|
}
|
|
4039
4466
|
/**
|
|
4467
|
+
* The version a managed-runtime path pins, in either layout, or null when the path is not one.
|
|
4468
|
+
*
|
|
4469
|
+
* Both `runtime/0.4.0/…` (every Gmail install before the move) and `runtime/0.4.0-gmail/…` (every install
|
|
4470
|
+
* since) are read, and a prerelease keeps its own hyphen: `0.5.0-rc.1-gmail` is `0.5.0-rc.1`. Either separator,
|
|
4471
|
+
* and allowed to start the string, so a Windows or a relative path is not missed. On Windows (`platform`), whose
|
|
4472
|
+
* paths are not case-sensitive, the folder names are read without case; the version keeps the case it has.
|
|
4473
|
+
*/
|
|
4474
|
+
function managedRuntimeVersion(path, packageName, platform) {
|
|
4475
|
+
const scope = packageName.split("/").map(escapeRegExp$2).join("[/\\\\]");
|
|
4476
|
+
return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$2(unscoped(packageName))})?[/\\\\]node_modules[/\\\\]${scope}[/\\\\]`, platform === "win32" ? "i" : "").exec(path)?.[1] ?? null;
|
|
4477
|
+
}
|
|
4478
|
+
/**
|
|
4479
|
+
* The version one argument of a registered entry pins, or null when it pins none.
|
|
4480
|
+
*
|
|
4481
|
+
* Two launchers pin, and they look nothing alike: `managed` writes a runtime path, `npx` a package spec. Reading
|
|
4482
|
+
* only the first reported an `npx`-pinned install as current for ever. `local` pins nothing and is ignored.
|
|
4483
|
+
*/
|
|
4484
|
+
function pinnedVersion(argument, product, platform) {
|
|
4485
|
+
const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$2).join("|");
|
|
4486
|
+
const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)$`, platform === "win32" ? "i" : "");
|
|
4487
|
+
return managedRuntimeVersion(argument, product.packageName, platform) ?? spec.exec(argument)?.[1] ?? null;
|
|
4488
|
+
}
|
|
4489
|
+
/**
|
|
4040
4490
|
* Whether a registered entry starts this product's server.
|
|
4041
4491
|
*
|
|
4042
4492
|
* Decides what `--force` may replace, so it errs towards "not ours". An npm package read off the command line
|
|
@@ -4044,11 +4494,20 @@ function managedRuntimeEntry(dataDir, packageName, version) {
|
|
|
4044
4494
|
* by, compared as whole runs of segments — `@agentcomms/gmail-evil/dist/cli.mjs`, `@agentcomms/gmail/dist/
|
|
4045
4495
|
* index.mjs` and `packages/gmail/src/nested/cli.ts` are all near misses, and each was once accepted by a looser
|
|
4046
4496
|
* match somewhere in this repository.
|
|
4497
|
+
*
|
|
4498
|
+
* Only the entry's own words are read: the suite's path options and their values are taken out first, because a pinned
|
|
4499
|
+
* folder that ends in `packages/slack/src/cli.ts` is a folder, not Slack (`withoutPathOptions`). On Windows
|
|
4500
|
+
* (`platform`), whose file names are not case-sensitive, the command's name and every path segment are compared
|
|
4501
|
+
* without case, as Windows itself finds them; everywhere else exactly. Left out, it compares exactly.
|
|
4047
4502
|
*/
|
|
4048
|
-
function isProductServer(server, product) {
|
|
4049
|
-
const
|
|
4050
|
-
|
|
4051
|
-
if (
|
|
4503
|
+
function isProductServer(server, product, platform) {
|
|
4504
|
+
const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
4505
|
+
const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
4506
|
+
if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
|
|
4507
|
+
if (server.packageName) {
|
|
4508
|
+
const named = fold(server.packageName);
|
|
4509
|
+
return named === fold(product.packageName) || named === fold(product.npxPackage);
|
|
4510
|
+
}
|
|
4052
4511
|
const short = unscoped(product.packageName);
|
|
4053
4512
|
const entries = [
|
|
4054
4513
|
[
|
|
@@ -4071,9 +4530,9 @@ function isProductServer(server, product) {
|
|
|
4071
4530
|
],
|
|
4072
4531
|
...product.entryFiles ?? []
|
|
4073
4532
|
];
|
|
4074
|
-
return [server.command, ...server.args].some((part) => {
|
|
4075
|
-
const segments = part.split(/[\\/]+/).filter(Boolean);
|
|
4076
|
-
return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === wanted));
|
|
4533
|
+
return [server.command, ...withoutPathOptions(server.args).words].some((part) => {
|
|
4534
|
+
const segments = part.split(/[\\/]+/).filter(Boolean).map(fold);
|
|
4535
|
+
return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === fold(wanted)));
|
|
4077
4536
|
});
|
|
4078
4537
|
}
|
|
4079
4538
|
/**
|
|
@@ -4169,6 +4628,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4169
4628
|
const launcher = options.launcher ?? "managed";
|
|
4170
4629
|
const { node } = resolved;
|
|
4171
4630
|
const env = minimalEnv(context, node);
|
|
4631
|
+
const pathArgs = registrationPathArgs(context);
|
|
4172
4632
|
const serverArgs = product.serverArgs(options);
|
|
4173
4633
|
if (launcher === "npx") return {
|
|
4174
4634
|
entry: {
|
|
@@ -4176,6 +4636,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4176
4636
|
args: [
|
|
4177
4637
|
"-y",
|
|
4178
4638
|
`${product.npxPackage}@${product.version}`,
|
|
4639
|
+
...pathArgs,
|
|
4179
4640
|
...product.npxArgs ?? [],
|
|
4180
4641
|
...serverArgs
|
|
4181
4642
|
],
|
|
@@ -4192,6 +4653,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4192
4653
|
args: [
|
|
4193
4654
|
...entryPath.endsWith(".ts") ? ["--experimental-strip-types", "--disable-warning=ExperimentalWarning"] : [],
|
|
4194
4655
|
entryPath,
|
|
4656
|
+
...pathArgs,
|
|
4195
4657
|
"mcp",
|
|
4196
4658
|
...serverArgs
|
|
4197
4659
|
],
|
|
@@ -4209,6 +4671,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4209
4671
|
command: node,
|
|
4210
4672
|
args: [
|
|
4211
4673
|
ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
|
|
4674
|
+
...pathArgs,
|
|
4212
4675
|
"mcp",
|
|
4213
4676
|
...serverArgs
|
|
4214
4677
|
],
|
|
@@ -4223,6 +4686,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4223
4686
|
command: node,
|
|
4224
4687
|
args: [
|
|
4225
4688
|
await installManagedRuntime(context, product, product.version),
|
|
4689
|
+
...pathArgs,
|
|
4226
4690
|
"mcp",
|
|
4227
4691
|
...serverArgs
|
|
4228
4692
|
],
|
|
@@ -4307,13 +4771,17 @@ function snippetFor(client, name, entry) {
|
|
|
4307
4771
|
* Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
|
|
4308
4772
|
* replaced.
|
|
4309
4773
|
*/
|
|
4310
|
-
function claimName(product, options, name, existing,
|
|
4774
|
+
function claimName(product, options, name, existing, _platform) {
|
|
4311
4775
|
const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
|
|
4312
4776
|
const foreign = taken.find((server) => !isProductServer(server, product));
|
|
4313
4777
|
if (foreign) {
|
|
4314
4778
|
const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
|
|
4315
4779
|
const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
|
|
4316
|
-
throw
|
|
4780
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, {
|
|
4781
|
+
binary: product.binary,
|
|
4782
|
+
words: installWords(product, options, other, []),
|
|
4783
|
+
say: (command) => `Register this one under another name: ${command}. --force does not replace a server this did not install.`
|
|
4784
|
+
});
|
|
4317
4785
|
}
|
|
4318
4786
|
if (taken.length > 0 && !options.force) {
|
|
4319
4787
|
for (const pin of PIN_OPTIONS) {
|
|
@@ -4321,14 +4789,22 @@ function claimName(product, options, name, existing, platform) {
|
|
|
4321
4789
|
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
4322
4790
|
if (!wanted || !served || served === wanted) continue;
|
|
4323
4791
|
const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
|
|
4324
|
-
throw
|
|
4792
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
|
|
4793
|
+
binary: product.binary,
|
|
4794
|
+
words: installWords(product, options, second === name ? `${name}-2` : second, []),
|
|
4795
|
+
say: (command) => `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${command}.`
|
|
4796
|
+
});
|
|
4325
4797
|
}
|
|
4326
4798
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
4327
4799
|
const again = {
|
|
4328
4800
|
...keepNarrowing(product, options, taken).options,
|
|
4329
4801
|
launcher: options.launcher ?? (npx ? "npx" : void 0)
|
|
4330
4802
|
};
|
|
4331
|
-
throw
|
|
4803
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
|
|
4804
|
+
binary: product.binary,
|
|
4805
|
+
words: installWords(product, again, name, ["--force"]),
|
|
4806
|
+
say: (command) => `Pass --force to replace it — that is how an upgrade reaches a client: ${command}.`
|
|
4807
|
+
});
|
|
4332
4808
|
}
|
|
4333
4809
|
return taken;
|
|
4334
4810
|
}
|
|
@@ -4364,20 +4840,17 @@ function keepNarrowing(product, options, previous) {
|
|
|
4364
4840
|
};
|
|
4365
4841
|
}
|
|
4366
4842
|
/**
|
|
4367
|
-
* The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach
|
|
4843
|
+
* The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach: its
|
|
4844
|
+
* words after the program.
|
|
4368
4845
|
*
|
|
4369
4846
|
* The hints used to be `mcp install --client <c> --force` whatever had been asked. Refused for `--name slack-acme
|
|
4370
4847
|
* --workspace acme/slack`, the hint registered a second server under the default name, pinned to nothing — every
|
|
4371
4848
|
* workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
|
|
4372
4849
|
* rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
|
|
4373
4850
|
* the pin and the narrowing, so they are repeated as they are.
|
|
4374
|
-
*
|
|
4375
|
-
* A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
|
|
4376
|
-
* command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
|
|
4377
4851
|
*/
|
|
4378
|
-
function
|
|
4852
|
+
function installWords(product, options, name, extra) {
|
|
4379
4853
|
const words = [
|
|
4380
|
-
product.binary,
|
|
4381
4854
|
"mcp",
|
|
4382
4855
|
"install",
|
|
4383
4856
|
"--client",
|
|
@@ -4387,7 +4860,24 @@ function installCommand$1(product, options, name, extra, platform) {
|
|
|
4387
4860
|
words.push(...product.serverArgs(options));
|
|
4388
4861
|
if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
|
|
4389
4862
|
words.push(...extra);
|
|
4390
|
-
return
|
|
4863
|
+
return words;
|
|
4864
|
+
}
|
|
4865
|
+
const REMEDIES = /* @__PURE__ */ new WeakMap();
|
|
4866
|
+
/** The remedy a refusal of `mcp install` carries, for the operation that called it to locate; undefined for any other. */
|
|
4867
|
+
function installRemedyOf(error) {
|
|
4868
|
+
return typeof error === "object" && error !== null ? REMEDIES.get(error) : void 0;
|
|
4869
|
+
}
|
|
4870
|
+
/**
|
|
4871
|
+
* A refusal whose remedy is another install: until the caller locates it, its hint says the words in prose — never a
|
|
4872
|
+
* command with a bare program.
|
|
4873
|
+
*/
|
|
4874
|
+
function refusedWithRemedy(code, message, remedy, options = {}) {
|
|
4875
|
+
const error = new CommsError(code, message, {
|
|
4876
|
+
hint: remedy.say(`its own \`mcp install\` with ${remedy.words.slice(2).join(" ")}`),
|
|
4877
|
+
...options.cause === void 0 ? {} : { cause: options.cause }
|
|
4878
|
+
});
|
|
4879
|
+
REMEDIES.set(error, remedy);
|
|
4880
|
+
return error;
|
|
4391
4881
|
}
|
|
4392
4882
|
/**
|
|
4393
4883
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
@@ -4430,16 +4920,16 @@ function capture$1(env, command, args) {
|
|
|
4430
4920
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4431
4921
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4432
4922
|
*/
|
|
4433
|
-
async function codexRegistration(env,
|
|
4434
|
-
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${
|
|
4923
|
+
async function codexRegistration(env, cliPath, name, path, platform) {
|
|
4924
|
+
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${clientCommandHint([
|
|
4435
4925
|
"codex",
|
|
4436
4926
|
"mcp",
|
|
4437
4927
|
"get",
|
|
4438
4928
|
name
|
|
4439
|
-
], platform)
|
|
4929
|
+
], platform, `codex's own \`mcp get\`, for the entry called "${name}"`)}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4440
4930
|
let answer;
|
|
4441
4931
|
try {
|
|
4442
|
-
answer = await capture$1(env,
|
|
4932
|
+
answer = await capture$1(env, cliPath, [
|
|
4443
4933
|
"mcp",
|
|
4444
4934
|
"get",
|
|
4445
4935
|
name,
|
|
@@ -4494,15 +4984,15 @@ async function backUp(context, client, name, previous, now) {
|
|
|
4494
4984
|
async function installTarget(context, options) {
|
|
4495
4985
|
const apply = options.apply ?? true;
|
|
4496
4986
|
const cliName = options.client === "claude-code" ? "claude" : options.client === "codex" ? "codex" : null;
|
|
4497
|
-
const
|
|
4987
|
+
const cliPath = cliName ? await findClientCli(cliName, context.env) : null;
|
|
4498
4988
|
const own = knownClientConfigs(context.env).find((file) => file.client === options.client)?.path;
|
|
4499
4989
|
const configPath = options.client === "claude-code" ? void 0 : own;
|
|
4500
4990
|
return {
|
|
4501
4991
|
cliName,
|
|
4502
|
-
|
|
4992
|
+
cliPath,
|
|
4503
4993
|
own,
|
|
4504
4994
|
configPath,
|
|
4505
|
-
writes: apply && (cliName ?
|
|
4995
|
+
writes: apply && (cliName ? cliPath !== null : configPath !== void 0)
|
|
4506
4996
|
};
|
|
4507
4997
|
}
|
|
4508
4998
|
/**
|
|
@@ -4534,14 +5024,14 @@ async function preflightInstall(context, product, options) {
|
|
|
4534
5024
|
const scan = await scanRegisteredServers(context.env);
|
|
4535
5025
|
const existing = scan.servers;
|
|
4536
5026
|
const target = await installTarget(context, options);
|
|
4537
|
-
const {
|
|
5027
|
+
const { cliPath, own, configPath, writes } = target;
|
|
4538
5028
|
if (writes) {
|
|
4539
5029
|
const blind = scan.unreadable.find((file) => file.path === own);
|
|
4540
5030
|
if (blind) throw new CommsError("CONFIG", `${blind.path} could not be read (${blind.reason}), so what is already registered there cannot be checked; nothing was written`, { hint: "Fix the file, or add the entry by hand: `--print` shows it without writing anything." });
|
|
4541
5031
|
}
|
|
4542
5032
|
let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
|
|
4543
|
-
if (writes &&
|
|
4544
|
-
const reported = await codexRegistration(context.env,
|
|
5033
|
+
if (writes && cliPath && options.client === "codex") {
|
|
5034
|
+
const reported = await codexRegistration(context.env, cliPath, name, configPath ?? "codex", context.platform);
|
|
4545
5035
|
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4546
5036
|
}
|
|
4547
5037
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
@@ -4586,7 +5076,7 @@ function plannedInstall(preflight) {
|
|
|
4586
5076
|
return {
|
|
4587
5077
|
writes: target.writes,
|
|
4588
5078
|
cliName: target.cliName,
|
|
4589
|
-
|
|
5079
|
+
cliPath: target.cliPath,
|
|
4590
5080
|
configPath: target.configPath ?? null,
|
|
4591
5081
|
own: target.own ?? null,
|
|
4592
5082
|
replaces: previous.map((server) => JSON.stringify([
|
|
@@ -4620,7 +5110,7 @@ function planDrift(product, client, name, planned, now) {
|
|
|
4620
5110
|
if (planned.writes !== now.writes) {
|
|
4621
5111
|
if (cli !== null) drift.push(now.writes ? `${cli} was not on PATH when this was planned, and is now, so it would register the server rather than print its entry` : `${cli} was on PATH when this was planned, and is not now, so it would only print the entry`);
|
|
4622
5112
|
else drift.push(now.writes ? "it would write the entry rather than print it" : "it would only print the entry");
|
|
4623
|
-
} else if (planned.
|
|
5113
|
+
} else if (planned.cliPath !== now.cliPath) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.cliPath}`);
|
|
4624
5114
|
if (planned.configPath !== now.configPath || planned.own !== now.own) drift.push(`${client}'s config is now ${now.own ?? now.configPath ?? "nowhere"}, not ${planned.own ?? planned.configPath ?? "nowhere"}`);
|
|
4625
5115
|
if (JSON.stringify(planned.replaces) !== JSON.stringify(now.replaces)) drift.push(`what is registered as "${name}" is not what it was planned to replace`);
|
|
4626
5116
|
if (JSON.stringify(planned.narrowing) !== JSON.stringify(now.narrowing)) {
|
|
@@ -4666,22 +5156,23 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4666
5156
|
...pinsOf(planned.narrowing)
|
|
4667
5157
|
};
|
|
4668
5158
|
const existing = scan.servers;
|
|
4669
|
-
const { cliName,
|
|
5159
|
+
const { cliName, cliPath, own, configPath, writes } = target;
|
|
4670
5160
|
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
|
|
4671
5161
|
if (kept.length > 0) {
|
|
4672
|
-
const
|
|
5162
|
+
const byHand = `delete "${name}" from ${configPath}`;
|
|
5163
|
+
const removal = options.client === "claude-code" ? clientCommandHint([
|
|
4673
5164
|
"claude",
|
|
4674
5165
|
"mcp",
|
|
4675
5166
|
"remove",
|
|
4676
5167
|
name,
|
|
4677
5168
|
"--scope",
|
|
4678
5169
|
"user"
|
|
4679
|
-
], context.platform)
|
|
5170
|
+
], context.platform, byHand) : options.client === "codex" ? clientCommandHint([
|
|
4680
5171
|
"codex",
|
|
4681
5172
|
"mcp",
|
|
4682
5173
|
"remove",
|
|
4683
5174
|
name
|
|
4684
|
-
], context.platform)
|
|
5175
|
+
], context.platform, byHand) : byHand;
|
|
4685
5176
|
warnings.push(`Kept ${kept.join(" ")} from the "${name}" entry this replaced, because this install did not say otherwise. To register it wider on purpose, remove that entry first (${removal}), then install without them.`);
|
|
4686
5177
|
}
|
|
4687
5178
|
if (!writes) {
|
|
@@ -4709,7 +5200,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4709
5200
|
let method = "printed";
|
|
4710
5201
|
let applied = false;
|
|
4711
5202
|
let notApplied;
|
|
4712
|
-
if (cliName &&
|
|
5203
|
+
if (cliName && cliPath && writes) {
|
|
4713
5204
|
const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
4714
5205
|
const args = options.client === "claude-code" ? [
|
|
4715
5206
|
"mcp",
|
|
@@ -4741,13 +5232,13 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4741
5232
|
name
|
|
4742
5233
|
];
|
|
4743
5234
|
try {
|
|
4744
|
-
await run(context.env,
|
|
5235
|
+
await run(context.env, cliPath, removal);
|
|
4745
5236
|
} catch (error) {
|
|
4746
5237
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4747
5238
|
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
4748
5239
|
}
|
|
4749
5240
|
try {
|
|
4750
|
-
await run(context.env,
|
|
5241
|
+
await run(context.env, cliPath, args);
|
|
4751
5242
|
} catch (error) {
|
|
4752
5243
|
const restore = options.client === "claude-code" ? [
|
|
4753
5244
|
"mcp",
|
|
@@ -4771,31 +5262,36 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4771
5262
|
];
|
|
4772
5263
|
let restored = true;
|
|
4773
5264
|
try {
|
|
4774
|
-
await run(context.env,
|
|
5265
|
+
await run(context.env, cliPath, restore);
|
|
4775
5266
|
} catch {
|
|
4776
5267
|
restored = false;
|
|
4777
5268
|
}
|
|
4778
|
-
throw new CommsError("CONFIG",
|
|
4779
|
-
hint:
|
|
5269
|
+
if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
|
|
5270
|
+
hint: "Check the client is not running, then try again.",
|
|
4780
5271
|
cause: error
|
|
4781
5272
|
});
|
|
5273
|
+
throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
5274
|
+
binary: product.binary,
|
|
5275
|
+
words: installWords(product, effective, name, []),
|
|
5276
|
+
say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
|
|
5277
|
+
}, { cause: error });
|
|
4782
5278
|
}
|
|
4783
5279
|
} else try {
|
|
4784
|
-
await run(context.env,
|
|
5280
|
+
await run(context.env, cliPath, args);
|
|
4785
5281
|
} catch (error) {
|
|
4786
5282
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4787
5283
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4788
|
-
hint: `Look at it with ${
|
|
5284
|
+
hint: `Look at it with ${clientCommandHint([
|
|
4789
5285
|
cliName,
|
|
4790
5286
|
"mcp",
|
|
4791
5287
|
"get",
|
|
4792
5288
|
name
|
|
4793
|
-
], context.platform)
|
|
5289
|
+
], 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([
|
|
4794
5290
|
cliName,
|
|
4795
5291
|
"mcp",
|
|
4796
5292
|
"remove",
|
|
4797
5293
|
name
|
|
4798
|
-
], context.platform)
|
|
5294
|
+
], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
|
|
4799
5295
|
cause: error
|
|
4800
5296
|
});
|
|
4801
5297
|
throw error;
|
|
@@ -5001,115 +5497,1033 @@ async function listManagedRuntimes(dataDir, packageName) {
|
|
|
5001
5497
|
* removes leaves the record with it. When the record cannot be read, nothing is removed;
|
|
5002
5498
|
* - never one a running process names. When the processes cannot be listed, nothing is removed at all.
|
|
5003
5499
|
*
|
|
5004
|
-
* What it cannot see is an entry in a file it does not read — a workspace `.vscode/mcp.json`, a config a client
|
|
5005
|
-
* was pointed at on its command line — put there by hand, or pasted from a `--print` that warned its record could
|
|
5006
|
-
* not be written. The documents say so rather than promise more.
|
|
5500
|
+
* What it cannot see is an entry in a file it does not read — a workspace `.vscode/mcp.json`, a config a client
|
|
5501
|
+
* was pointed at on its command line — put there by hand, or pasted from a `--print` that warned its record could
|
|
5502
|
+
* not be written. The documents say so rather than promise more.
|
|
5503
|
+
*
|
|
5504
|
+
* `only`, when given, is the most it may remove: the runtimes a person approved removing, from a dry run shown to
|
|
5505
|
+
* them. Anything else it would have removed is kept, and says why. Removal is approved as a list of paths, and a
|
|
5506
|
+
* runtime that became unused after that list was shown is not on it.
|
|
5507
|
+
*/
|
|
5508
|
+
async function pruneManagedRuntimes(context, product, options = {}) {
|
|
5509
|
+
const result = {
|
|
5510
|
+
runtimeDir: join(context.core.paths.dataDir, "runtime"),
|
|
5511
|
+
dryRun: options.dryRun === true,
|
|
5512
|
+
removed: [],
|
|
5513
|
+
kept: []
|
|
5514
|
+
};
|
|
5515
|
+
const candidates = [];
|
|
5516
|
+
for (const { path, version } of await listManagedRuntimes(context.core.paths.dataDir, product.packageName)) {
|
|
5517
|
+
const real = await realpath(path).catch(() => path);
|
|
5518
|
+
candidates.push({
|
|
5519
|
+
path,
|
|
5520
|
+
version,
|
|
5521
|
+
aliases: [.../* @__PURE__ */ new Set([path, real])]
|
|
5522
|
+
});
|
|
5523
|
+
}
|
|
5524
|
+
if (candidates.length === 0) return result;
|
|
5525
|
+
const refuse = (why) => {
|
|
5526
|
+
result.refused = why;
|
|
5527
|
+
result.kept = candidates.map(({ path, version }) => ({
|
|
5528
|
+
path,
|
|
5529
|
+
version,
|
|
5530
|
+
reason: "not checked"
|
|
5531
|
+
}));
|
|
5532
|
+
return result;
|
|
5533
|
+
};
|
|
5534
|
+
const processes = await (options.processes ?? runningCommandLines)();
|
|
5535
|
+
if (processes === null) return refuse("the running processes on this machine could not be listed, so none of these can be shown unused");
|
|
5536
|
+
const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
|
|
5537
|
+
const handedOut = await readHandedOut(ledger);
|
|
5538
|
+
if (handedOut === null) return refuse(`${ledger} could not be read, so none of these can be shown never to have been handed out`);
|
|
5539
|
+
const recorded = handedOut.flatMap((record) => record.config ? [{
|
|
5540
|
+
client: record.client,
|
|
5541
|
+
path: record.config
|
|
5542
|
+
}] : []);
|
|
5543
|
+
const scan = await scanRegisteredServers(context.env, process.platform, recorded);
|
|
5544
|
+
if (scan.unreadable.length > 0) return refuse(`${scan.unreadable.map((file) => `${file.path} (${file.reason})`).join("; ")} could not be read, so none of these can be shown unregistered`);
|
|
5545
|
+
const registered = scan.servers;
|
|
5546
|
+
const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
|
|
5547
|
+
const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
|
|
5548
|
+
const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
|
|
5549
|
+
for (const { path, version, aliases } of candidates) {
|
|
5550
|
+
const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
|
|
5551
|
+
const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
|
|
5552
|
+
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;
|
|
5553
|
+
if (reason) {
|
|
5554
|
+
result.kept.push({
|
|
5555
|
+
path,
|
|
5556
|
+
version,
|
|
5557
|
+
reason
|
|
5558
|
+
});
|
|
5559
|
+
continue;
|
|
5560
|
+
}
|
|
5561
|
+
if (options.only !== void 0 && !options.only.includes(path)) {
|
|
5562
|
+
result.kept.push({
|
|
5563
|
+
path,
|
|
5564
|
+
version,
|
|
5565
|
+
reason: "not in the removal that was approved; prune again to include it"
|
|
5566
|
+
});
|
|
5567
|
+
continue;
|
|
5568
|
+
}
|
|
5569
|
+
if (!result.dryRun) await rm(path, { recursive: true });
|
|
5570
|
+
result.removed.push({
|
|
5571
|
+
path,
|
|
5572
|
+
version
|
|
5573
|
+
});
|
|
5574
|
+
}
|
|
5575
|
+
if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
|
|
5576
|
+
const gone = result.removed.map((item) => item.path);
|
|
5577
|
+
const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
|
|
5578
|
+
if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
5579
|
+
}
|
|
5580
|
+
return result;
|
|
5581
|
+
}
|
|
5582
|
+
/** Every runtime the installer handed out, or null when the record is there and cannot be read. */
|
|
5583
|
+
async function readHandedOut(path) {
|
|
5584
|
+
let text;
|
|
5585
|
+
try {
|
|
5586
|
+
text = await readFile(path, "utf8");
|
|
5587
|
+
} catch (error) {
|
|
5588
|
+
return error.code === "ENOENT" ? [] : null;
|
|
5589
|
+
}
|
|
5590
|
+
const records = [];
|
|
5591
|
+
for (const line of text.split("\n")) {
|
|
5592
|
+
if (!line.trim()) continue;
|
|
5593
|
+
try {
|
|
5594
|
+
const record = JSON.parse(line);
|
|
5595
|
+
if (typeof record.runtime !== "string") return null;
|
|
5596
|
+
records.push({
|
|
5597
|
+
at: String(record.at ?? ""),
|
|
5598
|
+
client: String(record.client ?? ""),
|
|
5599
|
+
name: String(record.name ?? ""),
|
|
5600
|
+
runtime: record.runtime,
|
|
5601
|
+
...typeof record.config === "string" ? { config: record.config } : {},
|
|
5602
|
+
...record.applied === true ? { applied: true } : {}
|
|
5603
|
+
});
|
|
5604
|
+
} catch {
|
|
5605
|
+
return null;
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
return records;
|
|
5609
|
+
}
|
|
5610
|
+
/** A channel's registration facts, from its manifest. */
|
|
5611
|
+
function registrationFactsOf({ packageName, manifest }) {
|
|
5612
|
+
return {
|
|
5613
|
+
packageName,
|
|
5614
|
+
npxPackage: manifest.server.npxPackage,
|
|
5615
|
+
binary: manifest.binary,
|
|
5616
|
+
...manifest.server.bins === void 0 ? {} : { bins: manifest.server.bins },
|
|
5617
|
+
...manifest.server.entryFiles === void 0 ? {} : { entryFiles: manifest.server.entryFiles }
|
|
5618
|
+
};
|
|
5619
|
+
}
|
|
5620
|
+
/** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
|
|
5621
|
+
function launcherOf(server, facts, platform) {
|
|
5622
|
+
const fold = folding(platform);
|
|
5623
|
+
const parts = [server.command, ...server.args];
|
|
5624
|
+
if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
|
|
5625
|
+
if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
|
|
5626
|
+
const local = new RegExp("[/\\\\]packages[/\\\\][^/\\\\]+[/\\\\](?:src[/\\\\]cli\\.ts|dist[/\\\\]cli\\.mjs)$", platform === "win32" ? "i" : "");
|
|
5627
|
+
if (parts.some((part) => local.test(part))) return "local";
|
|
5628
|
+
return "other";
|
|
5629
|
+
}
|
|
5630
|
+
/** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
|
|
5631
|
+
function registrationVersion(server, facts, platform) {
|
|
5632
|
+
return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
|
|
5633
|
+
}
|
|
5634
|
+
PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
5635
|
+
/**
|
|
5636
|
+
* Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
|
|
5637
|
+
* out, and the package its command line launches is read again from what is left rather than taken from the entry.
|
|
5638
|
+
* A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
|
|
5639
|
+
*/
|
|
5640
|
+
function startsProduct(server, facts, platform) {
|
|
5641
|
+
const { words: args } = withoutPathOptions(server.args);
|
|
5642
|
+
const packageName = commandLinePackage(server.command, args);
|
|
5643
|
+
return isProductServer({
|
|
5644
|
+
command: server.command,
|
|
5645
|
+
args,
|
|
5646
|
+
...packageName === void 0 ? {} : { packageName }
|
|
5647
|
+
}, facts, platform);
|
|
5648
|
+
}
|
|
5649
|
+
/**
|
|
5650
|
+
* A command's name as Windows finds it — its last path segment without `.cmd`, `.exe`, `.bat` or `.ps1` — without case
|
|
5651
|
+
* on Windows (`platform`), and exactly elsewhere.
|
|
5652
|
+
*/
|
|
5653
|
+
function commandStem(command, platform) {
|
|
5654
|
+
return folding(platform)((command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
5655
|
+
}
|
|
5656
|
+
/** Whether npx runs the entry: its program is npx, or it names the product's package at a version, as npx does. */
|
|
5657
|
+
function isNpxRegistration(server, facts, platform) {
|
|
5658
|
+
return commandStem(server.command, platform) === "npx" || launcherOf(server, facts, platform) === "npx";
|
|
5659
|
+
}
|
|
5660
|
+
/**
|
|
5661
|
+
* The files an entry names as what it starts, as it writes them: every script among its words before `--` once the
|
|
5662
|
+
* path options are out (an interpreter's flags may name one too, so each is a candidate, not the answer), and its
|
|
5663
|
+
* program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
|
|
5664
|
+
*/
|
|
5665
|
+
function registrationFiles(server, facts, platform) {
|
|
5666
|
+
const { words } = withoutPathOptions(server.args);
|
|
5667
|
+
const sentinel = words.indexOf("--");
|
|
5668
|
+
const options = sentinel < 0 ? words : words.slice(0, sentinel);
|
|
5669
|
+
const fold = folding(platform);
|
|
5670
|
+
return {
|
|
5671
|
+
scripts: options.flatMap((word) => [word, ...word.startsWith("--") && word.includes("=") ? [word.slice(word.indexOf("=") + 1)] : []]).filter((word) => !word.startsWith("-") && /\.(?:[cm]?js|[cm]?ts)$/i.test(word)),
|
|
5672
|
+
command: [facts.binary, ...facts.bins ?? []].map(fold).includes(commandStem(server.command, platform)) ? server.command : null
|
|
5673
|
+
};
|
|
5674
|
+
}
|
|
5675
|
+
function folding(platform) {
|
|
5676
|
+
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
5677
|
+
}
|
|
5678
|
+
/** The package version, read from package.json at build time. */
|
|
5679
|
+
const VERSION = "0.13.1";
|
|
5680
|
+
/**
|
|
5681
|
+
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
5682
|
+
* to decide whether the release it last heard of is newer than the one running.
|
|
5683
|
+
*
|
|
5684
|
+
* Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
|
|
5685
|
+
* every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
|
|
5686
|
+
* all of it, so nothing that imported these from there changes.
|
|
5687
|
+
*/
|
|
5688
|
+
/**
|
|
5689
|
+
* A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
|
|
5690
|
+
*
|
|
5691
|
+
* Anything the registry answers is checked against this before it is used, because a version goes into a directory
|
|
5692
|
+
* name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
|
|
5693
|
+
* mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
|
|
5694
|
+
*/
|
|
5695
|
+
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
5696
|
+
function isVersion(value) {
|
|
5697
|
+
return typeof value === "string" && VERSION_PATTERN.test(value);
|
|
5698
|
+
}
|
|
5699
|
+
/** Two numeric strings with no leading zeros, compared as numbers of any size. */
|
|
5700
|
+
function compareNumeric(a, b) {
|
|
5701
|
+
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
|
|
5702
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
5703
|
+
}
|
|
5704
|
+
/**
|
|
5705
|
+
* Semver precedence: -1, 0 or 1, or null when either is not a version.
|
|
5706
|
+
*
|
|
5707
|
+
* Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
|
|
5708
|
+
* counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
|
|
5709
|
+
* few packages as it can.
|
|
5710
|
+
*/
|
|
5711
|
+
function compareVersions(a, b) {
|
|
5712
|
+
const left = VERSION_PATTERN.exec(a);
|
|
5713
|
+
const right = VERSION_PATTERN.exec(b);
|
|
5714
|
+
if (!left || !right) return null;
|
|
5715
|
+
for (const index of [
|
|
5716
|
+
1,
|
|
5717
|
+
2,
|
|
5718
|
+
3
|
|
5719
|
+
]) {
|
|
5720
|
+
const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
|
|
5721
|
+
if (order !== 0) return order < 0 ? -1 : 1;
|
|
5722
|
+
}
|
|
5723
|
+
const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
|
|
5724
|
+
const [ours, theirs] = [pre(left), pre(right)];
|
|
5725
|
+
if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
|
|
5726
|
+
for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
|
|
5727
|
+
const x = ours[index];
|
|
5728
|
+
const y = theirs[index];
|
|
5729
|
+
if (x === void 0) return -1;
|
|
5730
|
+
if (y === void 0) return 1;
|
|
5731
|
+
const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
5732
|
+
if (numericX && numericY) {
|
|
5733
|
+
const order = compareNumeric(x, y);
|
|
5734
|
+
if (order !== 0) return order < 0 ? -1 : 1;
|
|
5735
|
+
} else if (numericX !== numericY) return numericX ? -1 : 1;
|
|
5736
|
+
else if (x !== y) return x < y ? -1 : 1;
|
|
5737
|
+
}
|
|
5738
|
+
return 0;
|
|
5739
|
+
}
|
|
5740
|
+
/** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
|
|
5741
|
+
function isBehind(version, latest) {
|
|
5742
|
+
if (version === latest) return false;
|
|
5743
|
+
const order = compareVersions(version, latest);
|
|
5744
|
+
return order === null ? true : order < 0;
|
|
5745
|
+
}
|
|
5746
|
+
/**
|
|
5747
|
+
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
5748
|
+
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
5749
|
+
*/
|
|
5750
|
+
function isPrerelease(version) {
|
|
5751
|
+
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
5752
|
+
}
|
|
5753
|
+
/**
|
|
5754
|
+
* Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
|
|
5755
|
+
*
|
|
5756
|
+
* Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
|
|
5757
|
+
* all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
|
|
5758
|
+
* Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
|
|
5759
|
+
* is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
|
|
5760
|
+
*/
|
|
5761
|
+
function satisfiesRange(version, range) {
|
|
5762
|
+
const wanted = version.replace(/^v/, "");
|
|
5763
|
+
if (!isVersion(wanted)) return null;
|
|
5764
|
+
const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
|
|
5765
|
+
if (alternatives.some((comparators) => comparators.length === 0)) return null;
|
|
5766
|
+
let any = false;
|
|
5767
|
+
for (const comparators of alternatives) {
|
|
5768
|
+
let all = true;
|
|
5769
|
+
for (const comparator of comparators) {
|
|
5770
|
+
const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
|
|
5771
|
+
const bound = match?.[2] ?? "";
|
|
5772
|
+
if (!isVersion(bound)) return null;
|
|
5773
|
+
const order = compareVersions(wanted, bound);
|
|
5774
|
+
if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
|
|
5775
|
+
}
|
|
5776
|
+
if (all) any = true;
|
|
5777
|
+
}
|
|
5778
|
+
return any;
|
|
5779
|
+
}
|
|
5780
|
+
/**
|
|
5781
|
+
* The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
|
|
5782
|
+
*
|
|
5783
|
+
* A bare `agent-gmail approve …` is not runnable for most of the people it is printed for: a package run by npx, by a
|
|
5784
|
+
* managed runtime or from a checkout puts nothing on their PATH. So every such command names this process's Node and
|
|
5785
|
+
* a checked file of the product it runs, with the suite directories it uses pinned as options — and when no file can
|
|
5786
|
+
* be checked, there is no command at all, only a sentence saying what is missing. The caller says which package it is
|
|
5787
|
+
* and what to run; it never supplies the program or quotes the line.
|
|
5788
|
+
*
|
|
5789
|
+
* Three directions. A product's own CLI is found from the product itself, walking from one of its own modules to its
|
|
5790
|
+
* manifest: no registration is ever asked. A channel's handoff to core is found the way the channel itself finds core,
|
|
5791
|
+
* through its installed dependency, and only the same release. Another product's command is found among the servers
|
|
5792
|
+
* registered with the person's MCP clients, which the caller scanned and passes in: only a file-backed registration of
|
|
5793
|
+
* exactly the printing package's version, checked on disk. Nothing a registration names is run, or asked anything —
|
|
5794
|
+
* not even its interpreter for a version: no installer wrote down where a scanned entry came from, so none is trusted
|
|
5795
|
+
* to run (D1). This module never scans, spawns or probes.
|
|
5796
|
+
*
|
|
5797
|
+
* What is checked is checked when the command is printed, not for ever (D6): the file it names is real, readable and
|
|
5798
|
+
* inside its package then, and a later change to that installation is a later fact.
|
|
5799
|
+
*/
|
|
5800
|
+
/** The gate a constructor call has to pass: only this module holds it. */
|
|
5801
|
+
const GATE = Symbol("locateCliCommand");
|
|
5802
|
+
/**
|
|
5803
|
+
* A command that runs one of this suite's own CLIs, made only by `locateCliCommand`. Rendered as every printed command
|
|
5804
|
+
* is — a line to paste, or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`,
|
|
5805
|
+
* `commandText`).
|
|
5806
|
+
*
|
|
5807
|
+
* A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
|
|
5808
|
+
* a spread copy of one with other words in it, as it would be with a symbol-keyed brand. Only the type is exported;
|
|
5809
|
+
* the class and its gate are not.
|
|
5810
|
+
*/
|
|
5811
|
+
var PrintedCommand = class {
|
|
5812
|
+
#entry;
|
|
5813
|
+
/** The words, as the program is to receive them: this Node, its flags, the entry, then the CLI's own words. */
|
|
5814
|
+
words;
|
|
5815
|
+
/** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
|
|
5816
|
+
line;
|
|
5817
|
+
platform;
|
|
5818
|
+
constructor(gate, words, platform, entry) {
|
|
5819
|
+
if (gate !== GATE) throw new TypeError("a printed command is made by locateCliCommand, and only there");
|
|
5820
|
+
this.#entry = entry;
|
|
5821
|
+
this.words = Object.freeze([...words]);
|
|
5822
|
+
this.line = quoteCommand(this.words, platform).line;
|
|
5823
|
+
this.platform = platform;
|
|
5824
|
+
Object.freeze(this);
|
|
5825
|
+
}
|
|
5826
|
+
/**
|
|
5827
|
+
* The real path of the file it runs, as it was checked when the command was located: a fact about then (D6). The
|
|
5828
|
+
* command names this path and nothing else, so what runs is whatever is there when the person runs it.
|
|
5829
|
+
*/
|
|
5830
|
+
get entry() {
|
|
5831
|
+
return this.#entry;
|
|
5832
|
+
}
|
|
5833
|
+
/**
|
|
5834
|
+
* As JSON — a result's field written out by `--json`, or returned to an MCP client — it is the text it always was:
|
|
5835
|
+
* its line, or its words as JSON with what to do when no Windows line is safe (`commandText`). Rendered where it
|
|
5836
|
+
* leaves the process, so a field can hold the command itself and the wire stays the same.
|
|
5837
|
+
*/
|
|
5838
|
+
toJSON() {
|
|
5839
|
+
return quotedText(this);
|
|
5840
|
+
}
|
|
5841
|
+
/** Never interpolated: a sentence gives a command through `handoffSentence`, a value through `handoffText`. */
|
|
5842
|
+
[Symbol.toPrimitive]() {
|
|
5843
|
+
throw new TypeError("a printed command is rendered with handoffText or handoffSentence, never interpolated");
|
|
5844
|
+
}
|
|
5845
|
+
};
|
|
5846
|
+
const STRIP_TYPES = "--experimental-strip-types";
|
|
5847
|
+
const TRANSFORM_TYPES = "--experimental-transform-types";
|
|
5848
|
+
/** The suite's packages, by name, and its channel packages, which reach core through their own dependency on it. */
|
|
5849
|
+
const SUITE_PACKAGES = new Set(CHANNEL_SNAPSHOT.map(({ packageName }) => packageName));
|
|
5850
|
+
const CHANNEL_PACKAGES$1 = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
|
|
5851
|
+
/**
|
|
5852
|
+
* Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
|
|
5853
|
+
*
|
|
5854
|
+
* The program is always this process's Node (`process.execPath`), which must satisfy the target's `engines.node`; no
|
|
5855
|
+
* interpreter named anywhere else is run, or asked its version. A TypeScript entry gets `--experimental-strip-types`,
|
|
5856
|
+
* which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
|
|
5857
|
+
* no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
|
|
5858
|
+
* CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
|
|
5859
|
+
* before any `--`.
|
|
5860
|
+
*
|
|
5861
|
+
* Synchronous, so a hint or a renderer can call it where it builds its sentence; for another product the caller scans
|
|
5862
|
+
* the registrations first and passes them in (`request.registrations`).
|
|
5863
|
+
*/
|
|
5864
|
+
function locateCliCommand(request, runtime = {
|
|
5865
|
+
version: process.version,
|
|
5866
|
+
execArgv: process.execArgv
|
|
5867
|
+
}) {
|
|
5868
|
+
const { caller, target } = request;
|
|
5869
|
+
const [first] = request.words;
|
|
5870
|
+
if (first !== void 0 && (isAbsolute(first) || suiteCommandOf(first) !== null)) throw new TypeError(`the words to run start after the program: ${JSON.stringify(first)} is one`);
|
|
5871
|
+
const platform = request.platform ?? process.platform;
|
|
5872
|
+
let version = VERSION;
|
|
5873
|
+
const fail = (reason, detail, extra = {}) => notLocated(target, version, reason, detail, runtime.version, extra);
|
|
5874
|
+
let callerFile;
|
|
5875
|
+
try {
|
|
5876
|
+
callerFile = fileURLToPath(caller.url);
|
|
5877
|
+
} catch {
|
|
5878
|
+
return fail("caller", `${caller.url} is not a module on this machine`);
|
|
5879
|
+
}
|
|
5880
|
+
const callerSource = callerFile.endsWith(".ts");
|
|
5881
|
+
const nearest = nearestPackage(dirname(callerFile));
|
|
5882
|
+
const callerManifest = nearest?.manifest ?? null;
|
|
5883
|
+
if (nearest === null || callerManifest === null || callerManifest.name !== caller.packageName) {
|
|
5884
|
+
const name = typeof callerManifest?.name === "string" ? callerManifest.name : "no named package";
|
|
5885
|
+
return fail("manifest", `the package ${callerFile} belongs to is ${name}, not ${caller.packageName}`);
|
|
5886
|
+
}
|
|
5887
|
+
const callerVersion = typeof callerManifest.version === "string" ? callerManifest.version : null;
|
|
5888
|
+
if (callerVersion !== null) version = callerVersion;
|
|
5889
|
+
let callerRoot;
|
|
5890
|
+
try {
|
|
5891
|
+
callerRoot = realpathSync(nearest.root);
|
|
5892
|
+
} catch {
|
|
5893
|
+
return fail("caller", `${nearest.root} cannot be resolved`);
|
|
5894
|
+
}
|
|
5895
|
+
if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
|
|
5896
|
+
const pins = {};
|
|
5897
|
+
for (const key of request.uses) pins[key] = request.paths[key];
|
|
5898
|
+
let words;
|
|
5899
|
+
try {
|
|
5900
|
+
words = normalizePathOptionWords(request.words, pins, 0);
|
|
5901
|
+
} catch (error) {
|
|
5902
|
+
return fail("arguments", error instanceof Error ? error.message : String(error));
|
|
5903
|
+
}
|
|
5904
|
+
const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
|
|
5905
|
+
const located = (built, found, direction) => Object.freeze({
|
|
5906
|
+
ok: true,
|
|
5907
|
+
command: built.command,
|
|
5908
|
+
basis: Object.freeze({
|
|
5909
|
+
callerSource,
|
|
5910
|
+
callerRoot,
|
|
5911
|
+
callerVersion,
|
|
5912
|
+
targetRoot: found.root,
|
|
5913
|
+
targetVersion: typeof found.manifest.version === "string" ? found.manifest.version : null,
|
|
5914
|
+
callerWorkspace: null,
|
|
5915
|
+
targetWorkspace: null,
|
|
5916
|
+
entryKind: built.entryKind,
|
|
5917
|
+
...direction
|
|
5918
|
+
})
|
|
5919
|
+
});
|
|
5920
|
+
if (caller.packageName === target.packageName) {
|
|
5921
|
+
const found = {
|
|
5922
|
+
root: callerRoot,
|
|
5923
|
+
manifest: callerManifest,
|
|
5924
|
+
source: callerSource
|
|
5925
|
+
};
|
|
5926
|
+
const built = build(found);
|
|
5927
|
+
if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
|
|
5928
|
+
return located(built, found, {
|
|
5929
|
+
direction: "own",
|
|
5930
|
+
sameCheckout: true
|
|
5931
|
+
});
|
|
5932
|
+
}
|
|
5933
|
+
if (target.manifest.channel === "core" && CHANNEL_PACKAGES$1.has(caller.packageName)) {
|
|
5934
|
+
const installed = installedPackage(dirname(callerFile), target.packageName);
|
|
5935
|
+
if (installed === null) return fail("core", `${caller.packageName} has no ${target.packageName} installed where it can find one`);
|
|
5936
|
+
const manifest = readPackageManifest(installed);
|
|
5937
|
+
if (manifest === null || manifest.name !== target.packageName) return fail("manifest", `the package found as ${target.packageName}, at ${installed}, is not that package`);
|
|
5938
|
+
if (manifest.version !== version) {
|
|
5939
|
+
const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
|
|
5940
|
+
return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
|
|
5941
|
+
}
|
|
5942
|
+
const callerWorkspace = workspaceOf(callerRoot);
|
|
5943
|
+
const targetWorkspace = workspaceOf(installed);
|
|
5944
|
+
const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
|
|
5945
|
+
const found = {
|
|
5946
|
+
root: installed,
|
|
5947
|
+
manifest,
|
|
5948
|
+
source: callerSource && sameCheckout
|
|
5949
|
+
};
|
|
5950
|
+
const built = build(found);
|
|
5951
|
+
if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
|
|
5952
|
+
return located(built, found, {
|
|
5953
|
+
direction: "channel-to-core",
|
|
5954
|
+
callerWorkspace,
|
|
5955
|
+
targetWorkspace,
|
|
5956
|
+
sameCheckout
|
|
5957
|
+
});
|
|
5958
|
+
}
|
|
5959
|
+
if (!SUITE_PACKAGES.has(caller.packageName)) return fail("direction", `${caller.packageName} is not a package of this suite`);
|
|
5960
|
+
if (request.registrations === void 0) return fail("no-registrations", "nothing registered was given to look through for another product's command");
|
|
5961
|
+
const facts = registrationFactsOf(target);
|
|
5962
|
+
const notUsed = [];
|
|
5963
|
+
const usable = [];
|
|
5964
|
+
let engineRange;
|
|
5965
|
+
for (const server of request.registrations) {
|
|
5966
|
+
if (!startsProduct(server, facts, platform)) continue;
|
|
5967
|
+
const reject = (why) => notUsed.push(Object.freeze({
|
|
5968
|
+
client: server.client,
|
|
5969
|
+
path: server.path,
|
|
5970
|
+
name: server.name,
|
|
5971
|
+
why
|
|
5972
|
+
}));
|
|
5973
|
+
if (isNpxRegistration(server, facts, platform)) {
|
|
5974
|
+
reject("npx");
|
|
5975
|
+
continue;
|
|
5976
|
+
}
|
|
5977
|
+
if (withoutPathOptions(server.args).dangling) {
|
|
5978
|
+
reject("arguments");
|
|
5979
|
+
continue;
|
|
5980
|
+
}
|
|
5981
|
+
const launcher = launcherOf(server, facts, platform);
|
|
5982
|
+
const files = registrationFiles(server, facts, platform);
|
|
5983
|
+
if (![...files.scripts, ...files.command === null ? [] : [files.command]].some((file) => isAbsolute(file))) {
|
|
5984
|
+
reject("no-file");
|
|
5985
|
+
continue;
|
|
5986
|
+
}
|
|
5987
|
+
const found = registeredPackage(files, target, facts);
|
|
5988
|
+
if (found === null) {
|
|
5989
|
+
reject("no-package");
|
|
5990
|
+
continue;
|
|
5991
|
+
}
|
|
5992
|
+
const managedAt = launcher === "managed" ? registrationVersion(server, facts, platform) : version;
|
|
5993
|
+
if (found.manifest.version !== version || managedAt !== version) {
|
|
5994
|
+
reject("version");
|
|
5995
|
+
continue;
|
|
5996
|
+
}
|
|
5997
|
+
const built = build(found);
|
|
5998
|
+
if (!built.ok) {
|
|
5999
|
+
reject(built.reason);
|
|
6000
|
+
if (built.reason === "engine") engineRange ??= built.nodeRange;
|
|
6001
|
+
continue;
|
|
6002
|
+
}
|
|
6003
|
+
usable.push({
|
|
6004
|
+
server,
|
|
6005
|
+
launcher,
|
|
6006
|
+
built,
|
|
6007
|
+
found
|
|
6008
|
+
});
|
|
6009
|
+
}
|
|
6010
|
+
const rank = ({ server, launcher }) => [
|
|
6011
|
+
launcher === "managed" ? "0" : "1",
|
|
6012
|
+
server.client,
|
|
6013
|
+
server.path,
|
|
6014
|
+
server.name,
|
|
6015
|
+
server.command,
|
|
6016
|
+
...server.args
|
|
6017
|
+
];
|
|
6018
|
+
const [best] = usable.sort((a, b) => compareWords(rank(a), rank(b)));
|
|
6019
|
+
if (best === void 0) {
|
|
6020
|
+
if (engineRange !== void 0) return fail("engine", `it needs Node ${engineRange}, and this is Node ${runtime.version}`, {
|
|
6021
|
+
nodeRange: engineRange,
|
|
6022
|
+
registrations: notUsed
|
|
6023
|
+
});
|
|
6024
|
+
return fail("not-registered", notRegistered(notUsed, version), { registrations: notUsed });
|
|
6025
|
+
}
|
|
6026
|
+
const { client, path, name } = best.server;
|
|
6027
|
+
return located(best.built, best.found, {
|
|
6028
|
+
direction: "cross-product",
|
|
6029
|
+
sameCheckout: false,
|
|
6030
|
+
registration: Object.freeze({
|
|
6031
|
+
client,
|
|
6032
|
+
path,
|
|
6033
|
+
name,
|
|
6034
|
+
launcher: best.launcher
|
|
6035
|
+
})
|
|
6036
|
+
});
|
|
6037
|
+
}
|
|
6038
|
+
/**
|
|
6039
|
+
* Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
|
|
6040
|
+
* CLI or the manifest's bin for the command it declares — as a readable file inside its package.
|
|
6041
|
+
*/
|
|
6042
|
+
function buildCommand(target, found, words, runtime, callerSource, platform) {
|
|
6043
|
+
const engines = found.manifest.engines;
|
|
6044
|
+
const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
|
|
6045
|
+
if (typeof range !== "string") return {
|
|
6046
|
+
ok: false,
|
|
6047
|
+
reason: "engine",
|
|
6048
|
+
detail: `${target.packageName} at ${found.root} declares no Node range this can check`
|
|
6049
|
+
};
|
|
6050
|
+
if (satisfiesRange(runtime.version, range) !== true) return {
|
|
6051
|
+
ok: false,
|
|
6052
|
+
reason: "engine",
|
|
6053
|
+
detail: `it needs Node ${range}, and this is Node ${runtime.version}`,
|
|
6054
|
+
nodeRange: range
|
|
6055
|
+
};
|
|
6056
|
+
let entry;
|
|
6057
|
+
if (found.source) entry = join(found.root, "src", "cli.ts");
|
|
6058
|
+
else {
|
|
6059
|
+
const declared = found.manifest.agentcomms;
|
|
6060
|
+
const binary = typeof declared === "object" && declared !== null ? declared.binary : void 0;
|
|
6061
|
+
const bin = found.manifest.bin;
|
|
6062
|
+
const path = binary === target.manifest.binary && typeof bin === "object" && bin !== null && Object.hasOwn(bin, target.manifest.binary) ? bin[target.manifest.binary] : void 0;
|
|
6063
|
+
if (typeof path !== "string" || path.length === 0) return {
|
|
6064
|
+
ok: false,
|
|
6065
|
+
reason: "bin",
|
|
6066
|
+
detail: `the bin of ${target.packageName} at ${found.root} does not name the command it declares`
|
|
6067
|
+
};
|
|
6068
|
+
entry = resolve(found.root, path);
|
|
6069
|
+
}
|
|
6070
|
+
const checked = checkedEntry(found.root, entry);
|
|
6071
|
+
if (checked.ok === false) return {
|
|
6072
|
+
ok: false,
|
|
6073
|
+
reason: "entry",
|
|
6074
|
+
detail: checked.why
|
|
6075
|
+
};
|
|
6076
|
+
const flags = /\.[cm]?ts$/.test(checked.path) ? [STRIP_TYPES, ...callerSource && runtime.execArgv.includes(TRANSFORM_TYPES) ? [TRANSFORM_TYPES] : []] : [];
|
|
6077
|
+
return {
|
|
6078
|
+
ok: true,
|
|
6079
|
+
command: new PrintedCommand(GATE, [
|
|
6080
|
+
process.execPath,
|
|
6081
|
+
...flags,
|
|
6082
|
+
checked.path,
|
|
6083
|
+
...words
|
|
6084
|
+
], platform, checked.path),
|
|
6085
|
+
entryKind: found.source ? "source" : "bin"
|
|
6086
|
+
};
|
|
6087
|
+
}
|
|
6088
|
+
/**
|
|
6089
|
+
* The target's package a registration's files lead to, or null: a script it starts, real or as written when it is
|
|
6090
|
+
* gone, inside the package (or inside the product's own server wrapper, which depends on it); or, for the product's
|
|
6091
|
+
* own command, the package that command links into or that npm installed beside it.
|
|
6092
|
+
*/
|
|
6093
|
+
function registeredPackage(files, target, facts) {
|
|
6094
|
+
for (const script of files.scripts) {
|
|
6095
|
+
if (!isAbsolute(script)) continue;
|
|
6096
|
+
const found = packageAround(script, target, facts);
|
|
6097
|
+
if (found !== null) {
|
|
6098
|
+
const source = join(found.root, "src", "cli.ts");
|
|
6099
|
+
const real = realpathOfExisting(script);
|
|
6100
|
+
return {
|
|
6101
|
+
...found,
|
|
6102
|
+
source: real !== null && sameFile(real, source)
|
|
6103
|
+
};
|
|
6104
|
+
}
|
|
6105
|
+
}
|
|
6106
|
+
const { command } = files;
|
|
6107
|
+
if (command === null || !isAbsolute(command)) return null;
|
|
6108
|
+
const linked = packageAround(command, target, facts);
|
|
6109
|
+
if (linked !== null) return {
|
|
6110
|
+
...linked,
|
|
6111
|
+
source: false
|
|
6112
|
+
};
|
|
6113
|
+
const dir = dirname(command);
|
|
6114
|
+
for (const candidate of [join(dir, "node_modules"), join(dir, "..", "lib", "node_modules")]) {
|
|
6115
|
+
let root;
|
|
6116
|
+
try {
|
|
6117
|
+
root = realpathSync(join(candidate, ...target.packageName.split("/")));
|
|
6118
|
+
} catch {
|
|
6119
|
+
continue;
|
|
6120
|
+
}
|
|
6121
|
+
const manifest = readPackageManifest(root);
|
|
6122
|
+
if (manifest?.name === target.packageName) return {
|
|
6123
|
+
root,
|
|
6124
|
+
manifest,
|
|
6125
|
+
source: false
|
|
6126
|
+
};
|
|
6127
|
+
}
|
|
6128
|
+
return null;
|
|
6129
|
+
}
|
|
6130
|
+
/** The target's package that `file` is in — directly, or through the product's server wrapper, which depends on it. */
|
|
6131
|
+
function packageAround(file, target, facts) {
|
|
6132
|
+
const real = realpathOfExisting(file);
|
|
6133
|
+
if (real === null) return null;
|
|
6134
|
+
const nearest = nearestPackage(dirname(real));
|
|
6135
|
+
if (nearest === null || nearest.manifest === null) return null;
|
|
6136
|
+
let root;
|
|
6137
|
+
try {
|
|
6138
|
+
root = realpathSync(nearest.root);
|
|
6139
|
+
} catch {
|
|
6140
|
+
return null;
|
|
6141
|
+
}
|
|
6142
|
+
if (nearest.manifest.name === target.packageName) return {
|
|
6143
|
+
root,
|
|
6144
|
+
manifest: nearest.manifest
|
|
6145
|
+
};
|
|
6146
|
+
if (facts.npxPackage === target.packageName || nearest.manifest.name !== facts.npxPackage) return null;
|
|
6147
|
+
const installed = installedPackage(root, target.packageName);
|
|
6148
|
+
const manifest = installed === null ? null : readPackageManifest(installed);
|
|
6149
|
+
return installed !== null && manifest?.name === target.packageName ? {
|
|
6150
|
+
root: installed,
|
|
6151
|
+
manifest
|
|
6152
|
+
} : null;
|
|
6153
|
+
}
|
|
6154
|
+
function sameFile(a, b) {
|
|
6155
|
+
try {
|
|
6156
|
+
return realpathSync(a) === realpathSync(b);
|
|
6157
|
+
} catch {
|
|
6158
|
+
return false;
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
/** Two lists of words in order: the first that differs decides, and a list that runs out first comes first. */
|
|
6162
|
+
function compareWords(a, b) {
|
|
6163
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
6164
|
+
const [x, y] = [a[index], b[index]];
|
|
6165
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
6166
|
+
}
|
|
6167
|
+
return a.length - b.length;
|
|
6168
|
+
}
|
|
6169
|
+
/** Why no registration of another product could be used, in a clause: how many were looked at, and what each was. */
|
|
6170
|
+
function notRegistered(notUsed, version) {
|
|
6171
|
+
if (notUsed.length === 0) return "no MCP client on this machine has it registered";
|
|
6172
|
+
const said = {
|
|
6173
|
+
npx: "run by npx, which is never used for this",
|
|
6174
|
+
arguments: "with a path option that has no value",
|
|
6175
|
+
"no-file": "not started from a file named by its full path",
|
|
6176
|
+
"no-package": "whose package cannot be found or read",
|
|
6177
|
+
version: "of another or an unknown version",
|
|
6178
|
+
engine: "whose Node range this Node is outside, or which declares none",
|
|
6179
|
+
bin: "whose package does not name its command",
|
|
6180
|
+
entry: "whose command file is missing, unreadable or outside its package"
|
|
6181
|
+
};
|
|
6182
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6183
|
+
for (const { why } of notUsed) counts.set(why, (counts.get(why) ?? 0) + 1);
|
|
6184
|
+
const parts = [...counts].map(([why, count]) => `${count} ${said[why]}`);
|
|
6185
|
+
return `none of ${notUsed.length === 1 ? "the 1 registration" : `the ${notUsed.length} registrations`} of it is ${version} in a file this can check (${parts.join("; ")})`;
|
|
6186
|
+
}
|
|
6187
|
+
function notLocated(target, version, reason, detail, nodeVersion, extra) {
|
|
6188
|
+
const named = `${target.manifest.label} ${version} (${target.packageName})`;
|
|
6189
|
+
const { nodeRange, registrations } = extra;
|
|
6190
|
+
const message = nodeRange === void 0 ? `${named} is not locatable here: ${detail}. Install or update it through your usual route, then try again.` : `${named} needs Node ${nodeRange}, and this is Node ${nodeVersion}, so there is no command to run it with here. Run it again under a Node in that range.`;
|
|
6191
|
+
const notLocated = {
|
|
6192
|
+
ok: false,
|
|
6193
|
+
reason,
|
|
6194
|
+
product: target.manifest.label,
|
|
6195
|
+
package: target.packageName,
|
|
6196
|
+
version,
|
|
6197
|
+
...nodeRange === void 0 ? {} : { nodeRange },
|
|
6198
|
+
...registrations === void 0 ? {} : { registrations: Object.freeze([...registrations]) },
|
|
6199
|
+
detail,
|
|
6200
|
+
message
|
|
6201
|
+
};
|
|
6202
|
+
Object.defineProperties(notLocated, {
|
|
6203
|
+
toJSON: { value: () => message },
|
|
6204
|
+
[Symbol.toPrimitive]: { value: () => {
|
|
6205
|
+
throw new TypeError("a missing command is said with handoffText or handoffSentence, never interpolated");
|
|
6206
|
+
} }
|
|
6207
|
+
});
|
|
6208
|
+
return Object.freeze(notLocated);
|
|
6209
|
+
}
|
|
6210
|
+
/**
|
|
6211
|
+
* The entry's real path, when it is a readable regular file inside its package's real root by whole segments — as
|
|
6212
|
+
* written and after links are resolved — or why not.
|
|
6213
|
+
*
|
|
6214
|
+
* Checked now, for the command printed now (D6). This does not make the path durable: an npx cache evicted, a global
|
|
6215
|
+
* install upgraded in place or a file replaced after this is what the command meets when it runs — a missing file
|
|
6216
|
+
* fails with Node's own error naming it, a replaced one runs as it then is. Nothing here, or in the command, looks
|
|
6217
|
+
* anywhere else for the product, PATH included, and a later locate checks the path again.
|
|
6218
|
+
*/
|
|
6219
|
+
function checkedEntry(root, entry) {
|
|
6220
|
+
if (!isWithin(root, entry)) return {
|
|
6221
|
+
ok: false,
|
|
6222
|
+
why: `${entry} is outside its package, ${root}`
|
|
6223
|
+
};
|
|
6224
|
+
let real;
|
|
6225
|
+
try {
|
|
6226
|
+
real = realpathSync(entry);
|
|
6227
|
+
} catch {
|
|
6228
|
+
return {
|
|
6229
|
+
ok: false,
|
|
6230
|
+
why: `${entry} is not there`
|
|
6231
|
+
};
|
|
6232
|
+
}
|
|
6233
|
+
if (!isWithin(root, real)) return {
|
|
6234
|
+
ok: false,
|
|
6235
|
+
why: `${entry} leads to ${real}, outside its package, ${root}`
|
|
6236
|
+
};
|
|
6237
|
+
try {
|
|
6238
|
+
if (!statSync(real).isFile()) return {
|
|
6239
|
+
ok: false,
|
|
6240
|
+
why: `${entry} is not a file`
|
|
6241
|
+
};
|
|
6242
|
+
accessSync(real, constants$1.R_OK);
|
|
6243
|
+
} catch {
|
|
6244
|
+
return {
|
|
6245
|
+
ok: false,
|
|
6246
|
+
why: `${entry} cannot be read`
|
|
6247
|
+
};
|
|
6248
|
+
}
|
|
6249
|
+
return {
|
|
6250
|
+
ok: true,
|
|
6251
|
+
path: real
|
|
6252
|
+
};
|
|
6253
|
+
}
|
|
6254
|
+
/**
|
|
6255
|
+
* The real root of the package `name` installed for a module in `start`, found as Node's own resolution finds one: in
|
|
6256
|
+
* `node_modules` of each directory from there up. Not resolved through its `exports` — core's point at a build, and
|
|
6257
|
+
* which entry to run is decided here — and never from a global folder, which `import` does not read either.
|
|
6258
|
+
*/
|
|
6259
|
+
function installedPackage(start, name) {
|
|
6260
|
+
let dir = resolve(start);
|
|
6261
|
+
for (;;) {
|
|
6262
|
+
if (basename(dir) !== "node_modules") {
|
|
6263
|
+
const candidate = join(dir, "node_modules", ...name.split("/"));
|
|
6264
|
+
try {
|
|
6265
|
+
if (statSync(candidate).isDirectory()) return realpathSync(candidate);
|
|
6266
|
+
} catch {}
|
|
6267
|
+
}
|
|
6268
|
+
const parent = dirname(dir);
|
|
6269
|
+
if (parent === dir) return null;
|
|
6270
|
+
dir = parent;
|
|
6271
|
+
}
|
|
6272
|
+
}
|
|
6273
|
+
/**
|
|
6274
|
+
* The real repository checkout whose workspace lists `root` as one of its packages, or null: the nearest
|
|
6275
|
+
* `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
|
|
6276
|
+
* installed copy in its `node_modules` — is not a workspace package of it.
|
|
6277
|
+
*/
|
|
6278
|
+
function workspaceOf(root) {
|
|
6279
|
+
let dir = dirname(root);
|
|
6280
|
+
for (;;) {
|
|
6281
|
+
const globs = workspaceGlobs(dir);
|
|
6282
|
+
if (globs !== null) return inWorkspace(relative(dir, root).split(sep), globs) ? dir : null;
|
|
6283
|
+
const parent = dirname(dir);
|
|
6284
|
+
if (parent === dir) return null;
|
|
6285
|
+
dir = parent;
|
|
6286
|
+
}
|
|
6287
|
+
}
|
|
6288
|
+
/** The `packages` globs of `dir/pnpm-workspace.yaml`, or null when there is no such file. */
|
|
6289
|
+
function workspaceGlobs(dir) {
|
|
6290
|
+
let text;
|
|
6291
|
+
try {
|
|
6292
|
+
text = readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8");
|
|
6293
|
+
} catch (error) {
|
|
6294
|
+
const code = error.code;
|
|
6295
|
+
return code === "ENOENT" || code === "ENOTDIR" ? null : [];
|
|
6296
|
+
}
|
|
6297
|
+
const unquote = (glob) => glob.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
6298
|
+
const lines = text.split(/\r?\n/);
|
|
6299
|
+
const at = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
6300
|
+
if (at < 0) return [];
|
|
6301
|
+
const inline = /^packages\s*:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[at]);
|
|
6302
|
+
if (inline) return (inline[1] ?? "").split(",").map(unquote).filter(Boolean);
|
|
6303
|
+
const globs = [];
|
|
6304
|
+
for (const line of lines.slice(at + 1)) {
|
|
6305
|
+
if (/^\s*(?:#.*)?$/.test(line)) continue;
|
|
6306
|
+
const item = /^\s+-\s*(.*?)\s*(?:#.*)?$/.exec(line);
|
|
6307
|
+
if (!item) break;
|
|
6308
|
+
globs.push(unquote(item[1] ?? ""));
|
|
6309
|
+
}
|
|
6310
|
+
return globs;
|
|
6311
|
+
}
|
|
6312
|
+
/** Whether a path, as segments from the workspace root, is one of its packages: some glob takes it and no `!` one. */
|
|
6313
|
+
function inWorkspace(segments, globs) {
|
|
6314
|
+
const matches = (glob) => segmentsMatch(segments, glob.replace(/^\.\//, "").replace(/\/+$/, "").split("/").filter((part) => part !== "."));
|
|
6315
|
+
const included = globs.filter((glob) => !glob.startsWith("!"));
|
|
6316
|
+
const excluded = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
|
|
6317
|
+
return included.some(matches) && !excluded.some(matches);
|
|
6318
|
+
}
|
|
6319
|
+
function segmentsMatch(path, pattern) {
|
|
6320
|
+
const [head, ...rest] = pattern;
|
|
6321
|
+
if (head === void 0) return path.length === 0;
|
|
6322
|
+
if (head === "**") return segmentsMatch(path, rest) || path.length > 0 && segmentsMatch(path.slice(1), pattern);
|
|
6323
|
+
const [segment] = path;
|
|
6324
|
+
if (segment === void 0) return false;
|
|
6325
|
+
return new RegExp(`^${head.split("*").map(escapeRegExp$1).join("[^/]*")}$`).test(segment) && segmentsMatch(path.slice(1), rest);
|
|
6326
|
+
}
|
|
6327
|
+
function escapeRegExp$1(text) {
|
|
6328
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6329
|
+
}
|
|
6330
|
+
/**
|
|
6331
|
+
* The commands a package tells a person to run, made once per printing process (design 2026-10-04, D1 and D5;
|
|
6332
|
+
* CUE-403). How a channel migrates a handoff to these is in CONTRIBUTING.md, "Telling a person what to run".
|
|
6333
|
+
*
|
|
6334
|
+
* A package makes them from where it is — a module of its own and its package name, the caller — with the folders and
|
|
6335
|
+
* shell of the process printing: `openCore({ caller })` puts them on `core.handoffs`. Each method takes the CLI's own
|
|
6336
|
+
* words, never the program, and returns a `Handoff`: a located `PrintedCommand`, or why there is none here. Nothing
|
|
6337
|
+
* here quotes a line by hand, names a bare binary, or guesses an installation.
|
|
6338
|
+
*/
|
|
6339
|
+
/** The suite folders every CLI command opens: what a command is pinned to unless it says otherwise. */
|
|
6340
|
+
const HANDOFF_FOLDERS = Object.freeze([
|
|
6341
|
+
"configDir",
|
|
6342
|
+
"stateDir",
|
|
6343
|
+
"dataDir",
|
|
6344
|
+
"secretsDir"
|
|
6345
|
+
]);
|
|
6346
|
+
Object.freeze({
|
|
6347
|
+
url: import.meta.url,
|
|
6348
|
+
packageName: "@agentcomms/core"
|
|
6349
|
+
});
|
|
6350
|
+
/** A channel of this release, by its word; a programming error for a word that is none. */
|
|
6351
|
+
function entryOf$1(channel) {
|
|
6352
|
+
const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
|
|
6353
|
+
if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
|
|
6354
|
+
return entry;
|
|
6355
|
+
}
|
|
6356
|
+
/** The folders a command is pinned to. */
|
|
6357
|
+
function usesOf(use) {
|
|
6358
|
+
return [...use?.uses ?? HANDOFF_FOLDERS, ...use?.downloads ? ["downloadsDir"] : []];
|
|
6359
|
+
}
|
|
6360
|
+
/** The commands a package prints, located from `caller` (CONTRIBUTING.md, "Telling a person what to run"). */
|
|
6361
|
+
function cliHandoffs(options) {
|
|
6362
|
+
const { caller, paths, registrations } = options;
|
|
6363
|
+
const platform = options.platform ?? process.platform;
|
|
6364
|
+
const own = CHANNEL_SNAPSHOT.find((entry) => entry.packageName === caller.packageName);
|
|
6365
|
+
if (own === void 0) throw new TypeError(`${caller.packageName} is not a package of this suite`);
|
|
6366
|
+
const locate = (target, words, use) => {
|
|
6367
|
+
const result = locateCliCommand({
|
|
6368
|
+
caller,
|
|
6369
|
+
target,
|
|
6370
|
+
words,
|
|
6371
|
+
uses: usesOf(use),
|
|
6372
|
+
paths,
|
|
6373
|
+
platform,
|
|
6374
|
+
...registrations === void 0 ? {} : { registrations }
|
|
6375
|
+
}, ...options.runtime === void 0 ? [] : [options.runtime]);
|
|
6376
|
+
return result.ok ? result.command : result;
|
|
6377
|
+
};
|
|
6378
|
+
const handoffs = Object.freeze({
|
|
6379
|
+
caller,
|
|
6380
|
+
platform,
|
|
6381
|
+
own: (words, use) => locate(own, words, use),
|
|
6382
|
+
core: (words, use) => locate(entryOf$1("core"), words, use),
|
|
6383
|
+
of: (channel, words, use) => locate(entryOf$1(channel), words, use),
|
|
6384
|
+
on: (other) => other === platform ? handoffs : cliHandoffs({
|
|
6385
|
+
...options,
|
|
6386
|
+
platform: other
|
|
6387
|
+
}),
|
|
6388
|
+
registered: async () => cliHandoffs({
|
|
6389
|
+
...options,
|
|
6390
|
+
registrations: (await scanRegisteredServers(options.env ?? process.env, platform)).servers
|
|
6391
|
+
}),
|
|
6392
|
+
withRegistrations: (servers) => cliHandoffs({
|
|
6393
|
+
...options,
|
|
6394
|
+
registrations: servers
|
|
6395
|
+
})
|
|
6396
|
+
});
|
|
6397
|
+
return handoffs;
|
|
6398
|
+
}
|
|
6399
|
+
/**
|
|
6400
|
+
* `core.handoffs` — quoted for `platform` when one is given — for anything that prints a command. A programming error
|
|
6401
|
+
* when core was opened without its caller: the package has to give it one (`openCore({ caller })`), and there is no
|
|
6402
|
+
* bare name to print instead.
|
|
6403
|
+
*/
|
|
6404
|
+
function requireHandoffs(core, platform) {
|
|
6405
|
+
const handoffs = requiredHandoffs(core.handoffs);
|
|
6406
|
+
return platform === void 0 ? handoffs : handoffs.on(platform);
|
|
6407
|
+
}
|
|
6408
|
+
const MAP = {
|
|
6409
|
+
inbox: "inboxes",
|
|
6410
|
+
account: "accounts"
|
|
6411
|
+
};
|
|
6412
|
+
/**
|
|
6413
|
+
* An own property only.
|
|
6414
|
+
*
|
|
6415
|
+
* A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
|
|
6416
|
+
* of an inbox called `constructor`, which version 1 allows, found it.
|
|
6417
|
+
*/
|
|
6418
|
+
function own$1(map, key) {
|
|
6419
|
+
return Object.hasOwn(map, key) ? map[key] : void 0;
|
|
6420
|
+
}
|
|
6421
|
+
function resolveName(config, kind, name, notFound) {
|
|
6422
|
+
if (kind === "inbox") {
|
|
6423
|
+
const inbox = own$1(config.inboxes, name);
|
|
6424
|
+
if (inbox) return {
|
|
6425
|
+
alias: name,
|
|
6426
|
+
inbox
|
|
6427
|
+
};
|
|
6428
|
+
} else {
|
|
6429
|
+
const account = own$1(config.accounts, name);
|
|
6430
|
+
if (account) return {
|
|
6431
|
+
alias: name,
|
|
6432
|
+
account
|
|
6433
|
+
};
|
|
6434
|
+
}
|
|
6435
|
+
const renamed = formerNameRefusal(config, kind, name);
|
|
6436
|
+
if (renamed) throw renamed;
|
|
6437
|
+
throw notFound?.() ?? defaultNotFound(config, kind, name);
|
|
6438
|
+
}
|
|
6439
|
+
/** What to say when there are no mailboxes at all: how one is connected, in words (`connectMailboxWords`). */
|
|
6440
|
+
function noInboxesHint() {
|
|
6441
|
+
const connect = connectMailboxWords();
|
|
6442
|
+
return connect ? `No inboxes yet: add one with ${connect}, at a terminal or from a chat.` : "No inboxes yet.";
|
|
6443
|
+
}
|
|
6444
|
+
function defaultNotFound(config, kind, name) {
|
|
6445
|
+
if (kind === "inbox") {
|
|
6446
|
+
const known = Object.keys(config.inboxes);
|
|
6447
|
+
return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
|
|
6448
|
+
}
|
|
6449
|
+
return new CommsError("NOT_FOUND", `no account called "${name}"`);
|
|
6450
|
+
}
|
|
6451
|
+
function lookupName(config, kind, name) {
|
|
6452
|
+
return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
|
|
6453
|
+
}
|
|
6454
|
+
function findById(config, kind, id) {
|
|
6455
|
+
if (kind === "inbox") {
|
|
6456
|
+
for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
|
|
6457
|
+
alias,
|
|
6458
|
+
inbox
|
|
6459
|
+
};
|
|
6460
|
+
} else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
|
|
6461
|
+
alias,
|
|
6462
|
+
account
|
|
6463
|
+
};
|
|
6464
|
+
return null;
|
|
6465
|
+
}
|
|
6466
|
+
/**
|
|
6467
|
+
* The refusal for a former name, or null when `name` is not one.
|
|
6468
|
+
*
|
|
6469
|
+
* The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
|
|
6470
|
+
* account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
|
|
6471
|
+
* name that now belongs to nothing.
|
|
6472
|
+
*/
|
|
6473
|
+
function formerNameRefusal(config, kind, name) {
|
|
6474
|
+
if (config.version !== 2) return null;
|
|
6475
|
+
const former = own$1(config.formerNames[MAP[kind]], name);
|
|
6476
|
+
if (!former) return null;
|
|
6477
|
+
const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
|
|
6478
|
+
if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
|
|
6479
|
+
formerName: name,
|
|
6480
|
+
id: former.id
|
|
6481
|
+
} });
|
|
6482
|
+
return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
|
|
6483
|
+
hint: `Use "${current.alias}".`,
|
|
6484
|
+
details: {
|
|
6485
|
+
formerName: name,
|
|
6486
|
+
currentName: current.alias,
|
|
6487
|
+
id: former.id
|
|
6488
|
+
}
|
|
6489
|
+
});
|
|
6490
|
+
}
|
|
6491
|
+
/**
|
|
6492
|
+
* Whether `name` may be given to a new or renamed account of this kind and platform.
|
|
6493
|
+
*
|
|
6494
|
+
* Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
|
|
6495
|
+
* older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
|
|
6496
|
+
* 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
|
|
6497
|
+
* either kind.
|
|
5007
6498
|
*
|
|
5008
|
-
*
|
|
5009
|
-
*
|
|
5010
|
-
* runtime that became unused after that list was shown is not on it.
|
|
6499
|
+
* A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
|
|
6500
|
+
* again for version 2, because anything can happen between asking and writing.
|
|
5011
6501
|
*/
|
|
5012
|
-
|
|
5013
|
-
const
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
candidates.push({
|
|
5023
|
-
path,
|
|
5024
|
-
version,
|
|
5025
|
-
aliases: [.../* @__PURE__ */ new Set([path, real])]
|
|
5026
|
-
});
|
|
6502
|
+
function nameAvailable(config, kind, name, platform) {
|
|
6503
|
+
const refuse = (error) => ({
|
|
6504
|
+
ok: false,
|
|
6505
|
+
error
|
|
6506
|
+
});
|
|
6507
|
+
if (config.version === 1) {
|
|
6508
|
+
if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
|
|
6509
|
+
if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
|
|
6510
|
+
if (kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.inboxes, name) ?? own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
6511
|
+
return { ok: true };
|
|
5027
6512
|
}
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
6513
|
+
const problem = nameShapeProblem(name, platform);
|
|
6514
|
+
if (problem) return refuse(new CommsError("USAGE", problem));
|
|
6515
|
+
if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
6516
|
+
for (const map of ["inboxes", "accounts"]) {
|
|
6517
|
+
const former = own$1(config.formerNames[map], name);
|
|
6518
|
+
if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
|
|
6519
|
+
hint: "A former name keeps pointing people at the account that had it. Choose another name.",
|
|
6520
|
+
details: {
|
|
6521
|
+
formerName: name,
|
|
6522
|
+
id: former.id
|
|
6523
|
+
}
|
|
5035
6524
|
}));
|
|
5036
|
-
return result;
|
|
5037
|
-
};
|
|
5038
|
-
const processes = await (options.processes ?? runningCommandLines)();
|
|
5039
|
-
if (processes === null) return refuse("the running processes on this machine could not be listed, so none of these can be shown unused");
|
|
5040
|
-
const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
|
|
5041
|
-
const handedOut = await readHandedOut(ledger);
|
|
5042
|
-
if (handedOut === null) return refuse(`${ledger} could not be read, so none of these can be shown never to have been handed out`);
|
|
5043
|
-
const recorded = handedOut.flatMap((record) => record.config ? [{
|
|
5044
|
-
client: record.client,
|
|
5045
|
-
path: record.config
|
|
5046
|
-
}] : []);
|
|
5047
|
-
const scan = await scanRegisteredServers(context.env, process.platform, recorded);
|
|
5048
|
-
if (scan.unreadable.length > 0) return refuse(`${scan.unreadable.map((file) => `${file.path} (${file.reason})`).join("; ")} could not be read, so none of these can be shown unregistered`);
|
|
5049
|
-
const registered = scan.servers;
|
|
5050
|
-
const known = new Set(knownClientConfigs(context.env).map((file) => file.path));
|
|
5051
|
-
const mentions = (aliases, text) => aliases.some((alias) => text === alias || text.includes(`${alias}${sep}`) || text.includes(`${alias}/`));
|
|
5052
|
-
const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
|
|
5053
|
-
for (const { path, version, aliases } of candidates) {
|
|
5054
|
-
const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
|
|
5055
|
-
const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
|
|
5056
|
-
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;
|
|
5057
|
-
if (reason) {
|
|
5058
|
-
result.kept.push({
|
|
5059
|
-
path,
|
|
5060
|
-
version,
|
|
5061
|
-
reason
|
|
5062
|
-
});
|
|
5063
|
-
continue;
|
|
5064
|
-
}
|
|
5065
|
-
if (options.only !== void 0 && !options.only.includes(path)) {
|
|
5066
|
-
result.kept.push({
|
|
5067
|
-
path,
|
|
5068
|
-
version,
|
|
5069
|
-
reason: "not in the removal that was approved; prune again to include it"
|
|
5070
|
-
});
|
|
5071
|
-
continue;
|
|
5072
|
-
}
|
|
5073
|
-
if (!result.dryRun) await rm(path, { recursive: true });
|
|
5074
|
-
result.removed.push({
|
|
5075
|
-
path,
|
|
5076
|
-
version
|
|
5077
|
-
});
|
|
5078
|
-
}
|
|
5079
|
-
if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
|
|
5080
|
-
const gone = result.removed.map((item) => item.path);
|
|
5081
|
-
const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
|
|
5082
|
-
if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
5083
|
-
}
|
|
5084
|
-
return result;
|
|
5085
|
-
}
|
|
5086
|
-
/** Every runtime the installer handed out, or null when the record is there and cannot be read. */
|
|
5087
|
-
async function readHandedOut(path) {
|
|
5088
|
-
let text;
|
|
5089
|
-
try {
|
|
5090
|
-
text = await readFile(path, "utf8");
|
|
5091
|
-
} catch (error) {
|
|
5092
|
-
return error.code === "ENOENT" ? [] : null;
|
|
5093
|
-
}
|
|
5094
|
-
const records = [];
|
|
5095
|
-
for (const line of text.split("\n")) {
|
|
5096
|
-
if (!line.trim()) continue;
|
|
5097
|
-
try {
|
|
5098
|
-
const record = JSON.parse(line);
|
|
5099
|
-
if (typeof record.runtime !== "string") return null;
|
|
5100
|
-
records.push({
|
|
5101
|
-
at: String(record.at ?? ""),
|
|
5102
|
-
client: String(record.client ?? ""),
|
|
5103
|
-
name: String(record.name ?? ""),
|
|
5104
|
-
runtime: record.runtime,
|
|
5105
|
-
...typeof record.config === "string" ? { config: record.config } : {},
|
|
5106
|
-
...record.applied === true ? { applied: true } : {}
|
|
5107
|
-
});
|
|
5108
|
-
} catch {
|
|
5109
|
-
return null;
|
|
5110
|
-
}
|
|
5111
6525
|
}
|
|
5112
|
-
return
|
|
6526
|
+
return { ok: true };
|
|
5113
6527
|
}
|
|
5114
6528
|
/**
|
|
5115
6529
|
* A pattern for each package a channel's manifest names as a rival (`rivals.packages`).
|
|
@@ -5134,22 +6548,31 @@ function escapeRegExp(text) {
|
|
|
5134
6548
|
/**
|
|
5135
6549
|
* The command that removes a registered server, in its client's own terms — the name as the client's file has it,
|
|
5136
6550
|
* which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
|
|
5137
|
-
* (`
|
|
6551
|
+
* (`quoteCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
|
|
6552
|
+
*
|
|
6553
|
+
* The client's own command, so an external one (`externalCommand`). A name that is also one of this suite's commands,
|
|
6554
|
+
* or a path into one of its packages, is refused there, since such words are how a suite command would get round the
|
|
6555
|
+
* locator; that entry is removed by hand instead, from the file that holds it.
|
|
5138
6556
|
*/
|
|
5139
|
-
function removalCommand(client,
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
6557
|
+
function removalCommand(client, server, platform) {
|
|
6558
|
+
try {
|
|
6559
|
+
return remedy(externalCommand([
|
|
6560
|
+
client,
|
|
6561
|
+
"mcp",
|
|
6562
|
+
"remove",
|
|
6563
|
+
server.name
|
|
6564
|
+
], "the MCP client's own command removes an entry from its own configuration", platform));
|
|
6565
|
+
} catch {
|
|
6566
|
+
return remedy(`remove "${server.name}" from ${server.path} by hand, then restart ${server.client}`);
|
|
6567
|
+
}
|
|
5146
6568
|
}
|
|
5147
|
-
function gmailRemoval(
|
|
5148
|
-
|
|
6569
|
+
function gmailRemoval(registered, platform) {
|
|
6570
|
+
const { client, name: server, path, scope } = registered;
|
|
6571
|
+
if (scope === "project") return remedy(`remove "${server}" from the project entry in ${path} by hand`);
|
|
5149
6572
|
switch (client) {
|
|
5150
|
-
case "claude-code": return removalCommand("claude",
|
|
5151
|
-
case "codex": return removalCommand("codex",
|
|
5152
|
-
default: return `remove the "${server}" entry from ${path}, then restart ${client}
|
|
6573
|
+
case "claude-code": return removalCommand("claude", registered, platform);
|
|
6574
|
+
case "codex": return removalCommand("codex", registered, platform);
|
|
6575
|
+
default: return remedy(`remove the "${server}" entry from ${path}, then restart ${client}`);
|
|
5153
6576
|
}
|
|
5154
6577
|
}
|
|
5155
6578
|
/**
|
|
@@ -5160,7 +6583,7 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
|
|
|
5160
6583
|
const patterns = rivalPatterns(packages);
|
|
5161
6584
|
const findings = [];
|
|
5162
6585
|
for (const server of servers) {
|
|
5163
|
-
const line = [server.command, ...server.args].join(" ");
|
|
6586
|
+
const line = [server.command, ...withoutPathOptions(server.args).words].join(" ");
|
|
5164
6587
|
const known = patterns.find((candidate) => candidate.pattern.test(line));
|
|
5165
6588
|
if (!known) continue;
|
|
5166
6589
|
findings.push({
|
|
@@ -5185,7 +6608,7 @@ function findRivalWordServers(servers, word, product) {
|
|
|
5185
6608
|
return servers.filter((server) => !isProductServer(server, product) && pattern.test([
|
|
5186
6609
|
server.name,
|
|
5187
6610
|
server.command,
|
|
5188
|
-
...server.args,
|
|
6611
|
+
...withoutPathOptions(server.args).words,
|
|
5189
6612
|
server.url ?? ""
|
|
5190
6613
|
].join(" ")));
|
|
5191
6614
|
}
|
|
@@ -5351,10 +6774,6 @@ function renderPrune(result, color) {
|
|
|
5351
6774
|
if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
|
|
5352
6775
|
return lines.join("\n");
|
|
5353
6776
|
}
|
|
5354
|
-
/** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
|
|
5355
|
-
function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
|
|
5356
|
-
return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
|
|
5357
|
-
}
|
|
5358
6777
|
/**
|
|
5359
6778
|
* The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
|
|
5360
6779
|
* touches.
|
|
@@ -5466,7 +6885,7 @@ async function prepareChange(core, request, options) {
|
|
|
5466
6885
|
change: binding
|
|
5467
6886
|
}),
|
|
5468
6887
|
expiresAt: record.expiresAt,
|
|
5469
|
-
next: nextStep(record.approvalId, policy, options)
|
|
6888
|
+
next: nextStep(record.approvalId, policy, core, options)
|
|
5470
6889
|
};
|
|
5471
6890
|
} catch (error) {
|
|
5472
6891
|
await auditRefusal(core, "change.prepare", error, {
|
|
@@ -5498,8 +6917,9 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5498
6917
|
change: binding,
|
|
5499
6918
|
policy
|
|
5500
6919
|
}, {
|
|
5501
|
-
pendingHint: `Ask the user to run ${
|
|
5502
|
-
platform: options.platform
|
|
6920
|
+
pendingHint: handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`),
|
|
6921
|
+
platform: options.platform,
|
|
6922
|
+
handoffs: core.handoffs
|
|
5503
6923
|
});
|
|
5504
6924
|
const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
|
|
5505
6925
|
await auditChange(core, {
|
|
@@ -5517,7 +6937,11 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5517
6937
|
paths: binding.loosened.map((loosening) => loosening.path),
|
|
5518
6938
|
changes: binding.loosened
|
|
5519
6939
|
};
|
|
5520
|
-
} catch (
|
|
6940
|
+
} catch (caught) {
|
|
6941
|
+
const error = isMisdirectedSend(caught) ? new CommsError(caught.code, caught.message, {
|
|
6942
|
+
hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId),
|
|
6943
|
+
...caught.details === void 0 ? {} : { details: caught.details }
|
|
6944
|
+
}) : caught;
|
|
5521
6945
|
await auditRefusal(core, "change.claim", error, {
|
|
5522
6946
|
surface: options.surface,
|
|
5523
6947
|
approvalId,
|
|
@@ -5528,11 +6952,11 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
5528
6952
|
}
|
|
5529
6953
|
}
|
|
5530
6954
|
/** A change approval, checked to be one and to describe the change its digest binds. */
|
|
5531
|
-
async function changeRecord(core, approvalId) {
|
|
6955
|
+
async function changeRecord(core, approvalId, options = {}) {
|
|
5532
6956
|
const record = await core.approvals.get(approvalId);
|
|
5533
6957
|
if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
|
|
5534
6958
|
if (approvalKind(record) === "download") throw new CommsError("USAGE", `${approvalId} is a question about where to save files, not a configuration change`, { hint: DOWNLOAD_ANSWER_HINT });
|
|
5535
|
-
if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint:
|
|
6959
|
+
if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId) });
|
|
5536
6960
|
if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
|
|
5537
6961
|
hint: "Nothing was approved. Prepare the change again.",
|
|
5538
6962
|
details: { approvalId }
|
|
@@ -5547,7 +6971,7 @@ async function changeRecord(core, approvalId) {
|
|
|
5547
6971
|
async function beginChangeApproval(core, approvalId, options) {
|
|
5548
6972
|
let record;
|
|
5549
6973
|
try {
|
|
5550
|
-
record = await changeRecord(core, approvalId);
|
|
6974
|
+
record = await changeRecord(core, approvalId, options);
|
|
5551
6975
|
const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
|
|
5552
6976
|
return {
|
|
5553
6977
|
approvalId,
|
|
@@ -5568,7 +6992,7 @@ async function beginChangeApproval(core, approvalId, options) {
|
|
|
5568
6992
|
async function finishChangeApproval(core, approvalId, answer, options) {
|
|
5569
6993
|
let record;
|
|
5570
6994
|
try {
|
|
5571
|
-
record = await changeRecord(core, approvalId);
|
|
6995
|
+
record = await changeRecord(core, approvalId, options);
|
|
5572
6996
|
const digest = changeDigest(record.change);
|
|
5573
6997
|
const approved = await core.approvals.approve(approvalId, "terminal", {
|
|
5574
6998
|
draftMessageId: digest,
|
|
@@ -5629,8 +7053,8 @@ async function revokeChange(core, approvalId, reason, options) {
|
|
|
5629
7053
|
function changePolicyOf(record) {
|
|
5630
7054
|
return record.requiredPolicy === "chat" ? "chat" : "confirm";
|
|
5631
7055
|
}
|
|
5632
|
-
function nextStep(approvalId, policy, options) {
|
|
5633
|
-
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${
|
|
7056
|
+
function nextStep(approvalId, policy, core, options) {
|
|
7057
|
+
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `The change policy is confirm: ask the user to run ${command} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`);
|
|
5634
7058
|
}
|
|
5635
7059
|
/**
|
|
5636
7060
|
* One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
|
|
@@ -5790,13 +7214,11 @@ async function gatedChange(core, change, options) {
|
|
|
5790
7214
|
status: "approval-required",
|
|
5791
7215
|
prepared: await prepareChange(core, request, {
|
|
5792
7216
|
surface: options.surface,
|
|
5793
|
-
approveCommand: options.approveCommand,
|
|
5794
7217
|
platform: options.platform
|
|
5795
7218
|
})
|
|
5796
7219
|
};
|
|
5797
7220
|
const consent = await claimChange(core, options.approvalId, request, {
|
|
5798
7221
|
surface: options.surface,
|
|
5799
|
-
approveCommand: options.approveCommand,
|
|
5800
7222
|
platform: options.platform
|
|
5801
7223
|
});
|
|
5802
7224
|
return {
|
|
@@ -5814,11 +7236,9 @@ async function gatedChange(core, change, options) {
|
|
|
5814
7236
|
*/
|
|
5815
7237
|
async function gatedChangeAtTerminal(core, change, options) {
|
|
5816
7238
|
const streams = options.streams ?? defaultStreams;
|
|
5817
|
-
const { approveCommand } = options;
|
|
5818
7239
|
const first = await gatedChange(core, change, {
|
|
5819
7240
|
surface: "cli",
|
|
5820
7241
|
approvalId: options.approvalId,
|
|
5821
|
-
approveCommand,
|
|
5822
7242
|
platform: options.output.platform
|
|
5823
7243
|
});
|
|
5824
7244
|
if (first.status === "applied") return first.result;
|
|
@@ -5826,9 +7246,9 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5826
7246
|
if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
|
|
5827
7247
|
if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
|
|
5828
7248
|
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
5829
|
-
const
|
|
7249
|
+
const handoffs = requireHandoffs(core, options.output.platform);
|
|
5830
7250
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
5831
|
-
hint: options.pendingHint?.(prepared) ?? approvalHint(prepared,
|
|
7251
|
+
hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, handoffs[options.rerunOn ?? "own"](insertWordsBeforeSentinel(options.rerun, ...carrying)), handoffs),
|
|
5832
7252
|
details: {
|
|
5833
7253
|
approvalId: prepared.approvalId,
|
|
5834
7254
|
policy: prepared.policy,
|
|
@@ -5849,7 +7269,6 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5849
7269
|
const second = await gatedChange(core, change, {
|
|
5850
7270
|
surface: "cli",
|
|
5851
7271
|
approvalId: prepared.approvalId,
|
|
5852
|
-
approveCommand,
|
|
5853
7272
|
platform: options.output.platform
|
|
5854
7273
|
});
|
|
5855
7274
|
if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
|
|
@@ -5862,9 +7281,13 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
5862
7281
|
* Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
|
|
5863
7282
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
5864
7283
|
*/
|
|
5865
|
-
function approvalHint(prepared, rerun,
|
|
5866
|
-
const run =
|
|
5867
|
-
|
|
7284
|
+
function approvalHint(prepared, rerun, maker) {
|
|
7285
|
+
const run = isCommand(rerun) ? inlineCommand(rerun) : null;
|
|
7286
|
+
const rerunSaid = isCommand(rerun) ? "" : rerun.message;
|
|
7287
|
+
if (prepared.policy !== "confirm") return run === null ? `Show the person the preview. ${rerunSaid}` : `Show the person the preview. Once they say yes, run ${run}.`;
|
|
7288
|
+
const approve = maker.own(["approve", prepared.approvalId]);
|
|
7289
|
+
if (isCommand(approve) && run !== null) return `Show the person the preview. They run ${inlineCommand(approve)}; then run ${run}.`;
|
|
7290
|
+
return `Show the person the preview. ${handoffSentence(approve, (command) => `They run ${command}.`)} ${run === null ? rerunSaid : `Then run ${run}.`}`;
|
|
5868
7291
|
}
|
|
5869
7292
|
/**
|
|
5870
7293
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -5884,11 +7307,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
5884
7307
|
refuseUnlessPerson(env, streams, {
|
|
5885
7308
|
refusedToAgent: "only a person can approve a change, not an agent",
|
|
5886
7309
|
refusedWithoutTerminal: "approving a change needs an interactive terminal",
|
|
5887
|
-
command:
|
|
5888
|
-
"agentcomms",
|
|
5889
|
-
"approve",
|
|
5890
|
-
approvalId
|
|
5891
|
-
], output.platform)),
|
|
7310
|
+
command: requireHandoffs(core, output.platform).own(["approve", approvalId]),
|
|
5892
7311
|
color: output.color,
|
|
5893
7312
|
json: output.json
|
|
5894
7313
|
});
|
|
@@ -6074,11 +7493,17 @@ const KEYCHAIN_SERVICE = "agent-communications";
|
|
|
6074
7493
|
* answer an OS prompt, so it must not hang.
|
|
6075
7494
|
*/
|
|
6076
7495
|
const KEYCHAIN_TIMEOUT_MS = 12e3;
|
|
7496
|
+
/** "Run the doctor": core's, located from whatever is printing, or why there is none here. */
|
|
7497
|
+
function doctorSentence(handoffs, say) {
|
|
7498
|
+
return handoffSentence(requiredHandoffs(handoffs).core(["doctor"]), say, { instead: "Call comms_doctor from a chat." });
|
|
7499
|
+
}
|
|
6077
7500
|
var FileSecretStore = class {
|
|
6078
7501
|
kind = "file";
|
|
6079
7502
|
directory;
|
|
6080
|
-
|
|
7503
|
+
#handoffs;
|
|
7504
|
+
constructor(directory, options = {}) {
|
|
6081
7505
|
this.directory = directory;
|
|
7506
|
+
this.#handoffs = options.handoffs;
|
|
6082
7507
|
}
|
|
6083
7508
|
#path(ref) {
|
|
6084
7509
|
return join(this.directory, `${createHash("sha256").update(ref).digest("hex").slice(0, 32)}.json`);
|
|
@@ -6098,7 +7523,7 @@ var FileSecretStore = class {
|
|
|
6098
7523
|
} catch (error) {
|
|
6099
7524
|
if (error.code === "ENOENT") return null;
|
|
6100
7525
|
throw new CommsError("SECRET_STORE_UNAVAILABLE", "a stored secret file could not be read", {
|
|
6101
|
-
hint:
|
|
7526
|
+
hint: `${doctorSentence(this.#handoffs, (command) => `Run ${command}.`)} Re-authorise the inbox if the file is damaged.`,
|
|
6102
7527
|
cause: error
|
|
6103
7528
|
});
|
|
6104
7529
|
}
|
|
@@ -6133,10 +7558,10 @@ async function loadKeyringModule() {
|
|
|
6133
7558
|
var KeychainTimeout = class extends Error {
|
|
6134
7559
|
name = "KeychainTimeout";
|
|
6135
7560
|
};
|
|
6136
|
-
function keychainError(action, cause, timeoutMs) {
|
|
7561
|
+
function keychainError(action, cause, timeoutMs, handoffs) {
|
|
6137
7562
|
const timedOut = cause instanceof KeychainTimeout;
|
|
6138
7563
|
return new CommsError(timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "SECRET_STORE_UNAVAILABLE", timedOut ? `the system keychain did not answer within ${Math.round(timeoutMs / 1e3)}s while trying to ${action}` : `the system keychain refused to ${action} (it may be locked, or access was denied)`, {
|
|
6139
|
-
hint: timedOut ?
|
|
7564
|
+
hint: timedOut ? `Look for a system dialog asking to allow access to the keychain, then retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)}` : `Unlock the keychain and retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)} On macOS a Node upgrade can require allowing access again.`,
|
|
6140
7565
|
details: { reason: timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "KEYCHAIN_UNAVAILABLE" },
|
|
6141
7566
|
cause
|
|
6142
7567
|
});
|
|
@@ -6161,6 +7586,7 @@ var KeychainSecretStore = class {
|
|
|
6161
7586
|
#module;
|
|
6162
7587
|
#timeoutMs;
|
|
6163
7588
|
#namespace;
|
|
7589
|
+
#handoffs;
|
|
6164
7590
|
/** One keychain call at a time: parallel calls would each raise their own OS prompt and exhaust the thread pool. */
|
|
6165
7591
|
#queue = Promise.resolve();
|
|
6166
7592
|
/**
|
|
@@ -6174,17 +7600,18 @@ var KeychainSecretStore = class {
|
|
|
6174
7600
|
* @param namespace keeps entries of different config directories apart (a test run or a second setup must never
|
|
6175
7601
|
* read or overwrite another's `client:default:secret`); see {@link keychainNamespace}.
|
|
6176
7602
|
*/
|
|
6177
|
-
constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
|
|
7603
|
+
constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS, options = {}) {
|
|
6178
7604
|
this.#module = module;
|
|
6179
7605
|
this.#namespace = namespace;
|
|
6180
7606
|
this.#timeoutMs = timeoutMs;
|
|
7607
|
+
this.#handoffs = options.handoffs;
|
|
6181
7608
|
}
|
|
6182
7609
|
#entry(ref) {
|
|
6183
7610
|
return new this.#module.AsyncEntry(KEYCHAIN_SERVICE, `${this.#namespace}:${ref}`, { linux: { store: "secret-service" } });
|
|
6184
7611
|
}
|
|
6185
7612
|
#serial(action, fn) {
|
|
6186
7613
|
const run = this.#queue.then(async () => {
|
|
6187
|
-
if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs);
|
|
7614
|
+
if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs, this.#handoffs);
|
|
6188
7615
|
const native = fn();
|
|
6189
7616
|
try {
|
|
6190
7617
|
return await raceTimeout(native, this.#timeoutMs);
|
|
@@ -6196,7 +7623,7 @@ var KeychainSecretStore = class {
|
|
|
6196
7623
|
if (this.#stuck === stuck) this.#stuck = null;
|
|
6197
7624
|
});
|
|
6198
7625
|
}
|
|
6199
|
-
throw keychainError(action, error, this.#timeoutMs);
|
|
7626
|
+
throw keychainError(action, error, this.#timeoutMs, this.#handoffs);
|
|
6200
7627
|
}
|
|
6201
7628
|
});
|
|
6202
7629
|
this.#queue = run.catch(() => void 0);
|
|
@@ -6236,10 +7663,11 @@ function keychainNamespace(configDir) {
|
|
|
6236
7663
|
}
|
|
6237
7664
|
/** Opens the backend recorded in config for an inbox or client. */
|
|
6238
7665
|
async function openSecretStore(kind, options) {
|
|
6239
|
-
|
|
7666
|
+
const told = { handoffs: options.handoffs };
|
|
7667
|
+
if (kind === "file") return new FileSecretStore(options.secretsDir, told);
|
|
6240
7668
|
const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
|
|
6241
7669
|
if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
|
|
6242
|
-
return new KeychainSecretStore(keyring, options.namespace);
|
|
7670
|
+
return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
|
|
6243
7671
|
}
|
|
6244
7672
|
var InboxStateStore = class {
|
|
6245
7673
|
directory;
|
|
@@ -6273,25 +7701,37 @@ var InboxStateStore = class {
|
|
|
6273
7701
|
}
|
|
6274
7702
|
};
|
|
6275
7703
|
function openCore(options = {}) {
|
|
6276
|
-
const paths =
|
|
7704
|
+
const { paths, pathOverrides } = resolvePathIdentity(options);
|
|
6277
7705
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
6278
|
-
const
|
|
7706
|
+
const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
|
|
7707
|
+
caller: options.caller,
|
|
7708
|
+
paths,
|
|
7709
|
+
platform: options.platform,
|
|
7710
|
+
env: options.env
|
|
7711
|
+
});
|
|
7712
|
+
const config = new ConfigStore(paths.configDir, { handoffs });
|
|
6279
7713
|
let cached = null;
|
|
6280
7714
|
return {
|
|
6281
7715
|
paths,
|
|
7716
|
+
pathOverrides,
|
|
6282
7717
|
config,
|
|
6283
7718
|
states: new InboxStateStore(paths.stateDir),
|
|
6284
|
-
approvals: new ApprovalStore(paths.stateDir, {
|
|
7719
|
+
approvals: new ApprovalStore(paths.stateDir, {
|
|
7720
|
+
now,
|
|
7721
|
+
handoffs
|
|
7722
|
+
}),
|
|
6285
7723
|
ledger: new SendLedger(paths.stateDir, now),
|
|
6286
7724
|
plans: new PlanStore(paths.stateDir, now),
|
|
6287
7725
|
taint: new TaintStore(paths.stateDir, now),
|
|
6288
7726
|
audit: new AuditLog(paths.stateDir, now),
|
|
7727
|
+
handoffs,
|
|
6289
7728
|
async secrets(kind) {
|
|
6290
7729
|
const chosen = kind ?? secretsStoreOf(await config.load());
|
|
6291
7730
|
if (cached?.kind === chosen) return cached.store;
|
|
6292
7731
|
const store = await openSecretStore(chosen, {
|
|
6293
7732
|
secretsDir: paths.secretsDir,
|
|
6294
|
-
namespace: keychainNamespace(paths.configDir)
|
|
7733
|
+
namespace: keychainNamespace(paths.configDir),
|
|
7734
|
+
handoffs
|
|
6295
7735
|
});
|
|
6296
7736
|
cached = {
|
|
6297
7737
|
kind: chosen,
|
|
@@ -6491,81 +7931,6 @@ strictObject({
|
|
|
6491
7931
|
sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
|
|
6492
7932
|
});
|
|
6493
7933
|
/**
|
|
6494
|
-
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
6495
|
-
* to decide whether the release it last heard of is newer than the one running.
|
|
6496
|
-
*
|
|
6497
|
-
* Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
|
|
6498
|
-
* every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
|
|
6499
|
-
* all of it, so nothing that imported these from there changes.
|
|
6500
|
-
*/
|
|
6501
|
-
/**
|
|
6502
|
-
* A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
|
|
6503
|
-
*
|
|
6504
|
-
* Anything the registry answers is checked against this before it is used, because a version goes into a directory
|
|
6505
|
-
* name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
|
|
6506
|
-
* mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
|
|
6507
|
-
*/
|
|
6508
|
-
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
6509
|
-
function isVersion(value) {
|
|
6510
|
-
return typeof value === "string" && VERSION_PATTERN.test(value);
|
|
6511
|
-
}
|
|
6512
|
-
/** Two numeric strings with no leading zeros, compared as numbers of any size. */
|
|
6513
|
-
function compareNumeric(a, b) {
|
|
6514
|
-
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
|
|
6515
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
6516
|
-
}
|
|
6517
|
-
/**
|
|
6518
|
-
* Semver precedence: -1, 0 or 1, or null when either is not a version.
|
|
6519
|
-
*
|
|
6520
|
-
* Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
|
|
6521
|
-
* counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
|
|
6522
|
-
* few packages as it can.
|
|
6523
|
-
*/
|
|
6524
|
-
function compareVersions(a, b) {
|
|
6525
|
-
const left = VERSION_PATTERN.exec(a);
|
|
6526
|
-
const right = VERSION_PATTERN.exec(b);
|
|
6527
|
-
if (!left || !right) return null;
|
|
6528
|
-
for (const index of [
|
|
6529
|
-
1,
|
|
6530
|
-
2,
|
|
6531
|
-
3
|
|
6532
|
-
]) {
|
|
6533
|
-
const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
|
|
6534
|
-
if (order !== 0) return order < 0 ? -1 : 1;
|
|
6535
|
-
}
|
|
6536
|
-
const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
|
|
6537
|
-
const [ours, theirs] = [pre(left), pre(right)];
|
|
6538
|
-
if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
|
|
6539
|
-
for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
|
|
6540
|
-
const x = ours[index];
|
|
6541
|
-
const y = theirs[index];
|
|
6542
|
-
if (x === void 0) return -1;
|
|
6543
|
-
if (y === void 0) return 1;
|
|
6544
|
-
const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
6545
|
-
if (numericX && numericY) {
|
|
6546
|
-
const order = compareNumeric(x, y);
|
|
6547
|
-
if (order !== 0) return order < 0 ? -1 : 1;
|
|
6548
|
-
} else if (numericX !== numericY) return numericX ? -1 : 1;
|
|
6549
|
-
else if (x !== y) return x < y ? -1 : 1;
|
|
6550
|
-
}
|
|
6551
|
-
return 0;
|
|
6552
|
-
}
|
|
6553
|
-
/** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
|
|
6554
|
-
function isBehind(version, latest) {
|
|
6555
|
-
if (version === latest) return false;
|
|
6556
|
-
const order = compareVersions(version, latest);
|
|
6557
|
-
return order === null ? true : order < 0;
|
|
6558
|
-
}
|
|
6559
|
-
/**
|
|
6560
|
-
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
6561
|
-
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
6562
|
-
*/
|
|
6563
|
-
function isPrerelease(version) {
|
|
6564
|
-
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
6565
|
-
}
|
|
6566
|
-
/** The package version, read from package.json at build time. */
|
|
6567
|
-
const VERSION = "0.13.0";
|
|
6568
|
-
/**
|
|
6569
7934
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6570
7935
|
*
|
|
6571
7936
|
* `agentcomms mcp install` registers the core server; `comms_server_install` registers any channel's; and
|
|
@@ -6765,7 +8130,9 @@ function serverInstallChange(core, env, request, own) {
|
|
|
6765
8130
|
checkPin(config, request);
|
|
6766
8131
|
const product = await productNow();
|
|
6767
8132
|
const { version } = product;
|
|
6768
|
-
const preflight = await preflightInstall(context, product, installOptions(request))
|
|
8133
|
+
const preflight = await preflightInstall(context, product, installOptions(request)).catch(async (error) => {
|
|
8134
|
+
throw await remedyLocated(error, core);
|
|
8135
|
+
});
|
|
6769
8136
|
const { target, previous, effective, kept, command } = preflight;
|
|
6770
8137
|
planned = {
|
|
6771
8138
|
product,
|
|
@@ -6796,7 +8163,9 @@ function serverInstallChange(core, env, request, own) {
|
|
|
6796
8163
|
},
|
|
6797
8164
|
apply: async () => {
|
|
6798
8165
|
if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
|
|
6799
|
-
const result = await mcpInstall(context, planned.product, installOptions(request), planned.install)
|
|
8166
|
+
const result = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
|
|
8167
|
+
throw await remedyLocated(error, core);
|
|
8168
|
+
});
|
|
6800
8169
|
return {
|
|
6801
8170
|
...result,
|
|
6802
8171
|
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
|
|
@@ -6805,6 +8174,23 @@ function serverInstallChange(core, env, request, own) {
|
|
|
6805
8174
|
};
|
|
6806
8175
|
}
|
|
6807
8176
|
/**
|
|
8177
|
+
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
8178
|
+
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
8179
|
+
* installer says only the words; it may not import the locator.
|
|
8180
|
+
*/
|
|
8181
|
+
async function remedyLocated(error, core) {
|
|
8182
|
+
const remedy = installRemedyOf(error);
|
|
8183
|
+
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
8184
|
+
const channel = CHANNELS.find((each) => channelServer(each).binary === remedy.binary);
|
|
8185
|
+
if (channel === void 0) return error;
|
|
8186
|
+
const maker = await requireHandoffs(core).registered();
|
|
8187
|
+
return new CommsError(error.code, error.message, {
|
|
8188
|
+
hint: handoffSentence(maker.of(channel, remedy.words), remedy.say),
|
|
8189
|
+
...error.details === void 0 ? {} : { details: error.details },
|
|
8190
|
+
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
8191
|
+
});
|
|
8192
|
+
}
|
|
8193
|
+
/**
|
|
6808
8194
|
* Removing a channel's unused managed runtimes, as a change.
|
|
6809
8195
|
*
|
|
6810
8196
|
* A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
|
|
@@ -6822,7 +8208,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
6822
8208
|
};
|
|
6823
8209
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
6824
8210
|
packageName: channelServer(request.channel).packageName,
|
|
6825
|
-
version: "0.13.
|
|
8211
|
+
version: "0.13.1"
|
|
6826
8212
|
};
|
|
6827
8213
|
const product = {
|
|
6828
8214
|
packageName,
|
|
@@ -7057,40 +8443,52 @@ async function pendingUpdate(options) {
|
|
|
7057
8443
|
};
|
|
7058
8444
|
return verdict;
|
|
7059
8445
|
}
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
}
|
|
8446
|
+
function updateCommands(core, platform) {
|
|
8447
|
+
const handoffs = requireHandoffs(core, platform);
|
|
8448
|
+
return {
|
|
8449
|
+
update: handoffs.core(["update"]),
|
|
8450
|
+
later: handoffs.core(["update", "--later"])
|
|
8451
|
+
};
|
|
8452
|
+
}
|
|
8453
|
+
/** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
|
|
8454
|
+
function updateCommandSaid(commands, which, say) {
|
|
8455
|
+
return handoffSentence(commands[which], say);
|
|
8456
|
+
}
|
|
8457
|
+
function updateWaysOf(commands) {
|
|
8458
|
+
return {
|
|
8459
|
+
update: {
|
|
8460
|
+
tool: "comms_update",
|
|
8461
|
+
command: commands.update
|
|
8462
|
+
},
|
|
8463
|
+
later: {
|
|
8464
|
+
tool: "comms_update",
|
|
8465
|
+
arguments: { later: true },
|
|
8466
|
+
command: commands.later
|
|
8467
|
+
}
|
|
8468
|
+
};
|
|
8469
|
+
}
|
|
8470
|
+
/** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
|
|
8471
|
+
function labelOf(channel) {
|
|
8472
|
+
return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
|
|
8473
|
+
}
|
|
7077
8474
|
/**
|
|
7078
8475
|
* What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
|
|
7079
8476
|
* two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
|
|
7080
8477
|
*/
|
|
7081
|
-
function updateStopMessage(pending, where) {
|
|
8478
|
+
function updateStopMessage(pending, where, commands) {
|
|
8479
|
+
const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
|
|
7082
8480
|
const didNotRun = `Nothing was done: ${where.tool} did not run.`;
|
|
7083
|
-
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. At a terminal:
|
|
8481
|
+
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}.`)}`;
|
|
7084
8482
|
if (pending.kind === "restart") return [
|
|
7085
8483
|
"Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
|
|
7086
|
-
`This is ${
|
|
8484
|
+
`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}`,
|
|
7087
8485
|
`Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
|
|
7088
8486
|
].join("\n");
|
|
7089
8487
|
return [
|
|
7090
8488
|
UPDATE_FIRST,
|
|
7091
|
-
`This is ${
|
|
8489
|
+
`This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
|
|
7092
8490
|
"Ask the person which they want:",
|
|
7093
|
-
`- Update now: call comms_update on the
|
|
8491
|
+
`- 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.`,
|
|
7094
8492
|
`- ${later}`
|
|
7095
8493
|
].join("\n");
|
|
7096
8494
|
}
|
|
@@ -7117,7 +8515,6 @@ const CHANNEL_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(c
|
|
|
7117
8515
|
const SERVER_PACKAGES = Object.freeze(CHANNELS.map((channel) => channelServer(channel).npxPackage).filter((name) => !CHANNEL_PACKAGES.includes(name)));
|
|
7118
8516
|
Object.freeze([.../* @__PURE__ */ new Set([...CHANNEL_PACKAGES, ...SERVER_PACKAGES])].sort());
|
|
7119
8517
|
channelServer("core").packageName;
|
|
7120
|
-
listed(CHANNELS.map((channel) => `\`${channelServer(channel).binary} mcp prune\``), "and");
|
|
7121
8518
|
/**
|
|
7122
8519
|
* "Not now": no server or command on this machine stops for the update until the next local midnight.
|
|
7123
8520
|
*
|
|
@@ -7225,8 +8622,9 @@ function updateToolGate(options) {
|
|
|
7225
8622
|
if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
|
|
7226
8623
|
return stoppedCall(pending, {
|
|
7227
8624
|
server: options.server,
|
|
8625
|
+
channel: options.channel,
|
|
7228
8626
|
tool
|
|
7229
|
-
});
|
|
8627
|
+
}, updateCommands(options.core));
|
|
7230
8628
|
};
|
|
7231
8629
|
}
|
|
7232
8630
|
/**
|
|
@@ -7234,15 +8632,17 @@ function updateToolGate(options) {
|
|
|
7234
8632
|
* a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
|
|
7235
8633
|
* `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
|
|
7236
8634
|
*/
|
|
7237
|
-
function stoppedCall(pending, where) {
|
|
7238
|
-
const message = updateStopMessage(pending, where);
|
|
8635
|
+
function stoppedCall(pending, where, commands) {
|
|
8636
|
+
const message = updateStopMessage(pending, where, commands);
|
|
8637
|
+
const ways = updateWaysOf(commands);
|
|
8638
|
+
const leaving = (value) => JSON.parse(JSON.stringify(value));
|
|
7239
8639
|
return {
|
|
7240
8640
|
isError: true,
|
|
7241
8641
|
content: [{
|
|
7242
8642
|
type: "text",
|
|
7243
8643
|
text: message
|
|
7244
8644
|
}],
|
|
7245
|
-
structuredContent: { error: {
|
|
8645
|
+
structuredContent: leaving({ error: {
|
|
7246
8646
|
code: "UPDATE_REQUIRED",
|
|
7247
8647
|
message,
|
|
7248
8648
|
hint: pending.kind === "restart" ? "Restart the MCP client, or put the stop off until midnight with comms_update `later: true` once the person agrees." : "Update with comms_update (the person approves its preview), or put it off until midnight with comms_update `later: true` once the person agrees.",
|
|
@@ -7252,10 +8652,10 @@ function stoppedCall(pending, where) {
|
|
|
7252
8652
|
running: pending.running,
|
|
7253
8653
|
latest: pending.latest,
|
|
7254
8654
|
installed: pending.kind === "restart",
|
|
7255
|
-
update:
|
|
7256
|
-
later:
|
|
8655
|
+
update: ways.update,
|
|
8656
|
+
later: ways.later
|
|
7257
8657
|
}
|
|
7258
|
-
} }
|
|
8658
|
+
} })
|
|
7259
8659
|
};
|
|
7260
8660
|
}
|
|
7261
8661
|
/** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
|
|
@@ -7350,19 +8750,21 @@ async function updateGateAtTerminal(options) {
|
|
|
7350
8750
|
...where
|
|
7351
8751
|
});
|
|
7352
8752
|
if (pending === null) return null;
|
|
7353
|
-
|
|
8753
|
+
const person = agentMarker(env) === null && canPrompt(env, streams, {
|
|
7354
8754
|
json: output.json,
|
|
7355
8755
|
noInput: options.noInput === true
|
|
7356
|
-
})
|
|
8756
|
+
});
|
|
8757
|
+
const commands = updateCommands(core, output.platform);
|
|
8758
|
+
if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
|
|
7357
8759
|
const bold = (word) => paint(output.color, "bold", word);
|
|
7358
8760
|
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();
|
|
7359
8761
|
if (answer === "now" || answer === "update") {
|
|
7360
8762
|
if (pending.kind === "restart") {
|
|
7361
|
-
streams.stdout.write(`${pending.latest} is installed globally; this ${options.
|
|
8763
|
+
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`);
|
|
7362
8764
|
return EXIT_CODES.UPDATE;
|
|
7363
8765
|
}
|
|
7364
8766
|
if (!options.update) {
|
|
7365
|
-
streams.stdout.write(
|
|
8767
|
+
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`);
|
|
7366
8768
|
return EXIT_CODES.UPDATE;
|
|
7367
8769
|
}
|
|
7368
8770
|
return afterUpdate(await options.update(), options, pending);
|
|
@@ -7371,39 +8773,50 @@ async function updateGateAtTerminal(options) {
|
|
|
7371
8773
|
const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
|
|
7372
8774
|
env,
|
|
7373
8775
|
output,
|
|
7374
|
-
|
|
7375
|
-
|
|
8776
|
+
rerun: ["update", "--later"],
|
|
8777
|
+
rerunOn: "core",
|
|
7376
8778
|
answered: true,
|
|
7377
8779
|
streams
|
|
7378
8780
|
});
|
|
7379
8781
|
streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
|
|
7380
8782
|
return null;
|
|
7381
8783
|
}
|
|
7382
|
-
throw new CommsError("USAGE",
|
|
8784
|
+
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}.`) });
|
|
8785
|
+
}
|
|
8786
|
+
/**
|
|
8787
|
+
* The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
|
|
8788
|
+
* command most people can run, and which a sentence would read as one (CUE-403).
|
|
8789
|
+
*/
|
|
8790
|
+
function cliOf(channel) {
|
|
8791
|
+
if (channel === "core") return "core CLI";
|
|
8792
|
+
return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
|
|
7383
8793
|
}
|
|
7384
8794
|
/**
|
|
7385
8795
|
* The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
|
|
7386
8796
|
* — the update installed, and an older copy running — says to run the command from the installed one.
|
|
7387
8797
|
*/
|
|
7388
|
-
function updateRequired(pending,
|
|
7389
|
-
const
|
|
7390
|
-
const npx = `Where agentcomms is not installed: \`${UPDATE_WAYS.update.npx}\`, and \`${UPDATE_WAYS.later.npx}\`.`;
|
|
8798
|
+
function updateRequired(pending, cli, commands) {
|
|
8799
|
+
const ways = updateWaysOf(commands);
|
|
7391
8800
|
const details = {
|
|
7392
8801
|
running: pending.running,
|
|
7393
8802
|
latest: pending.latest,
|
|
7394
8803
|
installed: pending.kind === "restart",
|
|
7395
|
-
update:
|
|
7396
|
-
later:
|
|
8804
|
+
update: ways.update,
|
|
8805
|
+
later: ways.later
|
|
7397
8806
|
};
|
|
7398
|
-
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 ${
|
|
7399
|
-
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
|
|
8807
|
+
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`)}`, {
|
|
8808
|
+
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.`,
|
|
7400
8809
|
details
|
|
7401
8810
|
});
|
|
7402
|
-
return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): run
|
|
7403
|
-
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
|
|
8811
|
+
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`)}`, {
|
|
8812
|
+
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.`,
|
|
7404
8813
|
details
|
|
7405
8814
|
});
|
|
7406
8815
|
}
|
|
8816
|
+
/** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
|
|
8817
|
+
function bothSaid(commands, say) {
|
|
8818
|
+
return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
|
|
8819
|
+
}
|
|
7407
8820
|
/**
|
|
7408
8821
|
* What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
|
|
7409
8822
|
* the update did everything and this command's release is now installed where it runs from — its global package at
|
|
@@ -7411,8 +8824,10 @@ function updateRequired(pending, binary) {
|
|
|
7411
8824
|
* script that goes on after `cmd && next` must not take it for the command's success.
|
|
7412
8825
|
*/
|
|
7413
8826
|
async function afterUpdate(outcome, options, before) {
|
|
7414
|
-
const { streams
|
|
7415
|
-
const
|
|
8827
|
+
const { streams } = options;
|
|
8828
|
+
const cli = cliOf(options.channel);
|
|
8829
|
+
const notRun = "The command did not run";
|
|
8830
|
+
const commands = updateCommands(options.core, options.output.platform);
|
|
7416
8831
|
if (outcome === "failed") {
|
|
7417
8832
|
streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
|
|
7418
8833
|
return EXIT_CODES.UNAVAILABLE;
|
|
@@ -7426,10 +8841,10 @@ async function afterUpdate(outcome, options, before) {
|
|
|
7426
8841
|
streams.stdout.write("Updated. Run your command again.\n");
|
|
7427
8842
|
return EXIT_CODES.OK;
|
|
7428
8843
|
}
|
|
7429
|
-
streams.stdout.write(`${before.latest} is installed here: run your command again from it.
|
|
8844
|
+
streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
|
|
7430
8845
|
return EXIT_CODES.UPDATE;
|
|
7431
8846
|
}
|
|
7432
|
-
streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}.
|
|
8847
|
+
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`);
|
|
7433
8848
|
return EXIT_CODES.UPDATE;
|
|
7434
8849
|
}
|
|
7435
8850
|
async function askLine(streams, question) {
|
|
@@ -7446,7 +8861,7 @@ async function askLine(streams, question) {
|
|
|
7446
8861
|
}
|
|
7447
8862
|
channelServer("core").packageName;
|
|
7448
8863
|
//#endregion
|
|
7449
|
-
//#region ../core/dist/tool-arguments-
|
|
8864
|
+
//#region ../core/dist/tool-arguments-BcYu_EPp.mjs
|
|
7450
8865
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
7451
8866
|
const TARGET = "draft-2020-12";
|
|
7452
8867
|
/**
|
|
@@ -8450,7 +9865,7 @@ strictObject({
|
|
|
8450
9865
|
}))) !== JSON.stringify(narrowing)) issue(["narrowing"], kept ? `${kept.label}'s server is pinned by ${narrowingWords(narrowing)}, as its entries have always been written` : `\`inbox\`, \`--read-only\` and \`workspace\` are Gmail's and Slack's; a channel after them is pinned by ${narrowingWords(narrowing)} and nothing else`);
|
|
8451
9866
|
}
|
|
8452
9867
|
if (isCore) {
|
|
8453
|
-
if (manifest.binary !== CORE_BINARY) issue(["binary"],
|
|
9868
|
+
if (manifest.binary !== CORE_BINARY) issue(["binary"], "is not the core's own command name");
|
|
8454
9869
|
} else if (!CHANNEL_BINARY.test(manifest.binary)) issue(["binary"], "a channel's command is `agent-<something>`");
|
|
8455
9870
|
(manifest.server.bins ?? []).forEach((bin, index) => {
|
|
8456
9871
|
if (!CHANNEL_BINARY.test(bin)) issue([
|
|
@@ -8474,7 +9889,7 @@ strictObject({
|
|
|
8474
9889
|
if (rivals.word === void 0 && rivals.packages === void 0) issue(["rivals"], "a word or packages");
|
|
8475
9890
|
if (rivals.word === void 0 !== (rivals.can === void 0)) issue(["rivals"], "`word` and `can` come together: what a server matching the word can do unapproved");
|
|
8476
9891
|
}
|
|
8477
|
-
if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"],
|
|
9892
|
+
if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"], "must start with the channel's own command, its `binary`, and a space");
|
|
8478
9893
|
if (manifest.skills) {
|
|
8479
9894
|
const family = manifest.skills.prefix.slice(0, -1);
|
|
8480
9895
|
if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
|
|
@@ -8765,20 +10180,25 @@ function newWhatsAppAccount(options) {
|
|
|
8765
10180
|
* The configuration has names this channel can use.
|
|
8766
10181
|
*
|
|
8767
10182
|
* WhatsApp accounts are `organisation/whatsapp`, which only version 2 of the configuration names. A version-1 file is
|
|
8768
|
-
* one an older release on this machine may still share; `
|
|
8769
|
-
* channel adds and reads nothing rather than invent a flat name for a platform that never had one.
|
|
10183
|
+
* one an older release on this machine may still share; core's `names migrate` moves it on, and until then this
|
|
10184
|
+
* channel adds and reads nothing rather than invent a flat name for a platform that never had one. The refusal names
|
|
10185
|
+
* core's command as `handoffs` find it: the core this package installs.
|
|
8770
10186
|
*/
|
|
8771
|
-
function requireNamedConfig(config) {
|
|
8772
|
-
if (config.version !== 2) throw new CommsError("CONFIG", "WhatsApp accounts need the organisation/platform names, and this configuration still has the old flat ones", { hint:
|
|
10187
|
+
function requireNamedConfig(config, handoffs) {
|
|
10188
|
+
if (config.version !== 2) throw new CommsError("CONFIG", "WhatsApp accounts need the organisation/platform names, and this configuration still has the old flat ones", { hint: handoffSentence(handoffs.core([
|
|
10189
|
+
"names",
|
|
10190
|
+
"migrate",
|
|
10191
|
+
"--dry-run"
|
|
10192
|
+
]), (dryRun) => handoffSentence(handoffs.core(["names", "migrate"]), (migrate) => `Run ${dryRun} to see what everything would be called, then ${migrate}.`), { instead: "Call comms_names_migrate from a chat." }) });
|
|
8773
10193
|
}
|
|
8774
10194
|
/** Every WhatsApp account's name, sorted. Other platforms' accounts in the same map are not this channel's. */
|
|
8775
10195
|
function whatsappAccountNames(config) {
|
|
8776
10196
|
return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name]) => name).sort();
|
|
8777
10197
|
}
|
|
8778
|
-
function notFound(config, name) {
|
|
10198
|
+
function notFound(config, name, handoffs) {
|
|
8779
10199
|
return () => {
|
|
8780
10200
|
const known = whatsappAccountNames(config);
|
|
8781
|
-
return new CommsError("NOT_FOUND", `no WhatsApp account called "${name}"`, { hint: known.length ? `Known accounts: ${known.join(", ")}.` : "None yet: a person adds one with
|
|
10201
|
+
return new CommsError("NOT_FOUND", `no WhatsApp account called "${name}"`, { hint: known.length ? `Known accounts: ${known.join(", ")}.` : handoffSentenceToFill(handoffs.own(["add"]), ["<organisation>/whatsapp"], (command) => `None yet: a person adds one with ${command}.`) });
|
|
8782
10202
|
};
|
|
8783
10203
|
}
|
|
8784
10204
|
/**
|
|
@@ -8787,19 +10207,15 @@ function notFound(config, name) {
|
|
|
8787
10207
|
* Through core's `resolveName`, so a former name is refused with the name the account has now. Another platform's
|
|
8788
10208
|
* account under the name is "not found", as a missing one is. A record whose mode is not `read`, or whose `source`
|
|
8789
10209
|
* is not a store path this package would read, is refused rather than acted on: nothing here would know what it
|
|
8790
|
-
* means, and the vocabulary is closed (design §5).
|
|
10210
|
+
* means, and the vocabulary is closed (design §5). A refusal names this package's commands as `handoffs` find them.
|
|
8791
10211
|
*/
|
|
8792
|
-
function requireAccount(config, name,
|
|
10212
|
+
function requireAccount(config, name, handoffs) {
|
|
8793
10213
|
if (name === void 0 || name === "") throw new CommsError("USAGE", "which WhatsApp account? there is no default", { hint: "Pass the account, as `organisation/whatsapp`." });
|
|
8794
|
-
requireNamedConfig(config);
|
|
8795
|
-
const { account } = resolveName(config, "account", name, notFound(config, name));
|
|
8796
|
-
if (account.platform !== "whatsapp") throw notFound(config, name)();
|
|
10214
|
+
requireNamedConfig(config, handoffs);
|
|
10215
|
+
const { account } = resolveName(config, "account", name, notFound(config, name, handoffs));
|
|
10216
|
+
if (account.platform !== "whatsapp") throw notFound(config, name, handoffs)();
|
|
8797
10217
|
const mode = account.mode ?? account.tier;
|
|
8798
|
-
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with ${
|
|
8799
|
-
"agent-whatsapp",
|
|
8800
|
-
"remove",
|
|
8801
|
-
name
|
|
8802
|
-
], platform))} and add it again.` });
|
|
10218
|
+
if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. ${handoffSentence(handoffs.own(["remove", name]), (command) => `Remove it with ${command} and add it again.`)}` });
|
|
8803
10219
|
const source = account.source;
|
|
8804
10220
|
if (source !== void 0 && (typeof source !== "string" || basename(source) !== "ChatStorage.sqlite")) throw new CommsError("CONFIG", `"${name}" names a store this package does not read`, { hint: `Only a file called ${STORE_FILE} is read. Remove the account and add it again.` });
|
|
8805
10221
|
return {
|
|
@@ -9230,7 +10646,7 @@ function nodeSupportsSqlite(version = process.versions.node) {
|
|
|
9230
10646
|
/** Refuses to start on a Node too old for `node:sqlite`, saying which Node it is and what it needs. */
|
|
9231
10647
|
function requireSupportedNode(version = process.versions.node) {
|
|
9232
10648
|
if (nodeSupportsSqlite(version)) return;
|
|
9233
|
-
throw new CommsError("CONFIG", `
|
|
10649
|
+
throw new CommsError("CONFIG", `the WhatsApp channel needs Node ${MIN_NODE} or newer, and this is Node ${version}`, {
|
|
9234
10650
|
hint: `It reads WhatsApp's store with Node's own SQLite (node:sqlite), which Node ${MIN_NODE} is the first to have complete. Install a newer Node — the current 22 or 24 release — and run it again.`,
|
|
9235
10651
|
details: {
|
|
9236
10652
|
reason: "NODE_TOO_OLD",
|
|
@@ -9253,7 +10669,7 @@ function loadSqlite() {
|
|
|
9253
10669
|
return await import("node:sqlite");
|
|
9254
10670
|
} catch (error) {
|
|
9255
10671
|
throw new CommsError("CONFIG", `this Node (${process.versions.node}) has no usable node:sqlite`, {
|
|
9256
|
-
hint: `
|
|
10672
|
+
hint: `The WhatsApp channel needs Node ${MIN_NODE} or newer, run without flags that turn built-in modules off.`,
|
|
9257
10673
|
cause: error
|
|
9258
10674
|
});
|
|
9259
10675
|
} finally {
|
|
@@ -9450,19 +10866,22 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9450
10866
|
constructor(db) {
|
|
9451
10867
|
this.#db = db;
|
|
9452
10868
|
}
|
|
9453
|
-
/**
|
|
9454
|
-
|
|
10869
|
+
/**
|
|
10870
|
+
* Every read opens the index through here, and cannot without the account's lists. A refusal names the sync to run
|
|
10871
|
+
* as `handoffs` find it.
|
|
10872
|
+
*/
|
|
10873
|
+
static async open(directory, accountName, visibility, handoffs) {
|
|
9455
10874
|
const path = join(directory, INDEX_FILE);
|
|
10875
|
+
const sync = () => handoffs.own([
|
|
10876
|
+
"sync",
|
|
10877
|
+
"--account",
|
|
10878
|
+
accountName
|
|
10879
|
+
]);
|
|
9456
10880
|
try {
|
|
9457
10881
|
await stat(path);
|
|
9458
10882
|
} catch {
|
|
9459
10883
|
throw new CommsError("NOT_FOUND", `"${accountName}" has not been synced yet, so there is nothing to read`, {
|
|
9460
|
-
hint: `Run ${
|
|
9461
|
-
"agent-whatsapp",
|
|
9462
|
-
"sync",
|
|
9463
|
-
"--account",
|
|
9464
|
-
accountName
|
|
9465
|
-
], platform))} (or the whatsapp_sync tool) first.`,
|
|
10884
|
+
hint: handoffSentence(sync(), (command) => `Run ${command} (or the whatsapp_sync tool) first.`, { instead: "Call the whatsapp_sync tool first." }),
|
|
9466
10885
|
details: { reason: "NOT_SYNCED" }
|
|
9467
10886
|
});
|
|
9468
10887
|
}
|
|
@@ -9470,12 +10889,7 @@ var WhatsAppIndex = class WhatsAppIndex {
|
|
|
9470
10889
|
const db = await openDatabase(path, { readOnly: true });
|
|
9471
10890
|
if (db.prepare("SELECT value FROM meta WHERE key = 'format'").get()?.value !== String(2)) {
|
|
9472
10891
|
db.close();
|
|
9473
|
-
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run ${
|
|
9474
|
-
"agent-whatsapp",
|
|
9475
|
-
"sync",
|
|
9476
|
-
"--account",
|
|
9477
|
-
accountName
|
|
9478
|
-
], platform))} to rebuild it.` });
|
|
10892
|
+
throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: handoffSentence(sync(), (command) => `Run ${command} to rebuild it.`, { instead: "Call the whatsapp_sync tool to rebuild it." }) });
|
|
9479
10893
|
}
|
|
9480
10894
|
db.function("agent_sees_chat", { deterministic: true }, (id) => visibility.seesChat(String(id)) ? 1 : 0);
|
|
9481
10895
|
db.function("agent_kind", { deterministic: true }, (id) => chatKindOf(String(id)));
|
|
@@ -9752,8 +11166,8 @@ var ChatListStore = class {
|
|
|
9752
11166
|
* so a run that stopped before the rename finishes on the next one.
|
|
9753
11167
|
* - **Logged.** A line in core's audit log, and a line on stderr for the person when a command did it.
|
|
9754
11168
|
*
|
|
9755
|
-
* A version-1 configuration has no `organisation/whatsapp` names, so nothing moves until `
|
|
9756
|
-
*
|
|
11169
|
+
* A version-1 configuration has no `organisation/whatsapp` names, so nothing moves until core's `names migrate` has
|
|
11170
|
+
* run — named as the context's handoffs find core's command — and the file stays where it is for the next run.
|
|
9757
11171
|
*/
|
|
9758
11172
|
const SPIKE_CONFIG_FILE = "whatsapp-spike.json";
|
|
9759
11173
|
const spikeAccountSchema = looseObject({
|
|
@@ -9807,7 +11221,7 @@ async function migrateSpikeAccounts(context) {
|
|
|
9807
11221
|
const config = await context.core.config.load();
|
|
9808
11222
|
if (config.version !== 2) return {
|
|
9809
11223
|
...result,
|
|
9810
|
-
deferred:
|
|
11224
|
+
deferred: `the configuration still has the old flat names; ${handoffSentence(context.handoffs.core(["names", "migrate"]), (command) => `after ${command}, the spike’s accounts move on the next run`, { instead: "after comms_names_migrate, the spike’s accounts move on the next run." }).replace(/\.$/, "")}`
|
|
9811
11225
|
};
|
|
9812
11226
|
const moving = [];
|
|
9813
11227
|
const records = Object.entries(spike.data.accounts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -9945,6 +11359,8 @@ var WhatsAppContext = class {
|
|
|
9945
11359
|
surface;
|
|
9946
11360
|
platform;
|
|
9947
11361
|
core;
|
|
11362
|
+
/** The commands this tells a person to run, located, and quoted for this context's shell. */
|
|
11363
|
+
handoffs;
|
|
9948
11364
|
lists;
|
|
9949
11365
|
/** The name a pinned server was started for, as given. */
|
|
9950
11366
|
pinned;
|
|
@@ -9957,10 +11373,14 @@ var WhatsAppContext = class {
|
|
|
9957
11373
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
9958
11374
|
this.surface = options.surface ?? "cli";
|
|
9959
11375
|
this.platform = options.platform ?? process.platform;
|
|
9960
|
-
this.core = openCore({
|
|
11376
|
+
this.core = options.core ?? openCore({
|
|
9961
11377
|
env: this.env,
|
|
9962
|
-
now: this.now
|
|
11378
|
+
now: this.now,
|
|
11379
|
+
platform: this.platform,
|
|
11380
|
+
...options.pathOverrides ? { pathOverrides: options.pathOverrides } : {},
|
|
11381
|
+
caller: CALLER
|
|
9963
11382
|
});
|
|
11383
|
+
this.handoffs = requireHandoffs(this.core).on(this.platform);
|
|
9964
11384
|
this.lists = new ChatListStore(this.core.paths.configDir);
|
|
9965
11385
|
this.pinned = options.account;
|
|
9966
11386
|
this.#log = options.log;
|
|
@@ -9974,12 +11394,13 @@ var WhatsAppContext = class {
|
|
|
9974
11394
|
migration() {
|
|
9975
11395
|
this.#migration ??= migrateSpikeAccounts({
|
|
9976
11396
|
core: this.core,
|
|
11397
|
+
handoffs: this.handoffs,
|
|
9977
11398
|
lists: this.lists,
|
|
9978
11399
|
env: this.env,
|
|
9979
11400
|
now: this.now,
|
|
9980
11401
|
surface: this.surface
|
|
9981
11402
|
}).then((result) => {
|
|
9982
|
-
if (result) this.#log?.(`
|
|
11403
|
+
if (result) this.#log?.(`WhatsApp: ${describeMigration(result)}`);
|
|
9983
11404
|
return result;
|
|
9984
11405
|
});
|
|
9985
11406
|
return this.#migration;
|
|
@@ -9997,7 +11418,7 @@ var WhatsAppContext = class {
|
|
|
9997
11418
|
*/
|
|
9998
11419
|
async checkPin() {
|
|
9999
11420
|
if (this.pinned === void 0) return;
|
|
10000
|
-
this.#pinnedId = requireAccount(await this.config(), this.pinned, this.
|
|
11421
|
+
this.#pinnedId = requireAccount(await this.config(), this.pinned, this.handoffs).account.id;
|
|
10001
11422
|
}
|
|
10002
11423
|
/** The name a call acts on: the one it gave, checked against the pin when there is one. */
|
|
10003
11424
|
async #nameFor(config, named) {
|
|
@@ -10013,7 +11434,7 @@ var WhatsAppContext = class {
|
|
|
10013
11434
|
/** One account, resolved and checked, with the person's lists for it — which every read applies. */
|
|
10014
11435
|
async account(named) {
|
|
10015
11436
|
const config = await this.config();
|
|
10016
|
-
const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.
|
|
11437
|
+
const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.handoffs);
|
|
10017
11438
|
return {
|
|
10018
11439
|
name,
|
|
10019
11440
|
account,
|
|
@@ -10040,7 +11461,8 @@ var WhatsAppContext = class {
|
|
|
10040
11461
|
return {
|
|
10041
11462
|
...this.#source,
|
|
10042
11463
|
env: this.env,
|
|
10043
|
-
isDefault
|
|
11464
|
+
isDefault,
|
|
11465
|
+
handoffs: this.handoffs
|
|
10044
11466
|
};
|
|
10045
11467
|
}
|
|
10046
11468
|
/** The account's own directory under core's state directory: its index and its brief snapshots. */
|
|
@@ -10235,7 +11657,7 @@ async function draftMessage(context, request) {
|
|
|
10235
11657
|
for (const { name, account, lists } of scope) {
|
|
10236
11658
|
let index;
|
|
10237
11659
|
try {
|
|
10238
|
-
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.
|
|
11660
|
+
index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs);
|
|
10239
11661
|
} catch (error) {
|
|
10240
11662
|
if (scope.length > 1 && isCommsError(error) && error.details?.reason === "NOT_SYNCED") continue;
|
|
10241
11663
|
throw error;
|
|
@@ -10420,7 +11842,7 @@ async function openIndex(context, accountName) {
|
|
|
10420
11842
|
const { name, account, lists } = await context.account(accountName);
|
|
10421
11843
|
return {
|
|
10422
11844
|
name,
|
|
10423
|
-
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.
|
|
11845
|
+
index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs)
|
|
10424
11846
|
};
|
|
10425
11847
|
}
|
|
10426
11848
|
async function listChats(context, request) {
|
|
@@ -10560,7 +11982,7 @@ var SourceTimeout = class extends Error {
|
|
|
10560
11982
|
const stuckCalls = /* @__PURE__ */ new WeakMap();
|
|
10561
11983
|
function pendingError(options) {
|
|
10562
11984
|
return new CommsError("TRANSIENT", "reading WhatsApp’s message store is waiting for a macOS permission dialog", {
|
|
10563
|
-
hint: `Look for a dialog asking whether ${responsibleApp(options.env ?? {}) ?? "your terminal or MCP client"} may access data from other apps, choose Allow, then run this again. An MCP server cannot answer it: run
|
|
11985
|
+
hint: `Look for a dialog asking whether ${responsibleApp(options.env ?? {}) ?? "your terminal or MCP client"} may access data from other apps, choose Allow, then run this again. ${handoffSentence(options.handoffs.own(["status"]), (command) => `An MCP server cannot answer it: run ${command} in a terminal once, or grant Full Disk Access.`, { instead: "An MCP server cannot answer it: grant Full Disk Access." })}`,
|
|
10564
11986
|
details: { reason: "MACOS_PROMPT_PENDING" }
|
|
10565
11987
|
});
|
|
10566
11988
|
}
|
|
@@ -10600,7 +12022,11 @@ function sourceError(error, path, options) {
|
|
|
10600
12022
|
const code = error?.code;
|
|
10601
12023
|
const platform = options.platform ?? process.platform;
|
|
10602
12024
|
if (code === "ENOENT" || code === "ENOTDIR") return new CommsError("NOT_FOUND", `there is no WhatsApp message store at ${path}`, {
|
|
10603
|
-
hint: options.isDefault ?
|
|
12025
|
+
hint: options.isDefault ? `Is WhatsApp for Mac installed and signed in here? It creates this file once it has synced. ${handoffSentenceToFill(options.handoffs.own(["add"]), [
|
|
12026
|
+
"<organisation>/whatsapp",
|
|
12027
|
+
"--source",
|
|
12028
|
+
"<path to its ChatStorage.sqlite>"
|
|
12029
|
+
], (command) => `The WhatsApp Business app keeps its own, under group.net.whatsapp.WhatsAppSMB.shared: add it with ${command}.`, { instead: "The WhatsApp Business app keeps its own, under group.net.whatsapp.WhatsAppSMB.shared: a person adds that with add’s --source." })}` : "Check the path given with --source.",
|
|
10604
12030
|
details: {
|
|
10605
12031
|
reason: "NO_STORE",
|
|
10606
12032
|
path
|
|
@@ -10745,7 +12171,7 @@ async function removeStaleSnapshots(workDir) {
|
|
|
10745
12171
|
* The caller owns the result and must `dispose()` it: the copy is every message in the store, and it lives only as
|
|
10746
12172
|
* long as the import that reads it.
|
|
10747
12173
|
*/
|
|
10748
|
-
async function snapshotStore(storePath, workDir, options
|
|
12174
|
+
async function snapshotStore(storePath, workDir, options) {
|
|
10749
12175
|
checkStorePath(storePath);
|
|
10750
12176
|
const attempts = options.attempts ?? 5;
|
|
10751
12177
|
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -10799,7 +12225,7 @@ const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "latin1");
|
|
|
10799
12225
|
* Opening the file is what macOS's privacy check guards, so this is the cheapest honest test of access: a `stat` can
|
|
10800
12226
|
* succeed where an `open` is refused. The bytes are only compared with SQLite's magic header.
|
|
10801
12227
|
*/
|
|
10802
|
-
async function probeStore(storePath, options
|
|
12228
|
+
async function probeStore(storePath, options) {
|
|
10803
12229
|
checkStorePath(storePath);
|
|
10804
12230
|
const io = options.io ?? nodeSourceIo;
|
|
10805
12231
|
let header;
|
|
@@ -10860,7 +12286,7 @@ async function statusOf(context, named, check) {
|
|
|
10860
12286
|
}
|
|
10861
12287
|
let index = { synced: false };
|
|
10862
12288
|
try {
|
|
10863
|
-
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.
|
|
12289
|
+
const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.handoffs);
|
|
10864
12290
|
try {
|
|
10865
12291
|
const stats = opened.stats();
|
|
10866
12292
|
index = {
|
|
@@ -10903,7 +12329,7 @@ async function whatsappStatus(context, request) {
|
|
|
10903
12329
|
accounts,
|
|
10904
12330
|
reads: READS,
|
|
10905
12331
|
sends: SENDS,
|
|
10906
|
-
...accounts.length === 0 ? { setup: "
|
|
12332
|
+
...accounts.length === 0 ? { setup: handoffSentenceToFill(context.handoffs.own(["add"]), ["<organisation>/whatsapp"], (command) => `${command} — run by a person, in a terminal`) } : {},
|
|
10907
12333
|
...spike ? { spike: describeMigration(spike) } : {}
|
|
10908
12334
|
};
|
|
10909
12335
|
}
|
|
@@ -10911,8 +12337,14 @@ async function whatsappStatus(context, request) {
|
|
|
10911
12337
|
//#region src/operations/sync.ts
|
|
10912
12338
|
/** Builds tried before a sync whose lists keep changing under it gives up. */
|
|
10913
12339
|
const LIST_ROUNDS = 3;
|
|
10914
|
-
|
|
10915
|
-
|
|
12340
|
+
/** The account went while it synced; adding it again — this name, this store — is the person's, located here. */
|
|
12341
|
+
function removedWhileSyncing(context, name, account) {
|
|
12342
|
+
const add = [
|
|
12343
|
+
"add",
|
|
12344
|
+
name,
|
|
12345
|
+
...account.source === void 0 ? [] : ["--source", account.source]
|
|
12346
|
+
];
|
|
12347
|
+
return new CommsError("NOT_FOUND", `"${name}" was removed while it was being synced, so nothing was indexed`, { hint: handoffSentence(context.handoffs.own(add), (command) => `Add it again with ${command} to read it.`) });
|
|
10916
12348
|
}
|
|
10917
12349
|
function sameLists(a, b) {
|
|
10918
12350
|
return JSON.stringify([a.allow, a.deny]) === JSON.stringify([b.allow, b.deny]);
|
|
@@ -10933,7 +12365,7 @@ async function syncAccount(context, request) {
|
|
|
10933
12365
|
const directory = context.accountDir(account);
|
|
10934
12366
|
return withFileLock(context.syncLock(account), async () => {
|
|
10935
12367
|
let current = await context.accountById(account.id);
|
|
10936
|
-
if (!current) throw removedWhileSyncing(name);
|
|
12368
|
+
if (!current) throw removedWhileSyncing(context, name, account);
|
|
10937
12369
|
await ensurePrivateDir(directory);
|
|
10938
12370
|
const staleRemoved = await removeStaleSnapshots(directory);
|
|
10939
12371
|
const snapshot = await snapshotStore(store.path, directory, context.sourceOptions(store.isDefault));
|
|
@@ -10957,7 +12389,7 @@ async function syncAccount(context, request) {
|
|
|
10957
12389
|
copied: snapshot.copied
|
|
10958
12390
|
}, new Visibility(built.lists), async () => {
|
|
10959
12391
|
const now = await context.accountById(account.id);
|
|
10960
|
-
if (!now) throw removedWhileSyncing(built.name);
|
|
12392
|
+
if (!now) throw removedWhileSyncing(context, built.name, account);
|
|
10961
12393
|
current = now;
|
|
10962
12394
|
return sameLists(now.lists, built.lists);
|
|
10963
12395
|
});
|
|
@@ -10991,4 +12423,4 @@ async function syncAccount(context, request) {
|
|
|
10991
12423
|
});
|
|
10992
12424
|
}
|
|
10993
12425
|
//#endregion
|
|
10994
|
-
export { paint as $,
|
|
12426
|
+
export { paint as $, PATH_OPTIONS as A, colorEnabled as B, checkStorePath as C, CHANNEL_SERVERS as D, strictToolArguments as E, approvalsOf as F, handoffSentence as G, escapeForDisplay as H, approveChangeAtTerminal as I, isCommsError as J, handoffText as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CommsError as O, approvalKind as P, openCore as Q, childEnvironment as R, storeIdentity as S, expandSource as T, exemptFromUpdateGate as U, commandPathOf as V, gatedChangeAtTerminal as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, VERSION$1 as _t, listChats as a, sendApprovesHint as at, requireAccount as b, Presenter as c, toCommsError as ct, draftMessage as d, updateToolGate as dt, pathOverridesFromCliOptions as et, Visibility as f, windowsSystemProgram as ft, requireSupportedNode as g, PACKAGE_NAME as gt, WhatsAppIndex as h, CALLER as ht, CHAT_KINDS as i, runCommand as it, UNTRUSTED_NOTICE as j, EXIT_CODES as k, innerText as l, truncateDisplay as lt, WhatsAppContext as m, writeResult as mt, whatsappStatus as n, renderPrune as nt, readChat as o, serverInstallChange as ot, listKey as p, withFileLock as pt, installExitStatus as q, probeStore as r, resolvePaths as rt, searchMessages as s, serverPruneChange as st, syncAccount as t, renderInstall as tt, composeDraft as u, updateGateAtTerminal as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, cliHandoffs as z };
|