@agentcomms/whatsapp 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as gatedChangeAtTerminal, A as requireAccount, B as PATH_OPTIONS, Ct as CALLER, F as expandSource, G as approveChangeAtTerminal, H as absoluteSearchPath, J as cliHandoffs, K as canPrompt, L as CHANNEL_SERVERS, M as storeIdentity, N as checkStorePath, O as PLATFORM, P as defaultStorePath, Q as exemptFromUpdateNotice, R as CommsError, S as chatRefOf, St as writeResult, Tt as VERSION, U as agentMarker, W as approveAndWaitSentence, X as commandPathOf, Y as colorEnabled, Z as escapeForDisplay, _ as WhatsAppContext, _t as truncateDisplay, a as CHAT_KINDS, at as nameAvailable, b as requireSupportedNode, bt as windowsSystemProgram, c as searchMessages, ct as paint, dt as renderPrune, et as handoffSentence, f as draftMessage, ft as resolvePaths, ht as serverPruneChange, i as probeStore, it as lookupName, j as requireNamedConfig, k as newWhatsAppAccount, l as Presenter, lt as pathOverridesFromCliOptions, m as listKey, mt as serverInstallChange, nt as installExitStatus, o as listChats, ot as nameShapeProblem, p as Visibility, pt as runCommand, q as childEnvironment, r as whatsappStatus, rt as isCommsError, s as readChat, st as openCore, t as syncAccount, tt as handoffText, u as innerText, ut as renderInstall, v as WhatsAppIndex, vt as updateNoticeAtTerminal, xt as withFileLock, z as EXIT_CODES } from "./sync-Coys7_vB.mjs";
2
+ import { $ as gatedChangeAtTerminal, A as requireAccount, B as PATH_OPTIONS, Ct as CALLER, F as expandSource, G as approveChangeAtTerminal, H as absoluteSearchPath, J as cliHandoffs, K as canPrompt, L as CHANNEL_SERVERS, M as storeIdentity, N as checkStorePath, O as PLATFORM, P as defaultStorePath, Q as exemptFromUpdateNotice, R as CommsError, S as chatRefOf, St as writeResult, Tt as VERSION, U as agentMarker, W as approveAndWaitSentence, X as commandPathOf, Y as colorEnabled, Z as escapeForDisplay, _ as WhatsAppContext, _t as truncateDisplay, a as CHAT_KINDS, at as nameAvailable, b as requireSupportedNode, bt as windowsSystemProgram, c as searchMessages, ct as paint, dt as renderPrune, et as handoffSentence, f as draftMessage, ft as resolvePaths, ht as serverPruneChange, i as probeStore, it as lookupName, j as requireNamedConfig, k as newWhatsAppAccount, l as Presenter, lt as pathOverridesFromCliOptions, m as listKey, mt as serverInstallChange, nt as installExitStatus, o as listChats, ot as nameShapeProblem, p as Visibility, pt as runCommand, q as childEnvironment, r as whatsappStatus, rt as isCommsError, s as readChat, st as openCore, t as syncAccount, tt as handoffText, u as innerText, ut as renderInstall, v as WhatsAppIndex, vt as updateNoticeAtTerminal, xt as withFileLock, z as EXIT_CODES } from "./sync-Dqv3BF1I.mjs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import path, { delimiter, join } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
@@ -3569,7 +3569,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3569
3569
  ran = true;
3570
3570
  exitCode = await runCommand(output(), async () => {
3571
3571
  requireSupportedNode();
3572
- await (deps.startMcp ?? (await import("./stdio-entry-83qlMjK3.mjs")).startWhatsAppStdioServer)({
3572
+ await (deps.startMcp ?? (await import("./stdio-entry-BC_a0ubS.mjs")).startWhatsAppStdioServer)({
3573
3573
  env,
3574
3574
  platform,
3575
3575
  ...deps,
package/dist/index.d.mts CHANGED
@@ -4277,7 +4277,7 @@ declare namespace external_d_exports {
4277
4277
  export { $brand, $input, $output, BRAND, CompileOptions, GlobalMeta, INVALID, Infer$1 as Infer, IssueData, JSONType, NEVER, $RefinementCtx as RefinementCtx, SafeExtendShape, Schema, TimePrecision, TypeOf, ZodAny, ZodArray, ZodBase64, ZodBase64URL, ZodBigInt, ZodBigIntFormat, ZodBoolean, ZodCIDRv4, ZodCIDRv6, ZodCUID, ZodCUID2, ZodCatch, ZodCodec, ZodCoercedBigInt, ZodCoercedBoolean, ZodCoercedDate, ZodCoercedNumber, ZodCoercedString, ZodCompileAsyncError, ZodCompileUnsupportedError, ZodCreditCard, ZodCustom, ZodCustomStringFormat, ZodDate, ZodDefault, ZodDiscriminatedUnion, ZodE164, ZodEmail, ZodEmoji, ZodEnum, ZodError, ZodErrorMap, ZodExactOptional, ZodFile, ZodFirstPartySchemaTypes, ZodFirstPartyTypeKind, ZodFlattenedError, ZodFloat32, ZodFloat64, ZodFormattedError, ZodFunction, ZodGUID, ZodIBAN, ZodIPv4, ZodIPv6, ZodISODate, ZodISODateTime, ZodISODuration, ZodISOTime, ZodInstanceOf, ZodInt, ZodInt32, ZodInt64, ZodIntersection, ZodIssue, ZodIssueCode, ZodJSONSchema, ZodJSONSchemaInternals, ZodJWT, ZodKSUID, ZodLazy, ZodLiteral, ZodMAC, ZodMap, ZodNaN, ZodNanoID, ZodNever, ZodNonOptional, ZodNull, ZodNullable, ZodNumber, ZodNumberFormat, ZodObject, ZodOptional, ZodPipe, ZodPrefault, ZodPreprocess, ZodPromise, ZodRawShape$1 as ZodRawShape, ZodReadonly, ZodRealError, ZodRecord, ZodSafeParseError, ZodSafeParseResult, ZodSafeParseSuccess, ZodSchema, ZodSet, ZodStandardSchemaWithJSON, ZodString, ZodStringFormat, ZodSuccess, ZodSymbol, ZodTemplateLiteral, ZodTransform, ZodTuple, ZodType, ZodTypeAny, ZodUInt32, ZodUInt64, ZodULID, ZodURL, ZodUUID, ZodUndefined, ZodUnion, ZodUnknown, ZodVoid, ZodXID, ZodXor, _ZodBigInt, _ZodBoolean, _ZodDate, _ZodNumber, _ZodString, _ZodType, _default, _function, any, array, base64, base64url, bigint$1 as bigint, boolean$1 as boolean, _catch as catch, check, cidrv4, cidrv6, clone, codec, coerce_d_exports as coerce, compile, config, index_d_exports$1 as core, creditCard, cuid, cuid2, currencyCode, custom, date$2 as date, decode, decodeAsync, deepPartial, describe, discriminatedUnion, e164, email, emoji, encode, encodeAsync, _endsWith as endsWith, _enum as enum, exactOptional, file, flattenError, float32, float64, formatError, fromJSONSchema, _function as function, getDiscriminatedOption, getErrorMap, globalRegistry, _gt as gt, _gte as gte, guid, hash, hex, hostname, httpUrl, iban, _includes as includes, output$1 as infer, inferFlattenedErrors, inferFormattedError, input, _instanceof as instanceof, int, int32, int64, intersection, invertCodec, ipv4, ipv6, iso_d_exports as iso, json, jwt, keyof, ksuid, lazy, _length as length, literal, index_d_exports$2 as locales, looseObject, looseRecord, _lowercase as lowercase, _lt as lt, _lte as lte, mac, map, _maxLength as maxLength, _maxSize as maxSize, memoizer, meta, _mime as mime, _minLength as minLength, _minSize as minSize, _multipleOf as multipleOf, nan, nanoid, nativeEnum, _negative as negative, never, _nonnegative as nonnegative, nonoptional, _nonpositive as nonpositive, _normalize as normalize, _null as null, nullable, nullish, number$1 as number, object, optional, output, _overwrite as overwrite, parse, parseAsync, partialRecord, pipe, _positive as positive, prefault, preprocess, prettifyError, promise, _properties as properties, _property as property, readonly, record, refine, _regex as regex, regexes_d_exports as regexes, registry, safeDecode, safeDecodeAsync, safeEncode, safeEncodeAsync, safeParse, safeParseAsync, set, setErrorMap, _size as size, _slugify as slugify, _startsWith as startsWith, strictObject, string$1 as string, stringFormat, stringbool, success, superRefine, symbol, templateLiteral, toJSONSchema, _toLowerCase as toLowerCase, _toUpperCase as toUpperCase, toZod, transform, treeifyError, _trim as trim, tuple, uint32, uint64, ulid, _undefined as undefined, union, unknown, _uppercase as uppercase, url, util_d_exports as util, uuid, uuidv4, uuidv6, uuidv7, validate, validateAsync, _void as void, withParser, xid, xor };
4278
4278
  }
4279
4279
  //#endregion
4280
- //#region ../core/dist/launcher-VgihcM7Q.d.mts
4280
+ //#region ../core/dist/launcher-BuZRjGA8.d.mts
4281
4281
  //#endregion
4282
4282
  //#region src/config-hooks.d.ts
4283
4283
  /**
@@ -4370,7 +4370,7 @@ interface ResolvedPaths {
4370
4370
  */
4371
4371
  downloadsDir: string;
4372
4372
  }
4373
- type ErrorCode = "UNEXPECTED" | "APPROVAL_REQUIRED" | "APPROVAL_PENDING" | "APPROVAL_EXPIRED" | "APPROVAL_VOID" | "SEND_OUTCOME_UNKNOWN" | "SEND_REFUSED" | "POLICY_NEVER" | "RATE_CAPPED" | "UNSENDABLE_HTML" | "LOOSENING_REFUSED" | "USAGE" | "CURSOR_MISMATCH" | "BAD_DATA" | "REPLY_INVALID" | "NOT_FOUND" | "PROVIDER_UNAVAILABLE" | "SOURCE_UNAVAILABLE" | "SECRET_STORE_UNAVAILABLE" | "TRANSIENT" | "KEYCHAIN_APPROVAL_PENDING" | "LOCK_TIMEOUT" | "AUTH_REQUIRED" | "SCOPE_MISSING" | "WHATSAPP_VISIBILITY_SEAM_REQUIRED" | "CONFIG";
4373
+ type ErrorCode = "UNEXPECTED" | "APPROVAL_REQUIRED" | "APPROVAL_PENDING" | "APPROVAL_EXPIRED" | "APPROVAL_VOID" | "SEND_OUTCOME_UNKNOWN" | "SEND_REFUSED" | "POLICY_NEVER" | "RATE_CAPPED" | "UNSENDABLE_HTML" | "LOOSENING_REFUSED" | "NEEDS_NEWER_RELEASE" | "USAGE" | "CURSOR_MISMATCH" | "BAD_DATA" | "REPLY_INVALID" | "NOT_FOUND" | "PROVIDER_UNAVAILABLE" | "SOURCE_UNAVAILABLE" | "SECRET_STORE_UNAVAILABLE" | "TRANSIENT" | "KEYCHAIN_APPROVAL_PENDING" | "LOCK_TIMEOUT" | "AUTH_REQUIRED" | "SCOPE_MISSING" | "WHATSAPP_VISIBILITY_SEAM_REQUIRED" | "CONFIG";
4374
4374
  interface CommsErrorOptions {
4375
4375
  hint?: string;
4376
4376
  details?: Record<string, unknown>;
@@ -4852,6 +4852,18 @@ interface OrganisationRecord {
4852
4852
  } | undefined;
4853
4853
  /** The organisation's update caps, from the file beside its profile; absent when it names none. */
4854
4854
  updates?: OrganisationUpdates | undefined;
4855
+ /**
4856
+ * The entries of the profile's `extensions` this release did not know and skipped, being optional (design 2026-10-19,
4857
+ * X3): the release that first understands each, and the SHA-256 of the entry's canonical JSON — as
4858
+ * `Record<string, OrganisationIgnoredExtension>`. Kept in step with the profile. Absent when none was skipped.
4859
+ *
4860
+ * Held as the file holds it, whatever its shape, and read only through `ignoredListOf`, which keeps the well-formed
4861
+ * entries and names the rest: 0.21.0 is the first release to read it, so a shape a later release or a hand gave it
4862
+ * must never make the whole configuration unreadable, nor be dropped by an unrelated write. Losing it is safe: a
4863
+ * later release that understands an entry judges applying it by its classifier whether or not the list names it —
4864
+ * a tightening at once, anything else approved first, never a repair of drift (X4).
4865
+ */
4866
+ ignoredExtensions?: unknown;
4855
4867
  }
4856
4868
  interface ConfigV2 extends ConfigBody {
4857
4869
  version: 2;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { C as chatKindOf, D as text, E as numeric, R as CommsError, T as kindOf, Tt as VERSION, _ as WhatsAppContext, d as composeDraft, g as visibilityUnitKey, h as statusAuthor, m as listKey, n as withCheckedCopy, p as Visibility, w as coreDataToIso, wt as PACKAGE_NAME, x as inspectSchema, y as rebuildIndex } from "./sync-Coys7_vB.mjs";
2
- import { t as createWhatsAppMcpServer } from "./server-DP6vz_rw.mjs";
1
+ import { C as chatKindOf, D as text, E as numeric, R as CommsError, T as kindOf, Tt as VERSION, _ as WhatsAppContext, d as composeDraft, g as visibilityUnitKey, h as statusAuthor, m as listKey, n as withCheckedCopy, p as Visibility, w as coreDataToIso, wt as PACKAGE_NAME, x as inspectSchema, y as rebuildIndex } from "./sync-Dqv3BF1I.mjs";
2
+ import { t as createWhatsAppMcpServer } from "./server-BcUm8iL_.mjs";
3
3
  //#region src/source/event-reader.ts
4
4
  function rawFromMe(value) {
5
5
  const number = numeric(value);
@@ -1,4 +1,4 @@
1
- import { I as strictToolArguments, Tt as VERSION, V as UNTRUSTED_NOTICE, _ as WhatsAppContext, a as CHAT_KINDS, c as searchMessages, f as draftMessage, gt as toCommsError, o as listChats, r as whatsappStatus, s as readChat, t as syncAccount, yt as updateToolNotice } from "./sync-Coys7_vB.mjs";
1
+ import { I as strictToolArguments, Tt as VERSION, V as UNTRUSTED_NOTICE, _ as WhatsAppContext, a as CHAT_KINDS, c as searchMessages, f as draftMessage, gt as toCommsError, o as listChats, r as whatsappStatus, s as readChat, t as syncAccount, yt as updateToolNotice } from "./sync-Dqv3BF1I.mjs";
2
2
  import { c as boolean, h as number, x as string } from "./iso-USuVU_sk.mjs";
3
3
  import { t as McpServer } from "./mcp-DIH4cS6P-CIWWZQbz.mjs";
4
4
  //#region src/mcp/server.ts
@@ -1,4 +1,4 @@
1
- import { t as createWhatsAppMcpServer } from "./server-DP6vz_rw.mjs";
1
+ import { t as createWhatsAppMcpServer } from "./server-BcUm8iL_.mjs";
2
2
  //#region src/mcp/stdio-entry.ts
3
3
  /**
4
4
  * Starting the stdio server. On stdio, stdout is the protocol, so the console is redirected to stderr first — the
@@ -11,7 +11,7 @@ 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$2 = "0.20.0";
14
+ const VERSION$2 = "0.21.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -28,7 +28,7 @@ const CALLER = Object.freeze({
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
30
  //#endregion
31
- //#region ../core/dist/update-check-N_-uYKZQ.mjs
31
+ //#region ../core/dist/update-check-CrKGPUCv.mjs
32
32
  /**
33
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -106,6 +106,11 @@ const ERROR_REGISTRY = {
106
106
  retryable: false,
107
107
  summary: "a safety setting can only be loosened by a person"
108
108
  },
109
+ NEEDS_NEWER_RELEASE: {
110
+ exit: EXIT_CODES.UNEXPECTED,
111
+ retryable: false,
112
+ summary: "this needs a later agent-communications; update first, then run it again"
113
+ },
109
114
  USAGE: {
110
115
  exit: EXIT_CODES.USAGE,
111
116
  retryable: false,
@@ -3696,12 +3701,32 @@ function emptyReceipt() {
3696
3701
  };
3697
3702
  }
3698
3703
  async function readReceipt(context) {
3699
- let parsed;
3704
+ let text;
3705
+ try {
3706
+ text = await readFile(receiptPath(context), "utf8");
3707
+ } catch (error) {
3708
+ return error.code === "ENOENT" ? emptyReceipt() : null;
3709
+ }
3710
+ return receiptFrom(text);
3711
+ }
3712
+ /** `readReceipt`, for a reader that cannot wait: the command locator. */
3713
+ function readReceiptSync(context) {
3714
+ let text;
3700
3715
  try {
3701
- parsed = JSON.parse(await readFile(receiptPath(context), "utf8"));
3716
+ text = readFileSync(receiptPath(context), "utf8");
3702
3717
  } catch (error) {
3703
3718
  return error.code === "ENOENT" ? emptyReceipt() : null;
3704
3719
  }
3720
+ return receiptFrom(text);
3721
+ }
3722
+ /** The receipt a file holds, or null when it is not one this installation writes. */
3723
+ function receiptFrom(text) {
3724
+ let parsed;
3725
+ try {
3726
+ parsed = JSON.parse(text);
3727
+ } catch {
3728
+ return null;
3729
+ }
3705
3730
  if (typeof parsed !== "object" || parsed === null || parsed.version !== RECEIPT_VERSION || typeof parsed.shims !== "object" || parsed.shims === null) return null;
3706
3731
  const shims = {};
3707
3732
  for (const [binary, value] of Object.entries(parsed.shims)) {
@@ -3863,6 +3888,31 @@ function directoryOnPath(context, directory) {
3863
3888
  function pathNotice(directory) {
3864
3889
  return `The shim directory ${directory} is not on PATH. For this shell, run: export PATH=${posixWord(directory)}:"$PATH". No shell profile was edited.`;
3865
3890
  }
3891
+ /**
3892
+ * The managed shim for `binary` that starts `entry`, for a reader that cannot wait — the command locator, which names
3893
+ * it in place of a versioned runtime's entry when this installation has no stable launcher for it (0.21): its path as a
3894
+ * person puts it on PATH, or null.
3895
+ *
3896
+ * Only a shim this installation's receipt records at that very place, whose file is still the one it wrote, unchanged,
3897
+ * and which starts `entry` — the same file — with a Node that is there: what it runs now is exactly what the command it
3898
+ * stands in for would run. Never on a platform that has no shims, and never a file anyone else wrote.
3899
+ */
3900
+ function managedShimStarting(context, binary, entry) {
3901
+ if (!managedShimsSupported(context) || !isSafeBinary(binary)) return null;
3902
+ try {
3903
+ const publicPath = managedShimPath(context, binary);
3904
+ const path = join(realpathSync(dirname(publicPath)), basename(publicPath));
3905
+ const recorded = readReceiptSync(context)?.shims[binary];
3906
+ if (recorded === void 0 || recorded.path !== path) return null;
3907
+ if (!lstatSync(path).isFile() || sha256(readFileSync(path, "utf8")) !== recorded.digest) return null;
3908
+ if (realpathSync(recorded.entry) !== realpathSync(entry)) return null;
3909
+ accessSync(recorded.node, constants$1.X_OK);
3910
+ accessSync(path, constants$1.X_OK);
3911
+ return publicPath;
3912
+ } catch {
3913
+ return null;
3914
+ }
3915
+ }
3866
3916
  /** Create or repoint one shim, but only while its target is free or still matches this installation's receipt. */
3867
3917
  async function writeManagedShim(context, spec) {
3868
3918
  checkedBinary(spec.binary);
@@ -3989,6 +4039,111 @@ async function removeManagedShimsForRuntimes(context, runtimes, options = {}) {
3989
4039
  };
3990
4040
  });
3991
4041
  }
4042
+ /**
4043
+ * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
4044
+ * to decide whether the release it last heard of is newer than the one running.
4045
+ *
4046
+ * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
4047
+ * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
4048
+ * all of it, so nothing that imported these from there changes.
4049
+ */
4050
+ /**
4051
+ * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
4052
+ *
4053
+ * Anything the registry answers is checked against this before it is used, because a version goes into a directory
4054
+ * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
4055
+ * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
4056
+ */
4057
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
4058
+ function isVersion(value) {
4059
+ return typeof value === "string" && VERSION_PATTERN.test(value);
4060
+ }
4061
+ /** Two numeric strings with no leading zeros, compared as numbers of any size. */
4062
+ function compareNumeric(a, b) {
4063
+ if (a.length !== b.length) return a.length < b.length ? -1 : 1;
4064
+ return a < b ? -1 : a > b ? 1 : 0;
4065
+ }
4066
+ /**
4067
+ * Semver precedence: -1, 0 or 1, or null when either is not a version.
4068
+ *
4069
+ * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
4070
+ * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
4071
+ * few packages as it can.
4072
+ */
4073
+ function compareVersions(a, b) {
4074
+ const left = VERSION_PATTERN.exec(a);
4075
+ const right = VERSION_PATTERN.exec(b);
4076
+ if (!left || !right) return null;
4077
+ for (const index of [
4078
+ 1,
4079
+ 2,
4080
+ 3
4081
+ ]) {
4082
+ const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
4083
+ if (order !== 0) return order < 0 ? -1 : 1;
4084
+ }
4085
+ const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
4086
+ const [ours, theirs] = [pre(left), pre(right)];
4087
+ if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
4088
+ for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
4089
+ const x = ours[index];
4090
+ const y = theirs[index];
4091
+ if (x === void 0) return -1;
4092
+ if (y === void 0) return 1;
4093
+ const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
4094
+ if (numericX && numericY) {
4095
+ const order = compareNumeric(x, y);
4096
+ if (order !== 0) return order < 0 ? -1 : 1;
4097
+ } else if (numericX !== numericY) return numericX ? -1 : 1;
4098
+ else if (x !== y) return x < y ? -1 : 1;
4099
+ }
4100
+ return 0;
4101
+ }
4102
+ /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
4103
+ function isBehind(version, latest) {
4104
+ if (version === latest) return false;
4105
+ const order = compareVersions(version, latest);
4106
+ return order === null ? true : order < 0;
4107
+ }
4108
+ /**
4109
+ * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
4110
+ * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
4111
+ */
4112
+ /**
4113
+ * A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
4114
+ * `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
4115
+ * text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
4116
+ */
4117
+ function isPlainVersion(value) {
4118
+ return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
4119
+ }
4120
+ /**
4121
+ * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
4122
+ *
4123
+ * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
4124
+ * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
4125
+ * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
4126
+ * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
4127
+ */
4128
+ function satisfiesRange(version, range) {
4129
+ const wanted = version.replace(/^v/, "");
4130
+ if (!isVersion(wanted)) return null;
4131
+ const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
4132
+ if (alternatives.some((comparators) => comparators.length === 0)) return null;
4133
+ let any = false;
4134
+ for (const comparators of alternatives) {
4135
+ let all = true;
4136
+ for (const comparator of comparators) {
4137
+ const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
4138
+ const bound = match?.[2] ?? "";
4139
+ if (!isVersion(bound)) return null;
4140
+ const order = compareVersions(wanted, bound);
4141
+ if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
4142
+ }
4143
+ if (all) any = true;
4144
+ }
4145
+ return any;
4146
+ }
3992
4147
  /** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
3993
4148
  const PIN_OPTIONS = Object.freeze([
3994
4149
  "account",
@@ -5207,6 +5362,10 @@ async function listManagedRuntimes(dataDir, packageName) {
5207
5362
  * - only directories directly inside `<data>/runtime`, named like a version, that hold this product's package —
5208
5363
  * never a symbolic link, never another product's runtime, never anything else in that directory;
5209
5364
  * - never this release's own;
5365
+ * - never one newer than this release, or than the runtime its stable launcher starts: what an update is installing,
5366
+ * or checking before it switches, is newer than anything a pointer names yet. The update lock keeps a person's
5367
+ * prune from running beside an update (`serverPruneChange`); this keeps that runtime even when no lock is held —
5368
+ * an update interrupted half-way, a prune that is not a person's;
5210
5369
  * - never one an entry names in a client config `scanRegisteredServers` reads, in any scope — the ones this
5211
5370
  * environment names, and every one an install recorded registering into, wherever `CLAUDE_CONFIG_DIR` or
5212
5371
  * `CODEX_HOME` pointed then. When one of those files is there and cannot be read, nothing is removed at all: a
@@ -5284,10 +5443,18 @@ async function pruneManagedRuntimes(context, product, options = {}) {
5284
5443
  return null;
5285
5444
  };
5286
5445
  const current = managedRuntimeDir(context.core.paths.dataDir, product.packageName, product.version);
5446
+ const starts = pointer === void 0 ? null : launcherRuntimeVersion(pointer.current, name);
5447
+ /** Why a runtime newer than this release, or than what the launcher starts, stays; null for any other. */
5448
+ const newer = (version) => {
5449
+ const installing = "an update may be installing it, or checking it before it switches";
5450
+ if (compareVersions(version, product.version) === 1) return `newer than this release, ${product.version}: ${installing}`;
5451
+ if (starts !== null && compareVersions(version, starts) === 1) return `newer than ${starts}, which the stable launcher starts: ${installing}`;
5452
+ return null;
5453
+ };
5287
5454
  for (const { path, version, aliases } of candidates) {
5288
5455
  const owner = registered.find((server) => [server.command, ...server.args].some((part) => mentions(aliases, part)));
5289
5456
  const printed = options.includePrinted ? void 0 : handedOut.find((record) => !record.applied && mentions(aliases, record.runtime));
5290
- const reason = path === current ? "this release" : pointed(aliases) ?? (owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null);
5457
+ const reason = path === current ? "this release" : pointed(aliases) ?? newer(version) ?? (owner ? `registered with ${owner.client} as "${owner.name}"${known.has(owner.path) ? "" : ` in ${owner.path}`}` : printed ? `printed for ${printed.client} as "${printed.name}" on ${printed.at.slice(0, 10)}, and where that entry went cannot be read; once it is gone, \`mcp prune --include-printed\` removes this` : processes.some((line) => mentions(aliases, line)) ? "a running process uses it" : null);
5291
5458
  if (reason) {
5292
5459
  result.kept.push({
5293
5460
  path,
@@ -6013,7 +6180,8 @@ const organisationRecordSchema = looseObject({
6013
6180
  max: string().min(1).optional(),
6014
6181
  background: boolean().optional(),
6015
6182
  sha256: string().min(1)
6016
- }).optional()
6183
+ }).optional(),
6184
+ ignoredExtensions: unknown().optional()
6017
6185
  });
6018
6186
  const formerNameSchema = looseObject({
6019
6187
  name: nameSchema,
@@ -6131,6 +6299,25 @@ function schemaFor(version, naming = 2) {
6131
6299
  function schemaOf(config) {
6132
6300
  return schemaFor(config.version, config.version === 3 ? config.naming : void 0);
6133
6301
  }
6302
+ function connectedAccounts(config) {
6303
+ const mail = Object.entries(config.inboxes).map(([alias, inbox]) => ({
6304
+ kind: "mail",
6305
+ alias,
6306
+ id: inbox.id,
6307
+ platform: inbox.provider,
6308
+ secretRef: inbox.secretRef,
6309
+ inbox
6310
+ }));
6311
+ const channel = Object.entries(config.accounts).map(([alias, account]) => ({
6312
+ kind: "channel",
6313
+ alias,
6314
+ id: account.id,
6315
+ platform: account.platform,
6316
+ secretRef: account.secretRef,
6317
+ account
6318
+ }));
6319
+ return [...mail, ...channel].sort((a, b) => a.alias.localeCompare(b.alias));
6320
+ }
6134
6321
  /** The secret backend in use: the recorded one, or the keychain before anything has been stored. */
6135
6322
  function secretsStoreOf(config) {
6136
6323
  return config.secrets?.store ?? "keychain";
@@ -6791,7 +6978,7 @@ function classifyChange(before, after) {
6791
6978
  if (before.secrets === void 0 && after.secrets !== void 0 && after.secrets.store !== "keychain") {
6792
6979
  if (holdsSecrets(before)) loosen("secrets.store", secretsStoreOf(before), after.secrets.store);
6793
6980
  }
6794
- for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was), labelSettingValue(loosening, loosening.now));
6981
+ for (const loosening of classifyAgentLabelChange(agentLabelOf(before), agentLabelOf(after))) loosen(loosening.path, labelSettingValue(loosening, loosening.was, before), labelSettingValue(loosening, loosening.now, after));
6795
6982
  return {
6796
6983
  loosened: changes.map((change) => change.path),
6797
6984
  changes
@@ -6807,13 +6994,21 @@ function agentLabelRuleText(rule) {
6807
6994
  return `${rule.account}${where}${kinds}: ${rule.as}`;
6808
6995
  }
6809
6996
  /**
6810
- * A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles as they are, the
6811
- * rest as text.
6997
+ * A label setting's value as a loosening carries it: the rules in words, the icons and the Slack styles by the name of
6998
+ * the workspace each is for, in `config` — the side the value was read from — and the rest as text.
6999
+ *
7000
+ * Icons and styles are stored by the account's id, so they follow a rename; a preview names the workspace as the person
7001
+ * knows it, `acme/slack`, never its id, which they have never seen (0.21). The ids are still what an approval binds:
7002
+ * `changedSettings` records the stored values as they are. An id that names no connected account — one removed since —
7003
+ * has no other name, and keeps its id.
6812
7004
  */
6813
- function labelSettingValue(loosening, value) {
7005
+ function labelSettingValue(loosening, value, config) {
6814
7006
  if (value === void 0 || value === null) return void 0;
6815
7007
  if (loosening.path === "agentLabel.rules") return value.map(agentLabelRuleText);
6816
- if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") return { ...value };
7008
+ if (loosening.path === "agentLabel.slackIcons" || loosening.path === "agentLabel.slackStyles") {
7009
+ const accounts = connectedAccounts(config);
7010
+ return Object.fromEntries(Object.entries(value).map(([id, entry]) => [accounts.find((account) => account.id === id)?.alias ?? id, entry]));
7011
+ }
6817
7012
  return String(value);
6818
7013
  }
6819
7014
  /** The label settings a change can write, as the file holds them — raw, as `changedSettings` records every setting. */
@@ -10081,112 +10276,7 @@ function folding(platform) {
10081
10276
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
10082
10277
  }
10083
10278
  /** The package version, read from package.json at build time. */
10084
- const VERSION = "0.20.0";
10085
- /**
10086
- * Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
10087
- * to decide whether the release it last heard of is newer than the one running.
10088
- *
10089
- * Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
10090
- * every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
10091
- * all of it, so nothing that imported these from there changes.
10092
- */
10093
- /**
10094
- * A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
10095
- *
10096
- * Anything the registry answers is checked against this before it is used, because a version goes into a directory
10097
- * name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
10098
- * mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
10099
- */
10100
- 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-]+)*))?$/;
10101
- function isVersion(value) {
10102
- return typeof value === "string" && VERSION_PATTERN.test(value);
10103
- }
10104
- /** Two numeric strings with no leading zeros, compared as numbers of any size. */
10105
- function compareNumeric(a, b) {
10106
- if (a.length !== b.length) return a.length < b.length ? -1 : 1;
10107
- return a < b ? -1 : a > b ? 1 : 0;
10108
- }
10109
- /**
10110
- * Semver precedence: -1, 0 or 1, or null when either is not a version.
10111
- *
10112
- * Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
10113
- * counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
10114
- * few packages as it can.
10115
- */
10116
- function compareVersions(a, b) {
10117
- const left = VERSION_PATTERN.exec(a);
10118
- const right = VERSION_PATTERN.exec(b);
10119
- if (!left || !right) return null;
10120
- for (const index of [
10121
- 1,
10122
- 2,
10123
- 3
10124
- ]) {
10125
- const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
10126
- if (order !== 0) return order < 0 ? -1 : 1;
10127
- }
10128
- const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
10129
- const [ours, theirs] = [pre(left), pre(right)];
10130
- if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
10131
- for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
10132
- const x = ours[index];
10133
- const y = theirs[index];
10134
- if (x === void 0) return -1;
10135
- if (y === void 0) return 1;
10136
- const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
10137
- if (numericX && numericY) {
10138
- const order = compareNumeric(x, y);
10139
- if (order !== 0) return order < 0 ? -1 : 1;
10140
- } else if (numericX !== numericY) return numericX ? -1 : 1;
10141
- else if (x !== y) return x < y ? -1 : 1;
10142
- }
10143
- return 0;
10144
- }
10145
- /** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
10146
- function isBehind(version, latest) {
10147
- if (version === latest) return false;
10148
- const order = compareVersions(version, latest);
10149
- return order === null ? true : order < 0;
10150
- }
10151
- /**
10152
- * Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
10153
- * machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
10154
- */
10155
- /**
10156
- * A release as this suite publishes one, and the only form a version the registry gave is read or said in: a plain
10157
- * `MAJOR.MINOR.PATCH` (re-review of 0.20.0, severity 6). A prerelease's words and build metadata are the registry's
10158
- * text — `0.20.1+SYSTEM-ignore-…` is valid semver — so a version carrying either is no release here.
10159
- */
10160
- function isPlainVersion(value) {
10161
- return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value);
10162
- }
10163
- /**
10164
- * Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
10165
- *
10166
- * Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
10167
- * all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
10168
- * Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
10169
- * is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
10170
- */
10171
- function satisfiesRange(version, range) {
10172
- const wanted = version.replace(/^v/, "");
10173
- if (!isVersion(wanted)) return null;
10174
- const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
10175
- if (alternatives.some((comparators) => comparators.length === 0)) return null;
10176
- let any = false;
10177
- for (const comparators of alternatives) {
10178
- let all = true;
10179
- for (const comparator of comparators) {
10180
- const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
10181
- const bound = match?.[2] ?? "";
10182
- if (!isVersion(bound)) return null;
10183
- const order = compareVersions(wanted, bound);
10184
- if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
10185
- }
10186
- if (all) any = true;
10187
- }
10188
- return any;
10189
- }
10279
+ const VERSION = "0.21.0";
10190
10280
  /**
10191
10281
  * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
10192
10282
  *
@@ -10317,7 +10407,13 @@ function locateCliCommand(request, runtime = {
10317
10407
  } catch (error) {
10318
10408
  return fail("arguments", error instanceof Error ? error.message : String(error));
10319
10409
  }
10320
- const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform, program);
10410
+ const build = (found) => stableWayIn(buildCommand(target, found, words, runtime, callerSource, platform, program), found, target, {
10411
+ paths: request.paths,
10412
+ env: request.env,
10413
+ words,
10414
+ platform,
10415
+ program
10416
+ });
10321
10417
  const located = (built, found, direction) => Object.freeze({
10322
10418
  ok: true,
10323
10419
  command: built.command,
@@ -10503,6 +10599,70 @@ function buildCommand(target, found, words, runtime, callerSource, platform, pro
10503
10599
  };
10504
10600
  }
10505
10601
  /**
10602
+ * A managed runtime's command, as its stable way in (0.21). A command naming `<data>/runtime/0.20.0-core/…/cli.mjs`
10603
+ * stops working once that runtime is pruned — and the background job prunes old runtimes after each update — so a
10604
+ * command printed from a managed runtime of this installation names what stays put instead:
10605
+ *
10606
+ * - **its stable launcher**, `<data>/launch/<name>` (`launcherPaths`), when it is there and its pointer names a runtime
10607
+ * holding this package at exactly this release, whose CLI can start: the launcher, then the words, as a registration
10608
+ * starts it — on Windows, where the launcher is a script Node runs, this Node first;
10609
+ * - else **its managed CLI shim**, where shims are offered, when this installation's receipt records one that is
10610
+ * unchanged and starts this very entry (`managedShimStarting`): a release before the launcher wrote such shims, and
10611
+ * an update rewrites them;
10612
+ * - else the runtime's entry, as before.
10613
+ *
10614
+ * Only for a package inside `<data>/runtime` of the folders this command pins: a checkout, an npx cache, a global
10615
+ * install or another data folder's runtime is named as it always was. Checked when the command is printed (D6), as the
10616
+ * entry is: a launcher switched later runs what it is then switched to, as every registration that starts it does.
10617
+ */
10618
+ function stableWayIn(built, found, target, printing) {
10619
+ if (!built.ok || found.source) return built;
10620
+ const { paths, words, platform, program } = printing;
10621
+ const runtimes = realpathOfExisting(join(paths.dataDir, "runtime"));
10622
+ if (runtimes === null || !isWithin$1(runtimes, found.root) || resolve(runtimes) === resolve(found.root)) return built;
10623
+ const launcher = stableLauncher(paths.dataDir, target.packageName, found.manifest.version, platform);
10624
+ if (launcher !== null) return {
10625
+ ok: true,
10626
+ command: new PrintedCommand(GATE, platform === "win32" ? [
10627
+ program,
10628
+ launcher,
10629
+ ...words
10630
+ ] : [launcher, ...words], platform, realpathOfExisting(launcher) ?? launcher),
10631
+ entryKind: "launcher"
10632
+ };
10633
+ const shim = managedShimStarting({
10634
+ env: printing.env ?? process.env,
10635
+ dataDir: paths.dataDir,
10636
+ platform
10637
+ }, target.manifest.binary, built.command.entry);
10638
+ if (shim === null) return built;
10639
+ return {
10640
+ ok: true,
10641
+ command: new PrintedCommand(GATE, [shim, ...words], platform, realpathOfExisting(shim) ?? shim),
10642
+ entryKind: "shim"
10643
+ };
10644
+ }
10645
+ /**
10646
+ * This installation's stable launcher for `packageName`, when it starts that package at exactly `version`: the file is
10647
+ * there (executable, where it is a shell script), its pointer is one the launcher reads, and the runtime it names holds
10648
+ * the package at that version with a CLI the launcher can start. Null otherwise — a pointer that cannot be read too.
10649
+ */
10650
+ function stableLauncher(dataDir, packageName, version, platform) {
10651
+ if (typeof version !== "string") return null;
10652
+ try {
10653
+ const name = launcherName(packageName);
10654
+ const { launcher } = launcherPaths(dataDir, name);
10655
+ if (!statSync(launcher).isFile()) return null;
10656
+ accessSync(launcher, platform === "win32" ? constants$1.R_OK : constants$1.X_OK);
10657
+ const pointer = readPointerSync(dataDir, name);
10658
+ if (pointer === void 0 || !launcherCanStartSync(pointer.current, name)) return null;
10659
+ const started = readPackageManifest(join(pointer.current, "node_modules", ...packageName.split("/")));
10660
+ return started?.name === packageName && started.version === version ? launcher : null;
10661
+ } catch {
10662
+ return null;
10663
+ }
10664
+ }
10665
+ /**
10506
10666
  * The command a package declares for itself: a channel's in its `agentcomms` field, a service's in `agentcommsPackage`
10507
10667
  * (D14). A library declares none, and no package declares both: the registry refuses one that does.
10508
10668
  */
@@ -11005,8 +11165,30 @@ function renderInstall(result, color) {
11005
11165
  for (const warning of result.warnings) lines.push("", paint(color, "red", warning));
11006
11166
  return lines.join("\n");
11007
11167
  }
11008
- /** What `mcp prune` removed and kept, and why each one it kept is still needed. */
11168
+ /**
11169
+ * What `mcp prune` removed and kept, and why each one it kept is still needed. A prune of every managed server lists
11170
+ * each server that had a runtime under its own name, in the registry's order.
11171
+ */
11009
11172
  function renderPrune(result, color) {
11173
+ if ("servers" in result) {
11174
+ const groups = result.servers.map((part) => ({
11175
+ label: part.label,
11176
+ lines: pruneLines(part, color)
11177
+ })).filter((group) => group.lines.length > 0).map((group) => [`${group.label}:`, ...group.lines.map((line) => ` ${line}`)].join("\n"));
11178
+ if (groups.length === 0) return `No managed runtimes to remove in ${result.runtimeDir}.`;
11179
+ const unchecked = (result.refusedServers ?? []).map(({ label, refused }) => `${label} (${refused})`);
11180
+ if (unchecked.length > 0) {
11181
+ const its = unchecked.length === 1 ? "its" : "their";
11182
+ groups.push(paint(color, "yellow", `Not checked, so none of ${its} runtimes was removed: ${unchecked.join("; ")}.`));
11183
+ }
11184
+ return groups.join("\n");
11185
+ }
11186
+ const lines = pruneLines(result, color);
11187
+ if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
11188
+ return lines.join("\n");
11189
+ }
11190
+ /** One server's prune, a line a runtime or shim; none when it had nothing to look at. */
11191
+ function pruneLines(result, color) {
11010
11192
  const lines = [];
11011
11193
  if (result.refused) lines.push(paint(color, "yellow", `Nothing removed: ${result.refused}.`));
11012
11194
  const verb = result.dryRun ? "Would remove" : "Removed";
@@ -11014,8 +11196,7 @@ function renderPrune(result, color) {
11014
11196
  for (const path of result.shims.removed) lines.push(paint(color, "green", `${verb} CLI shim: ${path}`));
11015
11197
  for (const path of result.shims.kept) lines.push(`Kept foreign CLI shim: ${path}`);
11016
11198
  for (const item of result.kept) lines.push(`Kept ${item.version} (${item.reason}): ${item.path}`);
11017
- if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
11018
- return lines.join("\n");
11199
+ return lines;
11019
11200
  }
11020
11201
  /**
11021
11202
  * What a change is about: the inbox or account by the id it has before the change, or by name alone when this change
@@ -11109,7 +11290,7 @@ async function prepareChange(core, request, options) {
11109
11290
  preview: renderChangePreview({
11110
11291
  ...record,
11111
11292
  change: binding
11112
- }),
11293
+ }, { askedHere: options.askedHere === true }),
11113
11294
  expiresAt: record.expiresAt,
11114
11295
  next: nextStep(record.approvalId, policy, core, options),
11115
11296
  approval: await core.approvals.approvalOf(record)
@@ -11199,7 +11380,7 @@ async function beginChangeApproval(core, approvalId, options) {
11199
11380
  const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
11200
11381
  return {
11201
11382
  approvalId,
11202
- preview: renderChangePreview(record),
11383
+ preview: renderChangePreview(record, { codeHere: true }),
11203
11384
  challenge
11204
11385
  };
11205
11386
  } catch (error) {
@@ -11379,6 +11560,31 @@ function shown(value) {
11379
11560
  if (typeof value === "object") return Object.entries(value).map(([key, entry]) => `${key} ${String(entry)}`).join(", ");
11380
11561
  return String(value);
11381
11562
  }
11563
+ /**
11564
+ * The label settings kept for each Slack workspace, by its name (`labelSettingValue`), and what a workspace with none
11565
+ * has: its style is `app`, and the app's posts show the person's photo.
11566
+ */
11567
+ const PER_WORKSPACE = {
11568
+ "agentLabel.slackStyles": "app",
11569
+ "agentLabel.slackIcons": "your Slack photo"
11570
+ };
11571
+ function recordOf$1(value) {
11572
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
11573
+ }
11574
+ /**
11575
+ * A setting kept for each workspace, as the person reads it: each workspace it moves, by name, from → to —
11576
+ * `in acme/slack: app → labelled` — and none it leaves as it was. Undefined for any other setting, or a value of
11577
+ * another shape, which is shown as every other setting is.
11578
+ */
11579
+ function perWorkspace(field, before, after) {
11580
+ const none = PER_WORKSPACE[field];
11581
+ const [was, now] = [recordOf$1(before ?? {}), recordOf$1(after ?? {})];
11582
+ if (none === void 0 || was === void 0 || now === void 0) return void 0;
11583
+ const value = (record, name) => Object.hasOwn(record, name) ? String(record[name]) : none;
11584
+ const moved = [.../* @__PURE__ */ new Set([...Object.keys(was), ...Object.keys(now)])].filter((name) => value(was, name) !== value(now, name)).sort();
11585
+ if (moved.length === 0) return void 0;
11586
+ return moved.map((name) => `in ${name}: ${value(was, name)} → ${value(now, name)}`).join(", and ");
11587
+ }
11382
11588
  /** One loosening as a line a person reads: what, from, to, and what that means. */
11383
11589
  function describeLoosening(loosening) {
11384
11590
  const scoped = /^(inboxes|accounts)\.([^.]+)\.(.+)$/.exec(loosening.path);
@@ -11386,7 +11592,8 @@ function describeLoosening(loosening) {
11386
11592
  const label = SETTING_LABELS[field] ?? field;
11387
11593
  const what = scoped ? `${scoped[2]} ${label}${loosening.id === void 0 ? " (connected by this change)" : ""}` : label;
11388
11594
  const words = meaning(field, loosening.after, scoped ? scoped[1] : void 0);
11389
- return truncateDisplay(`${what}: ${shown(loosening.before)} → ${shown(loosening.after)}${words ? ` — ${words}` : ""}`, 300);
11595
+ const workspaces = scoped ? void 0 : perWorkspace(field, loosening.before, loosening.after);
11596
+ return truncateDisplay(`${workspaces === void 0 ? `${what}: ${shown(loosening.before)} → ${shown(loosening.after)}` : `${what} ${workspaces}`}${words ? ` — ${words}` : ""}`, 300);
11390
11597
  }
11391
11598
  /**
11392
11599
  * The change as a person reads it before agreeing: what it is for, every setting it loosens from → to in words, and
@@ -11396,10 +11603,14 @@ function describeLoosening(loosening) {
11396
11603
  * once as it was prepared (`doneAtOnce`). There it says what is true: those lines, listed apart, are done already, and
11397
11604
  * the rest is what waits for the approval. Decided by that field alone, never by the words of an effect, which can
11398
11605
  * carry text a person or a profile chose; every other preview's header is word for word what it was.
11606
+ *
11607
+ * It says how the change is approved: under `confirm`, a code typed at a terminal; under `chat`, a yes in the chat —
11608
+ * or, `askedHere`, a yes typed here, at this terminal, where a CLI asks the person reading it at once; or, `codeHere`,
11609
+ * a code typed here, at this terminal, where `approve <id>` shows a change prepared under `chat` and asks for its code.
11399
11610
  */
11400
- function renderChangePreview(record) {
11611
+ function renderChangePreview(record, options = {}) {
11401
11612
  const { change } = record;
11402
- const how = changePolicyOf(record) === "chat" ? "a yes in the chat" : "a code typed at a terminal";
11613
+ const how = changePolicyOf(record) === "chat" ? options.codeHere === true ? "a code typed here, at this terminal" : options.askedHere === true ? "a yes typed here, at this terminal" : "a yes in the chat" : "a code typed at a terminal";
11403
11614
  const target = change.target;
11404
11615
  const about = target === null ? "the whole configuration" : `${target.kind} ${target.name}${target.id === void 0 ? ", which this change connects" : ""}`;
11405
11616
  const doneAlready = (change.doneAtOnce ?? []).length > 0;
@@ -11460,7 +11671,8 @@ async function gatedChange(core, change, options) {
11460
11671
  prepared: await prepareChange(core, request, {
11461
11672
  surface: options.surface,
11462
11673
  channel: options.channel,
11463
- platform: options.platform
11674
+ platform: options.platform,
11675
+ askedHere: options.askedHere
11464
11676
  })
11465
11677
  };
11466
11678
  const { consent, approval } = await claimChangeApproval(core, options.approvalId, request, {
@@ -11483,15 +11695,17 @@ async function gatedChange(core, change, options) {
11483
11695
  */
11484
11696
  async function gatedChangeAtTerminal(core, change, options) {
11485
11697
  const streams = options.streams ?? defaultStreams;
11698
+ const person = agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true });
11486
11699
  const first = await gatedChange(core, change, {
11487
11700
  surface: "cli",
11488
11701
  channel: options.channel,
11489
11702
  approvalId: options.approvalId,
11490
- platform: options.output.platform
11703
+ platform: options.output.platform,
11704
+ askedHere: person
11491
11705
  });
11492
11706
  if (first.status === "applied") return first.result;
11493
11707
  const { prepared } = first;
11494
- if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
11708
+ if (!person) {
11495
11709
  if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
11496
11710
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
11497
11711
  const handoffs = requireHandoffs(core, options.output.platform);
@@ -11981,9 +12195,10 @@ const GOOGLE_CLIENT_ID_PATTERN = /^[0-9]{1,30}-[a-z0-9]{1,64}\.apps\.googleuserc
11981
12195
  * one place a profile changes the configuration, each change approved first.
11982
12196
  *
11983
12197
  * **A profile is data, never code, and none of it is trusted.** Every field comes from whoever wrote the document, so
11984
- * each has a grammar and a bound, an unknown key is an error, and the two free-text fields are neutralised and put on
11985
- * one line wherever they are shown. The client secret is never shown anywhere — not in a preview, an effect, an error,
11986
- * the audit log, a command's output or a tool's result.
12198
+ * each has a grammar and a bound, an unknown key is an error — anywhere but inside `extensions`, the one door a later
12199
+ * release's fields come in by (design 2026-10-19) — and the two free-text fields are neutralised and put on one line
12200
+ * wherever they are shown. The client secret is never shown anywhere — not in a preview, an effect, an error, the audit
12201
+ * log, a command's output or a tool's result.
11987
12202
  */
11988
12203
  /** What a profile says it is, in its first key. */
11989
12204
  const PROFILE_KIND = "organisation-profile";
@@ -12010,6 +12225,18 @@ const slackAppSchema = strictObject({
12010
12225
  clientId: string().regex(SLACK_CLIENT_ID_PATTERN, "a Slack client id is two runs of digits around a dot"),
12011
12226
  appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits").optional()
12012
12227
  });
12228
+ /** An extension's name: a lower-case letter, then 1–39 lower-case letters, digits or hyphens (X1). */
12229
+ const EXTENSION_NAME_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
12230
+ const extensionEntrySchema = looseObject({
12231
+ required: boolean({ message: "required is true or false: whether a release that does not know it must refuse it" }),
12232
+ since: string({ message: "since is the first release that understands it, as MAJOR.MINOR.PATCH" }).regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/, "since is the first release that understands it, as MAJOR.MINOR.PATCH: 0.23.0")
12233
+ });
12234
+ /**
12235
+ * The one door a version-1 profile has for what later releases add (design 2026-10-19, X1): at most 16 entries, each
12236
+ * named to the grammar above and each an object carrying `required` and `since`. Its other fields are the extension's;
12237
+ * every other object of the profile stays strict.
12238
+ */
12239
+ const extensionsSchema = record(string().regex(EXTENSION_NAME_PATTERN, "an extension is named by a lower-case letter, then 1–39 lower-case letters, digits or hyphens"), extensionEntrySchema, { message: "extensions is an object of named entries" }).refine((entries) => Object.keys(entries).length <= 16, { message: `extensions has at most 16 entries` });
12013
12240
  strictObject({
12014
12241
  agentcomms: literal(PROFILE_KIND),
12015
12242
  version: literal(1),
@@ -12029,7 +12256,8 @@ strictObject({
12029
12256
  read: slackAppSchema.optional(),
12030
12257
  send: slackAppSchema.optional()
12031
12258
  })
12032
- }).optional()
12259
+ }).optional(),
12260
+ extensions: extensionsSchema.optional()
12033
12261
  });
12034
12262
  /**
12035
12263
  * A string that came from a profile, as it is shown anywhere: chat-template tokens, role markers and envelope look-alikes
@@ -12780,6 +13008,49 @@ function wholeNumber(raw, rule) {
12780
13008
  return value;
12781
13009
  }
12782
13010
  /**
13011
+ * The machine's one update lock (design 2026-10-16 U5; plan, Review focus 1 and 3): a background run, a person's
13012
+ * update (`comms_update`, `agentcomms update`), a rollback and a person's prune (`comms_server_prune`, `mcp prune`)
13013
+ * each hold it for as long as they install, switch or prune, so none of them ever overlaps another — two of them would
13014
+ * switch the same pointers and prune runtimes the other had just installed, or was still checking.
13015
+ *
13016
+ * An install can take minutes, so the holder renews it, and a lock left by a process that died is taken over once it
13017
+ * has gone unrenewed for ten minutes. Nobody waits for it long: a background run that finds it held stops and runs
13018
+ * again the next day, and a person's update is refused at once — before an approval is prepared, where it can be — so
13019
+ * no conversation sits behind an install.
13020
+ */
13021
+ const UPDATE_LOCK_FILE = ".update.lock";
13022
+ function updateLockPath(stateDir) {
13023
+ return join(stateDir, UPDATE_LOCK_FILE);
13024
+ }
13025
+ const HOLD = {
13026
+ staleMs: 6e5,
13027
+ renewMs: 6e4
13028
+ };
13029
+ /** Runs `fn` holding the update lock, waiting at most `waitMs` for it; LOCK_TIMEOUT when another holds it. */
13030
+ async function withUpdateLock(stateDir, fn, waitMs = 1e3) {
13031
+ return withFileLock(updateLockPath(stateDir), fn, {
13032
+ timeoutMs: waitMs,
13033
+ ...HOLD
13034
+ });
13035
+ }
13036
+ /**
13037
+ * A person's update, rollback or prune, holding the update lock: refused, changing nothing, while a background run —
13038
+ * or another person's update — holds it. The refusal says what is under way and when to try again.
13039
+ */
13040
+ async function withPersonUpdateLock(stateDir, fn, what = "update") {
13041
+ try {
13042
+ return await withUpdateLock(stateDir, fn);
13043
+ } catch (error) {
13044
+ const refused = toCommsError(error);
13045
+ if (refused.code !== "LOCK_TIMEOUT" || !refused.message.includes(".update.lock")) throw error;
13046
+ throw new CommsError("LOCK_TIMEOUT", "nothing was changed: a background update run is under way on this machine, or another update is", { hint: `It installs, checks and switches in a few minutes, and the two never overlap. Run the ${what} again in a few minutes; the doctor and comms_audit_tail say what the run did.` });
13047
+ }
13048
+ }
13049
+ /** Refuses at once, changing nothing, while the update lock is held: the check a person's update makes as it plans. */
13050
+ async function refuseWhileUpdating(stateDir, what = "update") {
13051
+ await withPersonUpdateLock(stateDir, async () => void 0, what);
13052
+ }
13053
+ /**
12783
13054
  * Every sentence a background run may leave for the next session, and the one way a notice of it is said (design
12784
13055
  * 2026-10-16 U5 step 7, U9; security review of 0.20.0, severity 6): in this package's own words, with nothing in them
12785
13056
  * the registry or a certificate wrote. A release's version is the one thing a sentence takes from outside, and only as a
@@ -13454,18 +13725,24 @@ async function remedyLocated(error, core) {
13454
13725
  * one effect — the removal cannot be taken back — so the approval is bound to exactly that list; and the removal is
13455
13726
  * then restricted to it (`only`), so a runtime that became unused after the list was shown stays.
13456
13727
  *
13728
+ * A removal never runs beside an update (`update-lock.ts`): a background run installs a release and checks it before
13729
+ * any pointer names it, and a prune beside it would find that runtime unused. So it is refused at once while an update
13730
+ * holds the lock — before an approval is prepared — and the removal itself holds the lock, refused in turn when an
13731
+ * update has started since. A refusal stops nothing else.
13732
+ *
13457
13733
  * `own` is the calling channel's product, from its own `mcp prune`, so the runtime kept as "this release" is the
13458
13734
  * release of the command that was run; checked to be that channel's, as for an install.
13459
13735
  */
13460
13736
  function serverPruneChange(core, env, request, own) {
13461
- if (!isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
13737
+ if (request.channel === void 0 && own === void 0) return everyServerPruneChange(core, env, request);
13738
+ if (request.channel === void 0 || !isServer(request.channel)) throw new CommsError("USAGE", `"${String(request.channel)}" is not a channel`, { hint: `One of: ${CHANNELS.join(", ")}.` });
13462
13739
  const context = {
13463
13740
  env,
13464
13741
  core
13465
13742
  };
13466
13743
  const { packageName, version } = ownProduct(request.channel, own) ?? {
13467
13744
  packageName: serverFacts(request.channel).packageName,
13468
- version: "0.20.0"
13745
+ version: "0.21.0"
13469
13746
  };
13470
13747
  const product = {
13471
13748
  packageName,
@@ -13484,6 +13761,7 @@ function serverPruneChange(core, env, request, own) {
13484
13761
  after: config,
13485
13762
  summary: `Show which ${label} runtimes are unused`
13486
13763
  };
13764
+ await refuseWhileUpdating(core.paths.stateDir, "prune");
13487
13765
  const preview = await pruneManagedRuntimes(context, product, {
13488
13766
  ...base,
13489
13767
  dryRun: true
@@ -13492,15 +13770,124 @@ function serverPruneChange(core, env, request, own) {
13492
13770
  return {
13493
13771
  before: config,
13494
13772
  after: config,
13495
- effects: [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)],
13773
+ effects: pruneEffects(label, preview),
13496
13774
  summary: `Remove ${approved.length} unused ${label} runtime${approved.length === 1 ? "" : "s"}`
13497
13775
  };
13498
13776
  },
13499
- apply: () => pruneManagedRuntimes(context, product, {
13777
+ apply: () => request.dryRun ? pruneManagedRuntimes(context, product, {
13500
13778
  ...base,
13501
- dryRun: request.dryRun === true,
13502
- ...request.dryRun ? {} : { only: approved }
13503
- })
13779
+ dryRun: true
13780
+ }) : withPersonUpdateLock(core.paths.stateDir, () => pruneManagedRuntimes(context, product, {
13781
+ ...base,
13782
+ dryRun: false,
13783
+ only: approved
13784
+ }), "prune")
13785
+ };
13786
+ }
13787
+ /** What a removal of runtimes does, in the preview's words: each runtime, then each owned CLI shim. */
13788
+ function pruneEffects(label, preview) {
13789
+ return [...preview.removed.map((item) => `deletes the unused ${label} runtime ${item.version} at ${item.path}`), ...preview.shims.removed.map((path) => `deletes the owned ${label} CLI shim at ${path}`)];
13790
+ }
13791
+ /**
13792
+ * Every managed server's unused runtimes, as one change (0.21): what an update leaves behind is every server's, and
13793
+ * `--server` for each was a step a person had to know to take. The servers are the channel registry's (`SERVERS`),
13794
+ * channels and declared services alike, never a list written here; each is pruned exactly as `--server` prunes it —
13795
+ * this release's runtime, one any client config or printed entry names, one a running process uses, and the runtime
13796
+ * its stable launcher starts and the one it keeps for a rollback all stay, each said why.
13797
+ *
13798
+ * A dry run is free. Otherwise the plan is every server's dry run, shown grouped by server, and the approval is bound to
13799
+ * exactly that list; the removal is then restricted to it, so nothing that became unused after it was shown goes. A
13800
+ * server whose prune was stopped — a pointer or a config that cannot be read — is named in the preview, beside what the
13801
+ * others remove, and in the result's `refusedServers`. The running processes are listed again for each server, just
13802
+ * before it is decided, as the prune of one server lists them: a listing taken at the start of the pass would not see
13803
+ * a process started from a runtime while the servers before it were pruned. And, as for one server, a removal never
13804
+ * runs beside an update.
13805
+ */
13806
+ function everyServerPruneChange(core, env, request) {
13807
+ const context = {
13808
+ env,
13809
+ core
13810
+ };
13811
+ const servers = SERVERS.map((server) => ({
13812
+ server,
13813
+ label: serverLabel(server),
13814
+ product: {
13815
+ packageName: serverFacts(server).packageName,
13816
+ version: VERSION
13817
+ }
13818
+ }));
13819
+ const pruneEach = async (options) => {
13820
+ const parts = [];
13821
+ for (const { server, label, product } of servers) {
13822
+ const result = await pruneManagedRuntimes(context, product, {
13823
+ includePrinted: request.includePrinted === true,
13824
+ processes: request.processes ?? runningCommandLines,
13825
+ ...options
13826
+ });
13827
+ parts.push({
13828
+ server,
13829
+ label,
13830
+ ...result
13831
+ });
13832
+ }
13833
+ return parts;
13834
+ };
13835
+ /** Each server whose prune was stopped, and why. */
13836
+ const refusedOf = (parts) => parts.flatMap(({ server, label, refused }) => refused === void 0 ? [] : [{
13837
+ server,
13838
+ label,
13839
+ refused
13840
+ }]);
13841
+ let approved = [];
13842
+ return {
13843
+ plan: async (config) => {
13844
+ if (request.dryRun) return {
13845
+ before: config,
13846
+ after: config,
13847
+ summary: "Show which runtimes of every managed server are unused"
13848
+ };
13849
+ await refuseWhileUpdating(core.paths.stateDir, "prune");
13850
+ const previews = await pruneEach({ dryRun: true });
13851
+ approved = previews.flatMap((part) => part.removed.map((item) => item.path));
13852
+ const counts = previews.filter((part) => part.removed.length > 0).map((part) => `${part.removed.length} ${part.label}`);
13853
+ const removals = previews.flatMap((part) => pruneEffects(part.label, part));
13854
+ const unchecked = refusedOf(previews).map(({ label, refused }) => `does not check the ${label} runtimes, and removes none of them: ${refused}`);
13855
+ return {
13856
+ before: config,
13857
+ after: config,
13858
+ effects: removals.length > 0 ? [...removals, ...unchecked] : [],
13859
+ summary: `Remove ${approved.length} unused runtime${approved.length === 1 ? "" : "s"}${counts.length > 0 ? `: ${counts.join(", ")}` : ""}`
13860
+ };
13861
+ },
13862
+ apply: async () => {
13863
+ const dryRun = request.dryRun === true;
13864
+ const parts = dryRun ? await pruneEach({ dryRun }) : await withPersonUpdateLock(core.paths.stateDir, () => pruneEach({
13865
+ dryRun,
13866
+ only: approved
13867
+ }), "prune");
13868
+ const looked = parts.filter((part) => part.removed.length + part.kept.length > 0);
13869
+ const refused = looked.length > 0 && looked.every((part) => part.refused !== void 0);
13870
+ const refusedServers = refusedOf(parts);
13871
+ return {
13872
+ runtimeDir: join(core.paths.dataDir, "runtime"),
13873
+ dryRun,
13874
+ removed: parts.flatMap((part) => part.removed.map((item) => ({
13875
+ ...item,
13876
+ server: part.server
13877
+ }))),
13878
+ kept: parts.flatMap((part) => part.kept.map((item) => ({
13879
+ ...item,
13880
+ server: part.server
13881
+ }))),
13882
+ shims: {
13883
+ removed: parts.flatMap((part) => part.shims.removed),
13884
+ kept: parts.flatMap((part) => part.shims.kept)
13885
+ },
13886
+ ...refused ? { refused: [...new Set(looked.map((part) => part.refused))].join("; ") } : {},
13887
+ servers: parts,
13888
+ ...refusedServers.length > 0 ? { refusedServers } : {}
13889
+ };
13890
+ }
13504
13891
  };
13505
13892
  }
13506
13893
  /**
@@ -13887,7 +14274,7 @@ function commandDecisionLine(core, decision, channel, platform) {
13887
14274
  }
13888
14275
  channelServer("core").packageName;
13889
14276
  //#endregion
13890
- //#region ../core/dist/tool-arguments-4c3WR2Gy.mjs
14277
+ //#region ../core/dist/tool-arguments-C8yi7cpo.mjs
13891
14278
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
13892
14279
  const TARGET = "draft-2020-12";
13893
14280
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcomms/whatsapp",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "WhatsApp for coding agents, read-only: list, read and search the chats WhatsApp for Mac keeps on this Mac, and draft replies as links the person sends. No network client; it never sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@agentcomms/core": "0.20.0"
37
+ "@agentcomms/core": "0.21.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@modelcontextprotocol/client": "2.3.1",
@@ -98,5 +98,5 @@
98
98
  "typecheck": "tsc -p tsconfig.json",
99
99
  "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test --test-timeout=600000 \"test/**/*.test.ts\""
100
100
  },
101
- "gitHead": "b5e7322e890d2eb6a6f9917612f6d3af1882edbd"
101
+ "gitHead": "3916bacd2b66e5457624f97cd671bee60e704272"
102
102
  }