@agentcomms/whatsapp 0.11.0 → 0.12.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/dist/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { $ as renderPrune, A as CommsError, B as colorEnabled, C as checkStorePath, F as approvalsOf, G as installExitStatus, H as escapeForDisplay, I as approveChangeAtTerminal, J as nameAvailable, K as isCommsError, L as canPrompt, M as absoluteSearchPath, N as agentMarker, O as CHANNELS, P as approvalKind, Q as renderInstall, R as channelManifest, S as storeIdentity, T as expandSource, U as exemptFromUpdateGate, V as commandPathOf, W as gatedChangeAtTerminal, X as openCore, Y as nameShapeProblem, Z as paint, _ as chatRefOf, a as listChats, at as updateGateAtTerminal, b as requireAccount, c as Presenter, ct as withFileLock, d as draftMessage, et as runCommand, f as Visibility, g as requireSupportedNode, h as WhatsAppIndex, i as CHAT_KINDS, it as truncateDisplay, j as EXIT_CODES, k as CHANNEL_SERVERS, l as innerText, lt as writeResult, m as WhatsAppContext, n as whatsappStatus, nt as serverPruneChange, o as readChat, p as listKey, q as lookupName, r as probeStore, s as searchMessages, st as windowsSystemProgram, t as syncAccount, tt as serverInstallChange, ut as VERSION, v as PLATFORM, w as defaultStorePath, x as requireNamedConfig, y as newWhatsAppAccount, z as childEnvironment } from "./sync-CinH_TIY.mjs";
2
+ import { $ as renderPrune, A as CommsError, B as colorEnabled, C as checkStorePath, F as approvalsOf, G as installExitStatus, H as escapeForDisplay, I as approveChangeAtTerminal, J as nameAvailable, K as isCommsError, L as canPrompt, M as absoluteSearchPath, N as agentMarker, O as CHANNELS, P as approvalKind, Q as renderInstall, R as channelManifest, S as storeIdentity, T as expandSource, U as exemptFromUpdateGate, V as commandPathOf, W as gatedChangeAtTerminal, X as openCore, Y as nameShapeProblem, Z as paint, _ as chatRefOf, a as listChats, at as updateGateAtTerminal, b as requireAccount, c as Presenter, ct as withFileLock, d as draftMessage, et as runCommand, f as Visibility, g as requireSupportedNode, h as WhatsAppIndex, i as CHAT_KINDS, it as truncateDisplay, j as EXIT_CODES, k as CHANNEL_SERVERS, l as innerText, lt as writeResult, m as WhatsAppContext, n as whatsappStatus, nt as serverPruneChange, o as readChat, p as listKey, q as lookupName, r as probeStore, s as searchMessages, st as windowsSystemProgram, t as syncAccount, tt as serverInstallChange, ut as VERSION, v as PLATFORM, w as defaultStorePath, x as requireNamedConfig, y as newWhatsAppAccount, z as childEnvironment } from "./sync-BiO86eEl.mjs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import path, { delimiter, join } from "node:path";
5
+ import fs, { accessSync, constants as constants$1 } from "node:fs";
5
6
  import childProcess, { spawn } from "node:child_process";
6
7
  import { stripVTControlCharacters } from "node:util";
7
- import fs, { accessSync, constants as constants$1 } from "node:fs";
8
8
  import process$1 from "node:process";
9
9
  import { EventEmitter } from "node:events";
10
10
  //#region ../../node_modules/.pnpm/commander@15.0.0/node_modules/commander/lib/error.js
@@ -3534,7 +3534,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3534
3534
  ran = true;
3535
3535
  exitCode = await runCommand(output(), async () => {
3536
3536
  requireSupportedNode();
3537
- const { startWhatsAppStdioServer } = await import("./stdio-entry-r40LJtFu.mjs");
3537
+ const { startWhatsAppStdioServer } = await import("./stdio-entry-BznXKJaK.mjs");
3538
3538
  await startWhatsAppStdioServer({
3539
3539
  env,
3540
3540
  ...flags.account ? { account: String(flags.account) } : {}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { u as composeDraft, ut as VERSION } from "./sync-CinH_TIY.mjs";
2
- import { t as createWhatsAppMcpServer } from "./server-BLF6Xumr.mjs";
1
+ import { u as composeDraft, ut as VERSION } from "./sync-BiO86eEl.mjs";
2
+ import { t as createWhatsAppMcpServer } from "./server-BHwKQeXq.mjs";
3
3
  //#region src/index.ts
4
4
  /**
5
5
  * The library entry of `@agentcomms/whatsapp`.
@@ -1,4 +1,4 @@
1
- import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as updateToolGate, rt as toCommsError, s as searchMessages, t as syncAccount, ut as VERSION } from "./sync-CinH_TIY.mjs";
1
+ import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as updateToolGate, rt as toCommsError, s as searchMessages, t as syncAccount, ut as VERSION } from "./sync-BiO86eEl.mjs";
2
2
  import { c as boolean, h as number, x as string } from "./schemas-DiGKNDNq.mjs";
3
3
  import { t as McpServer } from "./mcp-DXXb3Vv3-Co5BzjYe.mjs";
4
4
  //#region src/mcp/server.ts
@@ -1,4 +1,4 @@
1
- import { t as createWhatsAppMcpServer } from "./server-BLF6Xumr.mjs";
1
+ import { t as createWhatsAppMcpServer } from "./server-BHwKQeXq.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
@@ -5,15 +5,15 @@ import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { homedir, platform, userInfo } from "node:os";
8
+ import { constants as constants$1 } from "node:fs";
8
9
  import { execFile, spawn } from "node:child_process";
9
10
  import { styleText } from "node:util";
10
- import { constants as constants$1 } from "node:fs";
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.11.0";
14
+ const VERSION$1 = "0.12.1";
15
15
  //#endregion
16
- //#region ../core/dist/update-gate-BMcLWU3V.mjs
16
+ //#region ../core/dist/update-check-Ba6REc8M.mjs
17
17
  /**
18
18
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
19
19
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -951,74 +951,60 @@ function connectMailboxCommand() {
951
951
  return mail ? `${mail.binary} inbox add` : void 0;
952
952
  }
953
953
  /**
954
- * The grammar of an account name in version 2 of the config: `<organisation>/<platform>[-<qualifier>]`.
955
- *
956
- * `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
957
- * first half says who an account belongs to and the second what it is — and the schema checks the second half against
958
- * the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
959
- *
960
- * Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
961
- * without depending on each other.
962
- */
963
- /**
964
- * Organisation names Windows cannot use as a directory.
965
- *
966
- * The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
967
- * device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
968
- * first time somebody on Windows saves an attachment.
954
+ * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
955
+ * and the preview renderer (which makes them visible), so the two can never disagree.
969
956
  */
970
- const WINDOWS_RESERVED = [
971
- "con",
972
- "prn",
973
- "aux",
974
- "nul",
975
- ...range$1("com"),
976
- ...range$1("lpt")
957
+ /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
958
+ const INVISIBLE_RANGES = [
959
+ [173, 173],
960
+ [847, 847],
961
+ [1564, 1564],
962
+ [4447, 4448],
963
+ [6068, 6069],
964
+ [6155, 6159],
965
+ [8203, 8207],
966
+ [8232, 8233],
967
+ [8234, 8238],
968
+ [8288, 8292],
969
+ [8294, 8303],
970
+ [12644, 12644],
971
+ [65024, 65039],
972
+ [65279, 65279],
973
+ [65440, 65440],
974
+ [917504, 917631]
977
975
  ];
978
- function range$1(prefix) {
979
- return Array.from({ length: 9 }, (_, index) => `${prefix}${index + 1}`);
976
+ function isInvisible(codePoint) {
977
+ for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
978
+ return false;
980
979
  }
981
- const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
982
- const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
983
980
  /**
984
- * The whole grammar as one expression, so a record key can be checked by the schema without a second pass.
985
- *
986
- * Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
987
- * `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
981
+ * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
982
+ * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
988
983
  */
989
- const NAME_PATTERN = new RegExp(`^(?!(?:${WINDOWS_RESERVED.join("|")})/)(${ORG})/(${PLATFORM$1})(?:-([a-z0-9](?:[a-z0-9-]{0,14}[a-z0-9])?))?$`);
990
- /** A platform word on its own — `gmail`, `slack` — which is also a channel's word in its manifest. */
991
- const PLATFORM_PATTERN = new RegExp(`^${PLATFORM$1}$`);
992
- const NAME_MESSAGE = "names look like organisation/platform, optionally with a qualifier: cue/gmail, wf/gmail-tech, cue/slack";
993
- /** The three parts of a valid name, or null for anything the grammar refuses. */
994
- function parseName(name) {
995
- const match = NAME_PATTERN.exec(name);
996
- if (!match) return null;
997
- const [, org = "", platform = "", qualifier] = match;
998
- return qualifier === void 0 ? {
999
- org,
1000
- platform
1001
- } : {
1002
- org,
1003
- platform,
1004
- qualifier
1005
- };
984
+ function isControl(codePoint) {
985
+ if (codePoint === 9 || codePoint === 10) return false;
986
+ return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
987
+ }
988
+ function isDangerous(codePoint) {
989
+ return isControl(codePoint) || isInvisible(codePoint);
1006
990
  }
1007
991
  /**
1008
- * Why a name is refused, in words a person can act on — or null when it is fine.
992
+ * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
993
+ * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
1009
994
  *
1010
- * `platform` is what the account actually is. Checked here as well as in the schema, so the error arrives when the
1011
- * name is proposed rather than as a refused config write after a sign-in has already been spent.
995
+ * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
996
+ * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
997
+ * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
1012
998
  */
1013
- function nameShapeProblem(name, platform) {
1014
- const parsed = parseName(name);
1015
- if (!parsed) {
1016
- const org = name.split("/")[0] ?? "";
1017
- if (WINDOWS_RESERVED.includes(org)) return `"${org}" cannot be an organisation name: Windows reserves it, and the organisation becomes a folder`;
1018
- return `"${name}" is not a valid name: it should be the organisation, a slash, then ${platform} — for example acme/${platform}, or acme/${platform}-support for a second one`;
1019
- }
1020
- if (parsed.platform !== platform) return `"${name}" ends in /${parsed.platform}, but this is a ${platform} account`;
1021
- return null;
999
+ function stripInvisible(text) {
1000
+ let removed = 0;
1001
+ let out = "";
1002
+ for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
1003
+ else out += char;
1004
+ return {
1005
+ text: out,
1006
+ removed
1007
+ };
1022
1008
  }
1023
1009
  const APP_DIR_NAME = "agent-communications";
1024
1010
  /**
@@ -1082,6 +1068,23 @@ function expandHome(path, home = homedir(), joinPaths = join) {
1082
1068
  return path;
1083
1069
  }
1084
1070
  /**
1071
+ * A path as a preview shows it: the home at its start written `~`, the way a person reads and types it. The inverse of
1072
+ * `expandHome`, and like it, nothing else is touched. Case is ignored on Windows, whose paths are not case-sensitive.
1073
+ */
1074
+ function shortenHome(path, home, platform = process.platform) {
1075
+ if (!home) return path;
1076
+ const same = (a, b) => platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
1077
+ let end = home.length;
1078
+ while (end > 0 && (home[end - 1] === "/" || home[end - 1] === "\\")) end -= 1;
1079
+ const root = home.slice(0, end);
1080
+ if (same(path, root)) return "~";
1081
+ for (const separator of platform === "win32" ? ["\\", "/"] : ["/"]) {
1082
+ const prefix = `${root}${separator}`;
1083
+ if (path.length > prefix.length && same(path.slice(0, prefix.length), prefix)) return `~${separator}${path.slice(prefix.length)}`;
1084
+ }
1085
+ return path;
1086
+ }
1087
+ /**
1085
1088
  * The user's home directory, read from an environment.
1086
1089
  *
1087
1090
  * Windows does not set `HOME`; it sets `USERPROFILE`. Call sites that wrote `env.HOME ?? ''` and handed the result to
@@ -1097,6 +1100,88 @@ function homeDirectory(env = process.env) {
1097
1100
  return env.HOME || env.USERPROFILE || homedir();
1098
1101
  }
1099
1102
  /**
1103
+ * Whether a folder, as written, says where it is on its own: from the home (`~`), or absolute — and on Windows with its
1104
+ * drive (`C:\…`) or share (`\\server\share\…`). Windows calls `\outgoing` absolute, but it is on whichever drive is
1105
+ * current when it is read, and `C:outgoing` is relative to that drive's current folder; either would move with the
1106
+ * process that reads it. A folder in the configuration that does not name its place — written by hand before 0.12.0,
1107
+ * say — allows nothing, and `agentcomms attach` takes none (#45).
1108
+ */
1109
+ function namesItsPlace(path, platform = process.platform) {
1110
+ if (path === "~" || path.startsWith("~/") || path.startsWith("~\\")) return true;
1111
+ if (platform === "win32") return /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/?.][^\\/]*[\\/][^\\/]+/.test(path);
1112
+ return isAbsolute(path);
1113
+ }
1114
+ /**
1115
+ * The grammar of an account name in version 2 of the config: `<organisation>/<platform>[-<qualifier>]`.
1116
+ *
1117
+ * `cue/gmail` is the CUE++ mailbox, `cue/slack` the CUE++ workspace, `wf/gmail-tech` a second Wherefrom mailbox. The
1118
+ * first half says who an account belongs to and the second what it is — and the schema checks the second half against
1119
+ * the account itself, so a name cannot claim to be a Slack workspace while naming a mailbox.
1120
+ *
1121
+ * Kept apart from `config.ts`, with no imports, so the schema and the helpers built on it can both depend on it
1122
+ * without depending on each other.
1123
+ */
1124
+ /**
1125
+ * Organisation names Windows cannot use as a directory.
1126
+ *
1127
+ * The organisation becomes a folder — downloads land in `downloads/cue/gmail/…` — and `con`, `nul` and the rest are
1128
+ * device names on Windows in every directory, with or without an extension. Refused here rather than discovered the
1129
+ * first time somebody on Windows saves an attachment.
1130
+ */
1131
+ const WINDOWS_RESERVED = [
1132
+ "con",
1133
+ "prn",
1134
+ "aux",
1135
+ "nul",
1136
+ ...range$1("com"),
1137
+ ...range$1("lpt")
1138
+ ];
1139
+ function range$1(prefix) {
1140
+ return Array.from({ length: 9 }, (_, index) => `${prefix}${index + 1}`);
1141
+ }
1142
+ const ORG = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?";
1143
+ const PLATFORM$1 = "[a-z][a-z0-9]{0,15}";
1144
+ /**
1145
+ * The whole grammar as one expression, so a record key can be checked by the schema without a second pass.
1146
+ *
1147
+ * Segments never start or end with a hyphen, and the character set is `[a-z0-9/-]` and nothing else: no `.`, so no
1148
+ * `..`; no whitespace, no controls, no upper case, nothing that looks like something else.
1149
+ */
1150
+ const NAME_PATTERN = new RegExp(`^(?!(?:${WINDOWS_RESERVED.join("|")})/)(${ORG})/(${PLATFORM$1})(?:-([a-z0-9](?:[a-z0-9-]{0,14}[a-z0-9])?))?$`);
1151
+ /** A platform word on its own — `gmail`, `slack` — which is also a channel's word in its manifest. */
1152
+ const PLATFORM_PATTERN = new RegExp(`^${PLATFORM$1}$`);
1153
+ const NAME_MESSAGE = "names look like organisation/platform, optionally with a qualifier: cue/gmail, wf/gmail-tech, cue/slack";
1154
+ /** The three parts of a valid name, or null for anything the grammar refuses. */
1155
+ function parseName(name) {
1156
+ const match = NAME_PATTERN.exec(name);
1157
+ if (!match) return null;
1158
+ const [, org = "", platform = "", qualifier] = match;
1159
+ return qualifier === void 0 ? {
1160
+ org,
1161
+ platform
1162
+ } : {
1163
+ org,
1164
+ platform,
1165
+ qualifier
1166
+ };
1167
+ }
1168
+ /**
1169
+ * Why a name is refused, in words a person can act on — or null when it is fine.
1170
+ *
1171
+ * `platform` is what the account actually is. Checked here as well as in the schema, so the error arrives when the
1172
+ * name is proposed rather than as a refused config write after a sign-in has already been spent.
1173
+ */
1174
+ function nameShapeProblem(name, platform) {
1175
+ const parsed = parseName(name);
1176
+ if (!parsed) {
1177
+ const org = name.split("/")[0] ?? "";
1178
+ if (WINDOWS_RESERVED.includes(org)) return `"${org}" cannot be an organisation name: Windows reserves it, and the organisation becomes a folder`;
1179
+ return `"${name}" is not a valid name: it should be the organisation, a slash, then ${platform} — for example acme/${platform}, or acme/${platform}-support for a second one`;
1180
+ }
1181
+ if (parsed.platform !== platform) return `"${name}" ends in /${parsed.platform}, but this is a ${platform} account`;
1182
+ return null;
1183
+ }
1184
+ /**
1100
1185
  * Whether this release may write version 2 of the config.
1101
1186
  *
1102
1187
  * Not while any reader that shares the file might still be one that cannot read it. So the release that teaches every
@@ -1671,8 +1756,17 @@ const POLICY_RANK$1 = {
1671
1756
  * user a consent prompt for a change that is not one. Linux is case-sensitive, so case is kept.
1672
1757
  */
1673
1758
  function normalisePath(path) {
1674
- const expanded = withoutTrailingSeparators(resolve(expandHome(path.trim(), homedir())));
1675
- return platform() === "darwin" || platform() === "win32" ? expanded.toLowerCase() : expanded;
1759
+ return comparablePath(resolve(expandHome(path.trim(), homedir())));
1760
+ }
1761
+ /**
1762
+ * An absolute path in the form two paths are compared in here: no trailing separator, and case folded on macOS and
1763
+ * Windows, whose filesystems are case-insensitive by default (see `normalisePath`). The top of a disk keeps its
1764
+ * separator — `/`, `C:\` — so it is not read as no path at all.
1765
+ */
1766
+ function comparablePath(absolute) {
1767
+ const stripped = withoutTrailingSeparators(absolute);
1768
+ const kept = stripped === "" || /^[A-Za-z]:$/.test(stripped) ? absolute.slice(0, stripped.length + 1) : stripped;
1769
+ return platform() === "darwin" || platform() === "win32" ? kept.toLowerCase() : kept;
1676
1770
  }
1677
1771
  /**
1678
1772
  * The path with any separators at its end removed. A loop, not `/[/\\]+$/`: that pattern backtracks over every run of
@@ -1687,9 +1781,14 @@ function withoutTrailingSeparators(path) {
1687
1781
  function holdsSecrets(config) {
1688
1782
  return Object.values(config.clients).some((client) => Boolean(client.secretRef)) || Object.values(config.inboxes).some((inbox) => Boolean(inbox.secretRef)) || Object.values(config.accounts).some((account) => Boolean(account.secretRef));
1689
1783
  }
1690
- /** True when `candidate` is the same directory as `parent`, or inside it. Both may be unset. */
1784
+ /**
1785
+ * True when `candidate` is the same directory as `parent`, or inside it. Both may be unset. Both are compared as
1786
+ * written, so both have to be in one form first: `normalisePath`, or `comparablePath` of a real path. A parent that is
1787
+ * the top of a disk — `/`, `c:\` — holds everything on it.
1788
+ */
1691
1789
  function isInsideDirectory(candidate, parent) {
1692
1790
  if (!candidate || !parent) return false;
1791
+ if (parent.endsWith("/") || parent.endsWith("\\")) return candidate.startsWith(parent);
1693
1792
  return candidate === parent || candidate.startsWith(`${parent}/`) || candidate.startsWith(`${parent}\\`);
1694
1793
  }
1695
1794
  /**
@@ -1762,10 +1861,11 @@ function classifyChange(before, after) {
1762
1861
  if (b.riskEscalation && !a.riskEscalation) loosen("defaults.riskEscalation", b.riskEscalation, a.riskEscalation);
1763
1862
  if (updateCheckSetting(before) === "on" && updateCheckSetting(after) === "off") loosen("defaults.updateCheck", updateCheckSetting(before), updateCheckSetting(after));
1764
1863
  if (a.sendCaps.perHour > b.sendCaps.perHour || a.sendCaps.perDay > b.sendCaps.perDay) loosen("defaults.sendCaps", b.sendCaps, a.sendCaps);
1765
- const roots = (list) => new Set(list.map(normalisePath));
1766
- const before_roots = roots(b.attachRoots);
1864
+ const roots = (list) => new Set(list.map((path) => comparablePath(resolve(expandHome(path, homedir())))));
1865
+ const readRoots = (list) => roots(list.filter((root) => namesItsPlace(root)));
1866
+ const before_roots = readRoots(b.attachRoots);
1767
1867
  const after_deny = roots(a.attachDeny);
1768
- if ([...roots(a.attachRoots)].some((root) => !before_roots.has(root))) loosen("defaults.attachRoots", b.attachRoots, a.attachRoots);
1868
+ if ([...readRoots(a.attachRoots)].some((root) => !before_roots.has(root))) loosen("defaults.attachRoots", b.attachRoots, a.attachRoots);
1769
1869
  if ([...roots(b.attachDeny)].some((deny) => !after_deny.has(deny))) loosen("defaults.attachDeny", b.attachDeny, a.attachDeny);
1770
1870
  const downloadsBefore = b.downloadsDir === void 0 ? void 0 : normalisePath(b.downloadsDir);
1771
1871
  const downloadsAfter = a.downloadsDir === void 0 ? void 0 : normalisePath(a.downloadsDir);
@@ -3626,7 +3726,7 @@ async function installManagedRuntime(context, product, version) {
3626
3726
  private: true
3627
3727
  }, null, 2)}\n`);
3628
3728
  const npmCli = await findNpmCli();
3629
- await run(process.execPath, [
3729
+ await run(context.env, process.execPath, [
3630
3730
  npmCli,
3631
3731
  "install",
3632
3732
  "--prefix",
@@ -3650,10 +3750,12 @@ async function findNpmCli() {
3650
3750
  throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
3651
3751
  }
3652
3752
  /**
3653
- * A command run to its end. Its environment is this process's, and on Windows also tells it never to take a program
3654
- * from its current folder: `npm` and a client's command both start programs of their own by name.
3753
+ * A command run to its end, in `env`: the environment the install resolved its target from — which client config,
3754
+ * which client CLI, which node — and not this process's, which may be another. On Windows it also tells the command
3755
+ * never to take a program from its current folder: `npm` and a client's command both start programs of their own by
3756
+ * name.
3655
3757
  */
3656
- function run(command, args) {
3758
+ function run(env, command, args) {
3657
3759
  return new Promise((resolvePromise, reject) => {
3658
3760
  const child = spawn(command, args, {
3659
3761
  stdio: [
@@ -3661,7 +3763,7 @@ function run(command, args) {
3661
3763
  "ignore",
3662
3764
  "pipe"
3663
3765
  ],
3664
- env: childEnvironment(process.env)
3766
+ env: childEnvironment(env)
3665
3767
  });
3666
3768
  let stderr = "";
3667
3769
  child.stderr?.on("data", (chunk) => {
@@ -3674,14 +3776,19 @@ function run(command, args) {
3674
3776
  });
3675
3777
  });
3676
3778
  }
3677
- async function buildEntry(context, product, options, apply) {
3779
+ /**
3780
+ * The entry for one server, starting the command its preflight resolved (`resolved`): what the plan named and the
3781
+ * person approved. Nothing here looks on PATH again — a node or an npx that turned up since would be another command
3782
+ * than the one the preview said.
3783
+ */
3784
+ async function buildEntry(context, product, options, apply, resolved) {
3678
3785
  const launcher = options.launcher ?? "managed";
3679
- const node = await resolveNode(context.env);
3786
+ const { node } = resolved;
3680
3787
  const env = minimalEnv(context, node);
3681
3788
  const serverArgs = product.serverArgs(options);
3682
3789
  if (launcher === "npx") return {
3683
3790
  entry: {
3684
- command: await whichExecutable("npx", context.env) ?? "npx",
3791
+ command: resolved.command,
3685
3792
  args: [
3686
3793
  "-y",
3687
3794
  `${product.npxPackage}@${product.version}`,
@@ -3825,6 +3932,13 @@ function claimName(product, options, name, existing) {
3825
3932
  throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name: \`${installCommand$1(product, options, other)}\`. --force does not replace a server this did not install.` });
3826
3933
  }
3827
3934
  if (taken.length > 0 && !options.force) {
3935
+ for (const pin of PIN_OPTIONS) {
3936
+ const wanted = options[pin];
3937
+ const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
3938
+ if (!wanted || !served || served === wanted) continue;
3939
+ const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
3940
+ throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: \`${installCommand$1(product, options, second === name ? `${name}-2` : second)}\`.` });
3941
+ }
3828
3942
  const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
3829
3943
  const again = {
3830
3944
  ...keepNarrowing(product, options, taken).options,
@@ -3890,9 +4004,10 @@ function installCommand$1(product, options, name, extra = []) {
3890
4004
  }
3891
4005
  /**
3892
4006
  * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
3893
- * `run` starts one, so that on Windows it takes no program from its current folder either.
4007
+ * `run` starts one — in the environment the target was resolved from — so that on Windows it takes no program from its
4008
+ * current folder either.
3894
4009
  */
3895
- function capture$1(command, args) {
4010
+ function capture$1(env, command, args) {
3896
4011
  return new Promise((resolvePromise, reject) => {
3897
4012
  const child = spawn(command, args, {
3898
4013
  stdio: [
@@ -3900,7 +4015,7 @@ function capture$1(command, args) {
3900
4015
  "pipe",
3901
4016
  "pipe"
3902
4017
  ],
3903
- env: childEnvironment(process.env)
4018
+ env: childEnvironment(env)
3904
4019
  });
3905
4020
  let stdout = "";
3906
4021
  let stderr = "";
@@ -3928,11 +4043,11 @@ function capture$1(command, args) {
3928
4043
  * or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
3929
4044
  * Nothing codex prints is repeated, because its answer carries the entry's env.
3930
4045
  */
3931
- async function codexRegistration(binary, name, path) {
4046
+ async function codexRegistration(env, binary, name, path) {
3932
4047
  const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with \`codex mcp get ${name}\`. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
3933
4048
  let answer;
3934
4049
  try {
3935
- answer = await capture$1(binary, [
4050
+ answer = await capture$1(env, binary, [
3936
4051
  "mcp",
3937
4052
  "get",
3938
4053
  name,
@@ -4034,21 +4149,48 @@ async function preflightInstall(context, product, options) {
4034
4149
  }
4035
4150
  let previous = writes ? claimName(product, options, name, existing) : [];
4036
4151
  if (writes && binary && options.client === "codex") {
4037
- const reported = await codexRegistration(binary, name, configPath ?? "codex");
4152
+ const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
4038
4153
  if (reported) previous = claimName(product, options, name, [reported]);
4039
4154
  }
4040
4155
  const { options: effective, kept } = keepNarrowing(product, options, previous);
4156
+ const node = await resolveNode(context.env);
4157
+ const command = (options.launcher ?? "managed") === "npx" ? await resolveNpx(context.env, node) : node;
4041
4158
  return {
4042
4159
  scan,
4043
4160
  target,
4044
4161
  previous,
4045
4162
  effective,
4046
- kept
4163
+ kept,
4164
+ node,
4165
+ command
4047
4166
  };
4048
4167
  }
4168
+ /**
4169
+ * The `npx` an npx entry starts, as a full path: from PATH, or beside the `node` found, where npm installs it.
4170
+ *
4171
+ * Never the bare name. An entry that says only `npx` is resolved by the client, later, in its own environment — its
4172
+ * PATH, and on Windows its current folder — so the approval would bind a word, and the program run would be whichever
4173
+ * the client found. With no `npx` either way, nothing is written.
4174
+ */
4175
+ async function resolveNpx(env, node) {
4176
+ const found = await whichExecutable("npx", env) ?? await executableIn("npx", [dirname(node)], env);
4177
+ if (found !== null) return found;
4178
+ throw new CommsError("CONFIG", "npx could not be found, on PATH or beside node, so nothing was written", { hint: "Install Node.js with npm, which brings npx, or register with the managed launcher — the default — which needs no npx." });
4179
+ }
4180
+ /**
4181
+ * Where an install puts the entry and what the entry starts, as one sentence of the preview a person approves: the
4182
+ * client's own config file and the command, each with the home written `~`. In the approval's digest with the rest, so
4183
+ * a claim from an environment that resolves another file or another command — `CLAUDE_CONFIG_DIR` set to another
4184
+ * account's, a PATH that finds another node — is another change, and refused.
4185
+ */
4186
+ function entryDestination(client, target, command, env) {
4187
+ const home = homeDirectory(env);
4188
+ const file = target.own ?? target.configPath;
4189
+ return `the entry goes in ${file === void 0 ? `${client}'s own configuration` : shortenHome(file, home)}, and ${client} will start it with ${shortenHome(command, home)}`;
4190
+ }
4049
4191
  /** The plan a preflight arrived at, in the form `mcpInstall` compares: see `PlannedInstall`. */
4050
4192
  function plannedInstall(preflight) {
4051
- const { target, previous, effective } = preflight;
4193
+ const { target, previous, effective, node, command } = preflight;
4052
4194
  return {
4053
4195
  writes: target.writes,
4054
4196
  cliName: target.cliName,
@@ -4070,7 +4212,9 @@ function plannedInstall(preflight) {
4070
4212
  inbox: effective.inbox ?? null,
4071
4213
  workspace: effective.workspace ?? null,
4072
4214
  readOnly: effective.readOnly === true
4073
- }
4215
+ },
4216
+ node,
4217
+ command
4074
4218
  };
4075
4219
  }
4076
4220
  /**
@@ -4094,6 +4238,8 @@ function planDrift(product, client, name, planned, now) {
4094
4238
  }).join(" ") || "no pin";
4095
4239
  drift.push(`the server would start with ${flags(now.narrowing)}, not ${flags(planned.narrowing)}`);
4096
4240
  }
4241
+ if (planned.command !== now.command) drift.push(`it would start the server with ${now.command}, not ${planned.command}`);
4242
+ else if (planned.node !== now.node) drift.push(`it would run under the node at ${now.node}, not ${planned.node}`);
4097
4243
  return drift;
4098
4244
  }
4099
4245
  /** The planned pin and `--read-only`, as the options that start the server: each one given, none left to be found. */
@@ -4137,7 +4283,7 @@ async function mcpInstall(context, product, options, planned) {
4137
4283
  if (!writes) {
4138
4284
  if (existing.find((server) => server.client === options.client && server.name === name && server.scope !== "project" && !isProductServer(server, product))) warnings.push(`${options.client} already has a different server called "${name}"; pasting this would replace it. Use --name to choose another name.`);
4139
4285
  }
4140
- const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply);
4286
+ const { entry, launcher, runtimeMissing } = await buildEntry(context, product, effective, apply, preflight);
4141
4287
  const snippet = snippetFor(options.client, name, entry);
4142
4288
  const backupPath = previous.length > 0 ? await backUp(context, options.client, name, previous, /* @__PURE__ */ new Date()) : void 0;
4143
4289
  const ledger = handedOutRuntimesPath(context.core.paths.dataDir);
@@ -4191,13 +4337,13 @@ async function mcpInstall(context, product, options, planned) {
4191
4337
  name
4192
4338
  ];
4193
4339
  try {
4194
- await run(binary, removal);
4340
+ await run(context.env, binary, removal);
4195
4341
  } catch (error) {
4196
4342
  const message = error instanceof CommsError ? error.message : String(error);
4197
4343
  if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
4198
4344
  }
4199
4345
  try {
4200
- await run(binary, args);
4346
+ await run(context.env, binary, args);
4201
4347
  } catch (error) {
4202
4348
  const restore = options.client === "claude-code" ? [
4203
4349
  "mcp",
@@ -4221,7 +4367,7 @@ async function mcpInstall(context, product, options, planned) {
4221
4367
  ];
4222
4368
  let restored = true;
4223
4369
  try {
4224
- await run(binary, restore);
4370
+ await run(context.env, binary, restore);
4225
4371
  } catch {
4226
4372
  restored = false;
4227
4373
  }
@@ -4231,7 +4377,7 @@ async function mcpInstall(context, product, options, planned) {
4231
4377
  });
4232
4378
  }
4233
4379
  } else try {
4234
- await run(binary, args);
4380
+ await run(context.env, binary, args);
4235
4381
  } catch (error) {
4236
4382
  const message = error instanceof CommsError ? error.message : String(error);
4237
4383
  if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
@@ -4720,62 +4866,6 @@ function requireChannelManifest(channel) {
4720
4866
  if (manifest === void 0) throw notAChannel(channel);
4721
4867
  return manifest;
4722
4868
  }
4723
- /**
4724
- * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
4725
- * and the preview renderer (which makes them visible), so the two can never disagree.
4726
- */
4727
- /** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
4728
- const INVISIBLE_RANGES = [
4729
- [173, 173],
4730
- [847, 847],
4731
- [1564, 1564],
4732
- [4447, 4448],
4733
- [6068, 6069],
4734
- [6155, 6159],
4735
- [8203, 8207],
4736
- [8232, 8233],
4737
- [8234, 8238],
4738
- [8288, 8292],
4739
- [8294, 8303],
4740
- [12644, 12644],
4741
- [65024, 65039],
4742
- [65279, 65279],
4743
- [65440, 65440],
4744
- [917504, 917631]
4745
- ];
4746
- function isInvisible(codePoint) {
4747
- for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
4748
- return false;
4749
- }
4750
- /**
4751
- * C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
4752
- * among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
4753
- */
4754
- function isControl(codePoint) {
4755
- if (codePoint === 9 || codePoint === 10) return false;
4756
- return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
4757
- }
4758
- function isDangerous(codePoint) {
4759
- return isControl(codePoint) || isInvisible(codePoint);
4760
- }
4761
- /**
4762
- * Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
4763
- * characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
4764
- *
4765
- * This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
4766
- * drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
4767
- * has to happen before any such pattern runs, on every path, not only on the ones that render a body.
4768
- */
4769
- function stripInvisible(text) {
4770
- let removed = 0;
4771
- let out = "";
4772
- for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
4773
- else out += char;
4774
- return {
4775
- text: out,
4776
- removed
4777
- };
4778
- }
4779
4869
  function okEnvelope(data) {
4780
4870
  return {
4781
4871
  ok: true,
@@ -5958,7 +6048,7 @@ function isPrerelease(version) {
5958
6048
  return VERSION_PATTERN.exec(version)?.[4] !== void 0;
5959
6049
  }
5960
6050
  /** The package version, read from package.json at build time. */
5961
- const VERSION = "0.11.0";
6051
+ const VERSION = "0.12.1";
5962
6052
  /**
5963
6053
  * Registering, pruning and listing the MCP servers of every channel, from the core.
5964
6054
  *
@@ -6133,9 +6223,10 @@ function installOptions(request) {
6133
6223
  *
6134
6224
  * `own` is the calling channel's product, from its own `mcp install`: see `ownProduct`. The sentences depend on the
6135
6225
  * request and the machine, not on which surface prepared them, so an approval prepared by one is claimed by the
6136
- * other. The one sentence that names code — `--launcher local`, which starts a checkout — names the file that will be
6137
- * started; a core running from another build of the checkout than the channel's command names another file, and that
6138
- * is another registration, refused as one.
6226
+ * other. The sentences that name code name it as this environment resolves it: the client's config file and the node
6227
+ * or npx the entry starts (#46), and for `--launcher local` the checkout's file. A claim from an environment that
6228
+ * resolves another — `CLAUDE_CONFIG_DIR` set to another account's, a PATH that finds another node, a core running
6229
+ * from another build of the checkout — is another registration, refused as one.
6139
6230
  */
6140
6231
  function serverInstallChange(core, env, request, own) {
6141
6232
  checkRequest(request);
@@ -6159,7 +6250,7 @@ function serverInstallChange(core, env, request, own) {
6159
6250
  const product = await productNow();
6160
6251
  const { version } = product;
6161
6252
  const preflight = await preflightInstall(context, product, installOptions(request));
6162
- const { target, previous, effective, kept } = preflight;
6253
+ const { target, previous, effective, kept, command } = preflight;
6163
6254
  planned = {
6164
6255
  product,
6165
6256
  install: plannedInstall(preflight)
@@ -6168,12 +6259,14 @@ function serverInstallChange(core, env, request, own) {
6168
6259
  if (target.writes) {
6169
6260
  const pinned = pinName === void 0 || pinName === "readOnly" ? void 0 : effective[pinName];
6170
6261
  const pins = [pinned !== void 0 ? `pinned to the ${noun} ${pinned}` : "", effective.readOnly ? "read-only" : ""].filter(Boolean);
6171
- const replacing = previous.length > 0 ? `, replacing its own earlier entry of that name${kept.length > 0 ? ` and keeping ${kept.join(" ")} from it` : ""}` : "";
6262
+ const what = (pinName === void 0 || pinName === "readOnly" ? [] : [...new Set(previous.map((server) => facts.narrowingOf(server.args)[pinName] ?? null))]).map((pin) => pin === null ? `every ${noun}` : `the ${noun} ${pin}`).join(" and ");
6263
+ const replacing = previous.length > 0 ? `, replacing its own earlier entry of that name${what ? `, which served ${what}${kept.length > 0 ? "," : ""}` : ""}${kept.length > 0 ? ` and keeping ${kept.join(" ")} from it` : ""}` : "";
6172
6264
  effects.push(`registers the ${label} MCP server with ${request.client} as "${name}"${pins.length > 0 ? `, ${pins.join(", ")}` : ""}${replacing}`);
6173
6265
  const everyAccount = pinName !== void 0 && pinned === void 0;
6174
6266
  const everyTool = switchable && effective.readOnly !== true;
6175
6267
  if (everyAccount) effects.push(`not pinned: it reaches every ${noun} on this machine${everyTool ? ", with every tool" : ""}`);
6176
6268
  else if (everyTool) effects.push(`not read-only: it has every tool for ${pinned}, including those that change it`);
6269
+ effects.push(entryDestination(request.client, target, command, env));
6177
6270
  if (launcher === "npx") effects.push(`${request.client} will fetch ${facts.npxPackage}@${version} from npm each time it starts it`);
6178
6271
  else if (launcher === "local") effects.push(`${request.client} will start it from ${await localCliEntry(product.moduleUrl)}`);
6179
6272
  }
@@ -6213,7 +6306,7 @@ function serverPruneChange(core, env, request, own) {
6213
6306
  };
6214
6307
  const { packageName, version } = ownProduct(request.channel, own) ?? {
6215
6308
  packageName: channelServer(request.channel).packageName,
6216
- version: "0.11.0"
6309
+ version: "0.12.1"
6217
6310
  };
6218
6311
  const product = {
6219
6312
  packageName,
@@ -6552,7 +6645,6 @@ function updateLaterChange(core, options = {}) {
6552
6645
  }
6553
6646
  };
6554
6647
  }
6555
- channelServer("core").packageName;
6556
6648
  Object.freeze({
6557
6649
  kind: "send",
6558
6650
  lookup: true
@@ -6836,8 +6928,9 @@ async function askLine(streams, question) {
6836
6928
  rl.close();
6837
6929
  }
6838
6930
  }
6931
+ channelServer("core").packageName;
6839
6932
  //#endregion
6840
- //#region ../core/dist/tool-arguments-5aAzuZnA.mjs
6933
+ //#region ../core/dist/tool-arguments-CohGlMUZ.mjs
6841
6934
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
6842
6935
  const TARGET = "draft-2020-12";
6843
6936
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcomms/whatsapp",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",
@@ -38,7 +38,7 @@
38
38
  "@modelcontextprotocol/server": "^2.0.0",
39
39
  "commander": "^15.0.0",
40
40
  "zod": "^4.6.5",
41
- "@agentcomms/core": "0.11.0"
41
+ "@agentcomms/core": "0.12.1"
42
42
  },
43
43
  "agentcomms": {
44
44
  "contract": 1,
@@ -87,5 +87,5 @@
87
87
  "typecheck": "tsc -p tsconfig.json",
88
88
  "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test --test-timeout=600000 \"test/**/*.test.ts\""
89
89
  },
90
- "gitHead": "bcc1392f8380dbf523a910a87de652a18faead6f"
90
+ "gitHead": "62c13e382939518b8464df3e0f582d07b3bea4cd"
91
91
  }