@agentcomms/whatsapp 0.12.2 → 0.12.3

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,9 +1,9 @@
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-C2_cXhZ0.mjs";
2
+ import { $ as paint, A as CommsError, B as colorEnabled, C as checkStorePath, F as approvalsOf, G as gatedChangeAtTerminal, H as commandText, I as approveChangeAtTerminal, J as isCommsError, K as inlineCommand, L as canPrompt, M as absoluteSearchPath, N as agentMarker, O as CHANNELS, P as approvalKind, Q as openCore, R as channelManifest, S as storeIdentity, T as expandSource, U as escapeForDisplay, V as commandPathOf, W as exemptFromUpdateGate, X as nameAvailable, Y as lookupName, Z as nameShapeProblem, _ as chatRefOf, a as listChats, at as shellCommand, b as requireAccount, c as Presenter, ct as updateGateAtTerminal, d as draftMessage, dt as withFileLock, et as renderInstall, f as Visibility, ft as writeResult, g as requireSupportedNode, h as WhatsAppIndex, i as CHAT_KINDS, it as serverPruneChange, j as EXIT_CODES, k as CHANNEL_SERVERS, l as innerText, m as WhatsAppContext, n as whatsappStatus, nt as runCommand, o as readChat, p as listKey, pt as VERSION, q as installExitStatus, r as probeStore, rt as serverInstallChange, s as searchMessages, st as truncateDisplay, t as syncAccount, tt as renderPrune, ut as windowsSystemProgram, v as PLATFORM, w as defaultStorePath, x as requireNamedConfig, y as newWhatsAppAccount, z as childEnvironment } from "./sync-DJlsr6xq.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";
6
5
  import { stripVTControlCharacters } from "node:util";
6
+ import fs, { accessSync, constants as constants$1 } from "node:fs";
7
7
  import childProcess, { spawn } from "node:child_process";
8
8
  import process$1 from "node:process";
9
9
  import { EventEmitter } from "node:events";
@@ -2994,12 +2994,17 @@ const WHATSAPP_MCP = {
2994
2994
  function refuseAnAgent(context, command, what) {
2995
2995
  const marker = agentMarker(context.env);
2996
2996
  if (marker !== null || context.surface !== "cli") throw new CommsError("LOOSENING_REFUSED", `only a person ${what}`, {
2997
- hint: `Ask the person to run \`agent-whatsapp ${command}\` in their own terminal.`,
2997
+ hint: `Ask the person to run ${inlineCommand(command)} in their own terminal.`,
2998
2998
  ...marker === null ? {} : { details: { marker } }
2999
2999
  });
3000
3000
  }
3001
3001
  async function addAccount(context, request) {
3002
- refuseAnAgent(context, `add ${request.name}`, "chooses which WhatsApp store an agent may read");
3002
+ refuseAnAgent(context, shellCommand([
3003
+ "agent-whatsapp",
3004
+ "add",
3005
+ request.name,
3006
+ ...request.source === void 0 ? [] : ["--source", request.source]
3007
+ ], context.platform), "chooses which WhatsApp store an agent may read");
3003
3008
  const problem = nameShapeProblem(request.name, PLATFORM);
3004
3009
  if (problem) throw new CommsError("USAGE", problem);
3005
3010
  const isDefault = request.source === void 0;
@@ -3013,7 +3018,11 @@ async function addAccount(context, request) {
3013
3018
  const refuseTaken = (config) => {
3014
3019
  requireNamedConfig(config);
3015
3020
  const free = nameAvailable(config, "account", request.name, PLATFORM);
3016
- if (!free.ok) throw lookupName(config, "account", request.name)?.platform === "whatsapp" ? new CommsError("USAGE", `"${request.name}" is already added`, { hint: `Remove it first with \`agent-whatsapp remove ${request.name}\` to point it at another store.` }) : free.error;
3021
+ if (!free.ok) throw lookupName(config, "account", request.name)?.platform === "whatsapp" ? new CommsError("USAGE", `"${request.name}" is already added`, { hint: `Remove it first with ${inlineCommand(shellCommand([
3022
+ "agent-whatsapp",
3023
+ "remove",
3024
+ request.name
3025
+ ], context.platform))} to point it at another store.` }) : free.error;
3017
3026
  for (const [name, other] of Object.entries(config.accounts)) {
3018
3027
  if (other.platform !== "whatsapp") continue;
3019
3028
  if (context.storeOf(other).path === path || other.workspace === identity.workspace) throw new CommsError("USAGE", `that store is already added, as "${name}"`, { hint: "One name per message store." });
@@ -3043,7 +3052,12 @@ async function addAccount(context, request) {
3043
3052
  default: isDefault,
3044
3053
  id: account.workspace
3045
3054
  },
3046
- next: `agent-whatsapp sync --account ${request.name}`
3055
+ next: commandText(shellCommand([
3056
+ "agent-whatsapp",
3057
+ "sync",
3058
+ "--account",
3059
+ request.name
3060
+ ], context.platform))
3047
3061
  };
3048
3062
  }
3049
3063
  /**
@@ -3052,8 +3066,12 @@ async function addAccount(context, request) {
3052
3066
  */
3053
3067
  const REMOVE_WAIT_MS = 6e4;
3054
3068
  async function removeAccount(context, request) {
3055
- refuseAnAgent(context, `remove ${request.name}`, "removes a WhatsApp account");
3056
- const { account } = requireAccount(await context.config(), request.name);
3069
+ refuseAnAgent(context, shellCommand([
3070
+ "agent-whatsapp",
3071
+ "remove",
3072
+ request.name
3073
+ ], context.platform), "removes a WhatsApp account");
3074
+ const { account } = requireAccount(await context.config(), request.name, context.platform);
3057
3075
  await withFileLock(context.syncLock(account), async () => {
3058
3076
  await context.core.config.update((config) => {
3059
3077
  if (lookupName(config, "account", request.name)?.id !== account.id) throw new CommsError("TRANSIENT", `"${request.name}" changed while it was being removed`, { hint: "Nothing was removed. Run it again." });
@@ -3097,7 +3115,7 @@ function effectOf(lists) {
3097
3115
  async function named(context, name, account, chatId) {
3098
3116
  let index;
3099
3117
  try {
3100
- index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(void 0));
3118
+ index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(void 0), context.platform);
3101
3119
  } catch (error) {
3102
3120
  if (!isCommsError(error) || error.details?.reason !== "NOT_SYNCED") throw error;
3103
3121
  return {
@@ -3118,7 +3136,12 @@ async function named(context, name, account, chatId) {
3118
3136
  }
3119
3137
  /** The one path every list change takes: the person check first, before any input is read. */
3120
3138
  async function change(context, command, request, next) {
3121
- refuseAnAgent(context, command, "changes which chats an agent may see");
3139
+ refuseAnAgent(context, shellCommand([
3140
+ "agent-whatsapp",
3141
+ command,
3142
+ ...request.chat === void 0 ? [] : [request.chat],
3143
+ ...request.account === void 0 ? [] : ["--account", request.account]
3144
+ ], context.platform), "changes which chats an agent may see");
3122
3145
  const chatId = request.chat === void 0 ? void 0 : chatRefOf(request.chat, { international: true }).id;
3123
3146
  const { name, account } = await context.account(request.account);
3124
3147
  const { before, after } = await context.lists.update(account.id, (lists) => next(lists, chatId));
@@ -3128,7 +3151,12 @@ async function change(context, command, request, next) {
3128
3151
  deny: [...after.deny],
3129
3152
  changed: JSON.stringify(before) !== JSON.stringify(after),
3130
3153
  effect: effectOf(after),
3131
- next: `agent-whatsapp sync --account ${name}`,
3154
+ next: commandText(shellCommand([
3155
+ "agent-whatsapp",
3156
+ "sync",
3157
+ "--account",
3158
+ name
3159
+ ], context.platform)),
3132
3160
  ...chatId === void 0 ? {} : await named(context, name, account, chatId)
3133
3161
  };
3134
3162
  }
@@ -3215,7 +3243,7 @@ function renderAdded(result, color) {
3215
3243
  function renderRemoved(result, color) {
3216
3244
  return `${paint(color, "green", "Removed")} ${result.account}, and its local index. WhatsApp's own store was not touched.`;
3217
3245
  }
3218
- function renderStatus(result, color) {
3246
+ function renderStatus(result, color, platform = process.platform) {
3219
3247
  const lines = [];
3220
3248
  if (result.accounts.length === 0) lines.push(`No WhatsApp account yet. ${result.setup ?? ""}`);
3221
3249
  for (const account of result.accounts) {
@@ -3229,7 +3257,12 @@ function renderStatus(result, color) {
3229
3257
  lines.push(` index ${account.index.chats} chats, ${account.index.messages} messages, ${account.index.media} with media, ${account.index.statusChats} status-update chat(s) · synced ${account.index.indexedAt}`);
3230
3258
  if (account.index.unattributedStatus > 0) lines.push(` ${unattributed(account.index.unattributedStatus)}`);
3231
3259
  for (const entry of account.index.degraded) lines.push(paint(color, "dim", ` without ${entry.part}: ${entry.costs}`));
3232
- } else lines.push(` index not synced yet — agent-whatsapp sync --account ${account.account}`);
3260
+ } else lines.push(` index not synced yet — ${commandText(shellCommand([
3261
+ "agent-whatsapp",
3262
+ "sync",
3263
+ "--account",
3264
+ account.account
3265
+ ], platform))}`);
3233
3266
  }
3234
3267
  lines.push("", paint(color, "dim", `Reads: ${result.reads}.`), paint(color, "dim", `Sends: ${result.sends}.`));
3235
3268
  return lines.join("\n");
@@ -3338,6 +3371,7 @@ async function run(argv, deps = {}) {
3338
3371
  stdin: process.stdin
3339
3372
  };
3340
3373
  const env = deps.env ?? process.env;
3374
+ const platform = deps.platform ?? process.platform;
3341
3375
  const open = deps.open ?? openLink;
3342
3376
  const program = new Command();
3343
3377
  let exitCode = 0;
@@ -3365,7 +3399,8 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3365
3399
  const options = program.opts();
3366
3400
  return {
3367
3401
  json: Boolean(options.json),
3368
- color: colorEnabled(env, streams.stdout, options.color)
3402
+ color: colorEnabled(env, streams.stdout, options.color),
3403
+ platform
3369
3404
  };
3370
3405
  };
3371
3406
  /**
@@ -3451,7 +3486,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3451
3486
  account: flags.account === void 0 ? void 0 : String(flags.account),
3452
3487
  check: flags.check !== false
3453
3488
  });
3454
- writeResult(result, options, () => renderStatus(result, options.color), streams);
3489
+ writeResult(result, options, () => renderStatus(result, options.color, context.platform), streams);
3455
3490
  }));
3456
3491
  accountOption(program.command("sync")).description("copy WhatsApp's store privately, check it, and rebuild the local index from the copy").action(act(async (context, options, flags) => {
3457
3492
  const result = await syncAccount(context, { account: String(flags.account) });
@@ -3509,16 +3544,25 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3509
3544
  program.command("approve <approvalId>").description("approve a change at this terminal — registering or pruning this server — by reading it and typing the code back; WhatsApp never sends, so there is no message to approve").action(act(async (context, options, approvalId) => {
3510
3545
  const marker = agentMarker(env);
3511
3546
  if (marker) throw new CommsError("APPROVAL_REQUIRED", "only a person can approve a change, not an agent", {
3512
- hint: `Ask the person to run \`agent-whatsapp approve ${approvalId}\` in their own terminal.`,
3547
+ hint: `Ask the person to run ${inlineCommand(shellCommand([
3548
+ "agent-whatsapp",
3549
+ "approve",
3550
+ approvalId
3551
+ ], context.platform))} in their own terminal.`,
3513
3552
  details: { marker }
3514
3553
  });
3515
- if (!canPrompt(env, streams, { json: options.json })) throw new CommsError("APPROVAL_REQUIRED", "approving a change needs an interactive terminal", { hint: `Run \`agent-whatsapp approve ${approvalId}\` directly in a terminal.` });
3554
+ if (!canPrompt(env, streams, { json: options.json })) throw new CommsError("APPROVAL_REQUIRED", "approving a change needs an interactive terminal", { hint: `Run ${inlineCommand(shellCommand([
3555
+ "agent-whatsapp",
3556
+ "approve",
3557
+ approvalId
3558
+ ], context.platform))} directly in a terminal.` });
3516
3559
  const pending = await context.core.approvals.get(approvalId);
3517
3560
  if (!pending) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "It may have expired, been used, or been cancelled. Prepare the change again." });
3518
3561
  if (approvalKind(pending) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, and WhatsApp never sends`, { hint: `Approve it with the command that prepared it — ${sendApproveCommands()} — not this one.` });
3519
3562
  const outcome = await approveChangeAtTerminal(context.core, approvalId, env, {
3520
3563
  json: options.json,
3521
- color: options.color
3564
+ color: options.color,
3565
+ platform: context.platform
3522
3566
  }, streams);
3523
3567
  streams.stdout.write(outcome.state === "approved" ? "Approved. This command approves; the change is applied by the command that prepared it.\n" : "Cancelled. Nothing was changed.\n");
3524
3568
  }));
@@ -3534,9 +3578,9 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3534
3578
  ran = true;
3535
3579
  exitCode = await runCommand(output(), async () => {
3536
3580
  requireSupportedNode();
3537
- const { startWhatsAppStdioServer } = await import("./stdio-entry-irMynM6w.mjs");
3538
- await startWhatsAppStdioServer({
3581
+ await (deps.startMcp ?? (await import("./stdio-entry-CAM7LpPv.mjs")).startWhatsAppStdioServer)({
3539
3582
  env,
3583
+ platform,
3540
3584
  ...flags.account ? { account: String(flags.account) } : {}
3541
3585
  });
3542
3586
  }, streams);
@@ -3559,7 +3603,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3559
3603
  const launcher = flags.launcher;
3560
3604
  const name = flags.name;
3561
3605
  await context.migration();
3562
- const again = [
3606
+ const again = shellCommand([
3563
3607
  "agent-whatsapp",
3564
3608
  "mcp",
3565
3609
  "install",
@@ -3570,7 +3614,7 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3570
3614
  ...launcher !== void 0 ? ["--launcher", launcher] : [],
3571
3615
  ...flags.verify === false ? ["--no-verify"] : [],
3572
3616
  ...flags.force === true ? ["--force"] : []
3573
- ].join(" ");
3617
+ ], context.platform);
3574
3618
  const result = await changeAt(context, serverInstallChange(context.core, env, {
3575
3619
  channel: "whatsapp",
3576
3620
  client: flags.client,
@@ -3590,7 +3634,12 @@ a macOS dialog may be waiting) · 77 permission needed (macOS privacy) · 78 con
3590
3634
  channel: "whatsapp",
3591
3635
  dryRun: flags.dryRun === true,
3592
3636
  includePrinted: flags.includePrinted === true
3593
- }, WHATSAPP_MCP), flags, `agent-whatsapp mcp prune${flags.includePrinted === true ? " --include-printed" : ""}`);
3637
+ }, WHATSAPP_MCP), flags, shellCommand([
3638
+ "agent-whatsapp",
3639
+ "mcp",
3640
+ "prune",
3641
+ ...flags.includePrinted === true ? ["--include-printed"] : []
3642
+ ], context.platform));
3594
3643
  writeResult(result, output(), () => renderPrune(result, options.color), streams);
3595
3644
  }));
3596
3645
  try {
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { u as composeDraft, ut as VERSION } from "./sync-C2_cXhZ0.mjs";
2
- import { t as createWhatsAppMcpServer } from "./server-ag_r78jB.mjs";
1
+ import { pt as VERSION, u as composeDraft } from "./sync-DJlsr6xq.mjs";
2
+ import { t as createWhatsAppMcpServer } from "./server-B1EmZ6FZ.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-C2_cXhZ0.mjs";
1
+ import { D as strictToolArguments, E as UNTRUSTED_NOTICE, a as listChats, d as draftMessage, i as CHAT_KINDS, lt as updateToolGate, m as WhatsAppContext, n as whatsappStatus, o as readChat, ot as toCommsError, pt as VERSION, s as searchMessages, t as syncAccount } from "./sync-DJlsr6xq.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-ag_r78jB.mjs";
1
+ import { t as createWhatsAppMcpServer } from "./server-B1EmZ6FZ.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
@@ -4,16 +4,16 @@ import path, { basename, delimiter, dirname, isAbsolute, join, resolve, sep } fr
4
4
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ import { styleText } from "node:util";
7
8
  import { homedir, platform, userInfo } from "node:os";
8
9
  import { constants as constants$1 } from "node:fs";
9
- import { styleText } from "node:util";
10
10
  import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$1 = "0.12.2";
14
+ const VERSION$1 = "0.12.3";
15
15
  //#endregion
16
- //#region ../core/dist/update-check-B-WYPZae.mjs
16
+ //#region ../core/dist/update-check-Dq-D4bAD.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
@@ -966,6 +966,253 @@ function connectMailboxCommand() {
966
966
  const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
967
967
  return mail ? `${mail.binary} inbox add` : void 0;
968
968
  }
969
+ /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
970
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
971
+ function randomString(length) {
972
+ const bytes = randomBytes(length);
973
+ let out = "";
974
+ for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
975
+ return out;
976
+ }
977
+ /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
978
+ const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
979
+ const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
980
+ function newApprovalId() {
981
+ return `ap_${randomString(26)}`;
982
+ }
983
+ function newPlanToken() {
984
+ return `pl_${randomString(26)}`;
985
+ }
986
+ const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
987
+ /** A short challenge a human types back to approve: letters only, no look-alikes. */
988
+ function newChallenge(length = 4) {
989
+ let out = "";
990
+ for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
991
+ return out;
992
+ }
993
+ /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
994
+ function hashChallenge(challenge) {
995
+ return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
996
+ }
997
+ /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
998
+ function challengeMatches(answer, storedHash) {
999
+ const a = Buffer.from(hashChallenge(answer), "hex");
1000
+ const b = Buffer.from(storedHash, "hex");
1001
+ return a.length === b.length && timingSafeEqual(a, b);
1002
+ }
1003
+ function okEnvelope(data) {
1004
+ return {
1005
+ ok: true,
1006
+ schemaVersion: 1,
1007
+ data
1008
+ };
1009
+ }
1010
+ function errorEnvelope(error) {
1011
+ const body = {
1012
+ code: error.code,
1013
+ message: error.message
1014
+ };
1015
+ if (error.hint !== void 0) body.hint = error.hint;
1016
+ if (error.details !== void 0) body.details = error.details;
1017
+ return {
1018
+ ok: false,
1019
+ schemaVersion: 1,
1020
+ error: body
1021
+ };
1022
+ }
1023
+ const defaultStreams = {
1024
+ stdout: process.stdout,
1025
+ stderr: process.stderr,
1026
+ stdin: process.stdin
1027
+ };
1028
+ function colorEnabled(env, stream, flag) {
1029
+ if (flag === false) return false;
1030
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
1031
+ if (env.TERM === "dumb") return false;
1032
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
1033
+ return Boolean(stream.isTTY);
1034
+ }
1035
+ /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
1036
+ function canPrompt(env, streams, options) {
1037
+ if (options.json || options.noInput) return false;
1038
+ if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
1039
+ return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
1040
+ }
1041
+ /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
1042
+ const AGENT_MARKERS = [
1043
+ "CLAUDECODE",
1044
+ "CLAUDE_CODE_ENTRYPOINT",
1045
+ "CODEX_SANDBOX",
1046
+ "CODEX_HOME",
1047
+ "CURSOR_AGENT",
1048
+ "GEMINI_CLI",
1049
+ "AGENT_COMMS_AGENT"
1050
+ ];
1051
+ function agentMarker(env) {
1052
+ for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
1053
+ return null;
1054
+ }
1055
+ function paint(color, format, text) {
1056
+ return color ? styleText(format, text, { validateStream: false }) : text;
1057
+ }
1058
+ /**
1059
+ * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
1060
+ * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
1061
+ * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
1062
+ *
1063
+ * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
1064
+ * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
1065
+ * as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
1066
+ * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
1067
+ * has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
1068
+ * the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
1069
+ * hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
1070
+ * reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
1071
+ * line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
1072
+ * applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
1073
+ * 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
1074
+ * `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
1075
+ * old way, and cmd.exe then reads that new line. So on Windows:
1076
+ *
1077
+ * - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
1078
+ * does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
1079
+ * splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
1080
+ * that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
1081
+ * is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
1082
+ * - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
1083
+ * `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
1084
+ * backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
1085
+ * quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
1086
+ * expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
1087
+ * expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
1088
+ * escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
1089
+ * tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
1090
+ * that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
1091
+ * backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
1092
+ * backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
1093
+ * which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
1094
+ * - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
1095
+ * as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
1096
+ * still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
1097
+ * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
1098
+ * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
1099
+ *
1100
+ * Everywhere else there is always a line: single quotes make any word safe.
1101
+ */
1102
+ function shellCommand(words, platform = process.platform) {
1103
+ if (platform !== "win32") return {
1104
+ words,
1105
+ line: words.map(posixShellWord).join(" "),
1106
+ platform
1107
+ };
1108
+ const printed = words.map(windowsShellWord);
1109
+ return {
1110
+ words,
1111
+ line: printed.includes(null) ? null : printed.join(" "),
1112
+ platform
1113
+ };
1114
+ }
1115
+ /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
1116
+ function withWords(command, ...more) {
1117
+ return shellCommand([...command.words, ...more], command.platform);
1118
+ }
1119
+ function posixShellWord(word) {
1120
+ return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1121
+ }
1122
+ /**
1123
+ * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
1124
+ * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
1125
+ */
1126
+ function windowsShellWord(word) {
1127
+ if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
1128
+ if (word === "" || word.endsWith("\\")) return null;
1129
+ if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
1130
+ if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
1131
+ return `"${word}"`;
1132
+ }
1133
+ /**
1134
+ * The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
1135
+ * parse, and nothing to run.
1136
+ *
1137
+ * Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
1138
+ * opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
1139
+ * finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
1140
+ * `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
1141
+ * JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
1142
+ * as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
1143
+ * so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
1144
+ * doubled, as JSON has it.
1145
+ */
1146
+ function commandAsJson(words) {
1147
+ const word = (text) => {
1148
+ let json = "";
1149
+ for (let index = 0; index < text.length; index++) {
1150
+ const code = text.charCodeAt(index);
1151
+ const char = text[index];
1152
+ if (char === "\\") json += "\\\\";
1153
+ else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
1154
+ else json += `\\u${code.toString(16).padStart(4, "0")}`;
1155
+ }
1156
+ return `"${json}"`;
1157
+ };
1158
+ return `[${words.map(word).join(",")}]`;
1159
+ }
1160
+ /** What is said beside a command shown as JSON. */
1161
+ const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
1162
+ /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
1163
+ function inlineCommand(command) {
1164
+ return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
1165
+ }
1166
+ /** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
1167
+ function commandText(command) {
1168
+ return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
1169
+ }
1170
+ /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
1171
+ function writeResult(data, options, human, streams = defaultStreams) {
1172
+ if (options.json) {
1173
+ streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
1174
+ return;
1175
+ }
1176
+ const text = human(data);
1177
+ if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
1178
+ }
1179
+ /** Writes an error and returns the exit code to use. */
1180
+ function writeError(error, options, streams = defaultStreams) {
1181
+ const commsError = toCommsError(error);
1182
+ if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
1183
+ else {
1184
+ streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
1185
+ if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
1186
+ }
1187
+ return commsError.exitCode;
1188
+ }
1189
+ /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
1190
+ async function runCommand(options, body, streams = defaultStreams) {
1191
+ try {
1192
+ await body();
1193
+ return EXIT_CODES.OK;
1194
+ } catch (error) {
1195
+ return writeError(error, options, streams);
1196
+ }
1197
+ }
1198
+ /**
1199
+ * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
1200
+ * whose code is not its own to make up.
1201
+ *
1202
+ * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
1203
+ * but it refuses in exactly the same order and words, because it is the same gate.
1204
+ */
1205
+ function refuseUnlessPerson(env, streams, gate) {
1206
+ const marker = agentMarker(env);
1207
+ if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
1208
+ hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
1209
+ details: { marker }
1210
+ });
1211
+ const prompting = {};
1212
+ if (gate.json !== void 0) prompting.json = gate.json;
1213
+ if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
1214
+ if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
1215
+ }
969
1216
  /**
970
1217
  * One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
971
1218
  * and the preview renderer (which makes them visible), so the two can never disagree.
@@ -1967,40 +2214,6 @@ function canonicalLoosening(loosening) {
1967
2214
  function sameLoosening(a, b) {
1968
2215
  return canonicalLoosening(a) === canonicalLoosening(b);
1969
2216
  }
1970
- /** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
1971
- const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1972
- function randomString(length) {
1973
- const bytes = randomBytes(length);
1974
- let out = "";
1975
- for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
1976
- return out;
1977
- }
1978
- /** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
1979
- const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
1980
- const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
1981
- function newApprovalId() {
1982
- return `ap_${randomString(26)}`;
1983
- }
1984
- function newPlanToken() {
1985
- return `pl_${randomString(26)}`;
1986
- }
1987
- const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
1988
- /** A short challenge a human types back to approve: letters only, no look-alikes. */
1989
- function newChallenge(length = 4) {
1990
- let out = "";
1991
- for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
1992
- return out;
1993
- }
1994
- /** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
1995
- function hashChallenge(challenge) {
1996
- return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
1997
- }
1998
- /** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
1999
- function challengeMatches(answer, storedHash) {
2000
- const a = Buffer.from(hashChallenge(answer), "hex");
2001
- const b = Buffer.from(storedHash, "hex");
2002
- return a.length === b.length && timingSafeEqual(a, b);
2003
- }
2004
2217
  /**
2005
2218
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
2006
2219
  * names it showed them under.
@@ -2289,19 +2502,23 @@ var ApprovalStore = class {
2289
2502
  * Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
2290
2503
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
2291
2504
  */
2292
- #requireKind(record, kind) {
2505
+ #requireKind(record, kind, platform) {
2293
2506
  const actual = approvalKind(record);
2294
2507
  if (actual === kind) return;
2295
2508
  const id = record.approvalId;
2296
2509
  if (actual === "download") throw (kind === "change" ? refuseChange : refuse)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
2297
2510
  if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
2298
- throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with \`agentcomms approve ${id}\` — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
2511
+ throw kind === "send" ? refuse("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with ${inlineCommand(shellCommand([
2512
+ "agentcomms",
2513
+ "approve",
2514
+ id
2515
+ ], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
2299
2516
  }
2300
2517
  /** Issues a new challenge to show a human; only its hash is kept. */
2301
- async issueChallenge(approvalId, kind = "send") {
2518
+ async issueChallenge(approvalId, kind = "send", platform = process.platform) {
2302
2519
  const challenge = newChallenge();
2303
2520
  await this.#transition(approvalId, (current) => {
2304
- this.#requireKind(current, kind);
2521
+ this.#requireKind(current, kind, platform);
2305
2522
  if (current.state !== "pending") throw this.#stateError(current);
2306
2523
  return {
2307
2524
  ...current,
@@ -2318,10 +2535,10 @@ var ApprovalStore = class {
2318
2535
  * A change is approved the same way, with `kind: 'change'` and its digest standing in for the draft (see
2319
2536
  * `createChange`), so a person's typed code means one thing whichever kind of approval it is typed for.
2320
2537
  */
2321
- async approve(approvalId, via, live, answer, kind = "send") {
2538
+ async approve(approvalId, via, live, answer, kind = "send", platform = process.platform) {
2322
2539
  let failure = null;
2323
2540
  const result = await this.#transition(approvalId, (current) => {
2324
- this.#requireKind(current, kind);
2541
+ this.#requireKind(current, kind, platform);
2325
2542
  if (current.state !== "pending") throw this.#stateError(current);
2326
2543
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
2327
2544
  if (live.draftMessageId !== current.draftMessageId || live.digest !== current.digest) {
@@ -2378,7 +2595,7 @@ var ApprovalStore = class {
2378
2595
  async claimForSend(approvalId, live, options = {}) {
2379
2596
  let failure = null;
2380
2597
  const result = await this.#transition(approvalId, (current) => {
2381
- this.#requireKind(current, "send");
2598
+ this.#requireKind(current, "send", options.platform ?? process.platform);
2382
2599
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2383
2600
  if (options.signal?.aborted) throw cancelledClaim(current);
2384
2601
  const voidWith = (code, reason) => {
@@ -2489,7 +2706,7 @@ var ApprovalStore = class {
2489
2706
  const digest = changeDigest(live.change);
2490
2707
  let failure = null;
2491
2708
  const result = await this.#transition(approvalId, (current) => {
2492
- this.#requireKind(current, "change");
2709
+ this.#requireKind(current, "change", options.platform ?? process.platform);
2493
2710
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2494
2711
  if (options.signal?.aborted) throw cancelledClaim(current);
2495
2712
  const voidWith = (reason) => {
@@ -2505,7 +2722,11 @@ var ApprovalStore = class {
2505
2722
  };
2506
2723
  if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
2507
2724
  if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
2508
- if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run \`agentcomms approve ${approvalId}\` in their own terminal, then try again with the same approval.`);
2725
+ if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? `Ask the user to run ${inlineCommand(shellCommand([
2726
+ "agentcomms",
2727
+ "approve",
2728
+ approvalId
2729
+ ], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
2509
2730
  if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
2510
2731
  }
2511
2732
  return {
@@ -2582,13 +2803,13 @@ var ApprovalStore = class {
2582
2803
  *
2583
2804
  * Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
2584
2805
  */
2585
- async answerDownload(approvalId, via, answer) {
2806
+ async answerDownload(approvalId, via, answer, platform = process.platform) {
2586
2807
  const recorded = answer.choice === "other" ? {
2587
2808
  choice: "other",
2588
2809
  folder: answer.folder
2589
2810
  } : { choice: answer.choice };
2590
2811
  return await this.#transition(approvalId, (current) => {
2591
- this.#requireKind(current, "download");
2812
+ this.#requireKind(current, "download", platform);
2592
2813
  if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
2593
2814
  if (current.state !== "pending") throw this.#stateError(current);
2594
2815
  if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
@@ -2627,7 +2848,7 @@ var ApprovalStore = class {
2627
2848
  const digest = downloadDigest(live);
2628
2849
  let failure = null;
2629
2850
  const result = await this.#transition(approvalId, (current) => {
2630
- this.#requireKind(current, "download");
2851
+ this.#requireKind(current, "download", options.platform ?? process.platform);
2631
2852
  if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2632
2853
  if (options.signal?.aborted) throw cancelledClaim(current);
2633
2854
  const voidWith = (reason) => {
@@ -2898,219 +3119,6 @@ function nameAvailable(config, kind, name, platform) {
2898
3119
  }
2899
3120
  return { ok: true };
2900
3121
  }
2901
- function okEnvelope(data) {
2902
- return {
2903
- ok: true,
2904
- schemaVersion: 1,
2905
- data
2906
- };
2907
- }
2908
- function errorEnvelope(error) {
2909
- const body = {
2910
- code: error.code,
2911
- message: error.message
2912
- };
2913
- if (error.hint !== void 0) body.hint = error.hint;
2914
- if (error.details !== void 0) body.details = error.details;
2915
- return {
2916
- ok: false,
2917
- schemaVersion: 1,
2918
- error: body
2919
- };
2920
- }
2921
- const defaultStreams = {
2922
- stdout: process.stdout,
2923
- stderr: process.stderr,
2924
- stdin: process.stdin
2925
- };
2926
- function colorEnabled(env, stream, flag) {
2927
- if (flag === false) return false;
2928
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
2929
- if (env.TERM === "dumb") return false;
2930
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
2931
- return Boolean(stream.isTTY);
2932
- }
2933
- /** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
2934
- function canPrompt(env, streams, options) {
2935
- if (options.json || options.noInput) return false;
2936
- if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
2937
- return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
2938
- }
2939
- /** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
2940
- const AGENT_MARKERS = [
2941
- "CLAUDECODE",
2942
- "CLAUDE_CODE_ENTRYPOINT",
2943
- "CODEX_SANDBOX",
2944
- "CODEX_HOME",
2945
- "CURSOR_AGENT",
2946
- "GEMINI_CLI",
2947
- "AGENT_COMMS_AGENT"
2948
- ];
2949
- function agentMarker(env) {
2950
- for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
2951
- return null;
2952
- }
2953
- function paint(color, format, text) {
2954
- return color ? styleText(format, text, { validateStream: false }) : text;
2955
- }
2956
- /**
2957
- * A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
2958
- * Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
2959
- * entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
2960
- *
2961
- * Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
2962
- * but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
2963
- * as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
2964
- * PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
2965
- * has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
2966
- * the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
2967
- * hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
2968
- * reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
2969
- * line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
2970
- * applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
2971
- * 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
2972
- * `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
2973
- * old way, and cmd.exe then reads that new line. So on Windows:
2974
- *
2975
- * - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
2976
- * does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
2977
- * splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
2978
- * that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
2979
- * is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
2980
- * - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
2981
- * `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
2982
- * backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
2983
- * quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
2984
- * expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
2985
- * expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
2986
- * escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
2987
- * tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
2988
- * that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
2989
- * backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
2990
- * backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
2991
- * which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
2992
- * - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
2993
- * as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
2994
- * still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
2995
- * hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
2996
- * as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
2997
- *
2998
- * Everywhere else there is always a line: single quotes make any word safe.
2999
- */
3000
- function shellCommand(words, platform = process.platform) {
3001
- if (platform !== "win32") return {
3002
- words,
3003
- line: words.map(posixShellWord).join(" "),
3004
- platform
3005
- };
3006
- const printed = words.map(windowsShellWord);
3007
- return {
3008
- words,
3009
- line: printed.includes(null) ? null : printed.join(" "),
3010
- platform
3011
- };
3012
- }
3013
- /** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
3014
- function withWords(command, ...more) {
3015
- return shellCommand([...command.words, ...more], command.platform);
3016
- }
3017
- function posixShellWord(word) {
3018
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
3019
- }
3020
- /**
3021
- * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
3022
- * does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
3023
- */
3024
- function windowsShellWord(word) {
3025
- if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
3026
- if (word === "" || word.endsWith("\\")) return null;
3027
- if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
3028
- if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
3029
- return `"${word}"`;
3030
- }
3031
- /**
3032
- * The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
3033
- * parse, and nothing to run.
3034
- *
3035
- * Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
3036
- * opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
3037
- * finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
3038
- * `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
3039
- * JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
3040
- * as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
3041
- * so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
3042
- * doubled, as JSON has it.
3043
- */
3044
- function commandAsJson(words) {
3045
- const word = (text) => {
3046
- let json = "";
3047
- for (let index = 0; index < text.length; index++) {
3048
- const code = text.charCodeAt(index);
3049
- const char = text[index];
3050
- if (char === "\\") json += "\\\\";
3051
- else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
3052
- else json += `\\u${code.toString(16).padStart(4, "0")}`;
3053
- }
3054
- return `"${json}"`;
3055
- };
3056
- return `[${words.map(word).join(",")}]`;
3057
- }
3058
- /** What is said beside a command shown as JSON. */
3059
- const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
3060
- /** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
3061
- function inlineCommand(command) {
3062
- return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
3063
- }
3064
- /** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
3065
- function commandText(command) {
3066
- return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
3067
- }
3068
- /** Writes a successful result: the envelope with --json, otherwise the human rendering. */
3069
- function writeResult(data, options, human, streams = defaultStreams) {
3070
- if (options.json) {
3071
- streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
3072
- return;
3073
- }
3074
- const text = human(data);
3075
- if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
3076
- }
3077
- /** Writes an error and returns the exit code to use. */
3078
- function writeError(error, options, streams = defaultStreams) {
3079
- const commsError = toCommsError(error);
3080
- if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
3081
- else {
3082
- streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
3083
- if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
3084
- }
3085
- return commsError.exitCode;
3086
- }
3087
- /** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
3088
- async function runCommand(options, body, streams = defaultStreams) {
3089
- try {
3090
- await body();
3091
- return EXIT_CODES.OK;
3092
- } catch (error) {
3093
- return writeError(error, options, streams);
3094
- }
3095
- }
3096
- /**
3097
- * The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
3098
- * whose code is not its own to make up.
3099
- *
3100
- * `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
3101
- * but it refuses in exactly the same order and words, because it is the same gate.
3102
- */
3103
- function refuseUnlessPerson(env, streams, gate) {
3104
- const marker = agentMarker(env);
3105
- if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
3106
- hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
3107
- details: { marker }
3108
- });
3109
- const prompting = {};
3110
- if (gate.json !== void 0) prompting.json = gate.json;
3111
- if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
3112
- if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
3113
- }
3114
3122
  /**
3115
3123
  * Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
3116
3124
  *
@@ -4544,7 +4552,7 @@ async function mcpInstall(context, product, options, planned) {
4544
4552
  };
4545
4553
  const existing = scan.servers;
4546
4554
  const { cliName, binary, own, configPath, writes } = target;
4547
- const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
4555
+ const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
4548
4556
  if (kept.length > 0) {
4549
4557
  const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
4550
4558
  "claude",
@@ -5050,8 +5058,8 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
5050
5058
  return findings;
5051
5059
  }
5052
5060
  /** What registering a server says about the rival packages its manifest names: one line each. */
5053
- function rivalPackageWarnings(servers, packages) {
5054
- return findRivalPackageServers(servers, packages).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
5061
+ function rivalPackageWarnings(servers, packages, platform = process.platform) {
5062
+ return findRivalPackageServers(servers, packages, platform).map((finding) => `${finding.packageName} is registered with ${finding.client} as "${finding.name}": ${finding.reason}. Remove it: ${finding.removal}`);
5055
5063
  }
5056
5064
  /**
5057
5065
  * Registered servers that name the service's `word` anywhere — their name, command, arguments or URL — and are not
@@ -5137,7 +5145,7 @@ function serverFactsOf({ packageName, manifest }) {
5137
5145
  if (rivals === void 0) return facts;
5138
5146
  return {
5139
5147
  ...facts,
5140
- warnAbout: (servers) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
5148
+ warnAbout: (servers, platform) => [...rivals.packages ? rivalPackageWarnings(servers, rivals.packages, platform) : [], ...rivals.word !== void 0 && rivals.can !== void 0 ? rivalWordWarnings(servers, rivals.word, rivals.can, facts) : []]
5141
5149
  };
5142
5150
  }
5143
5151
  const CHANNEL_SERVERS = Object.freeze(Object.fromEntries(CHANNEL_SNAPSHOT.map((entry) => [entry.manifest.channel, serverFactsOf(entry)])));
@@ -5228,9 +5236,9 @@ function renderPrune(result, color) {
5228
5236
  if (lines.length === 0) lines.push(`No managed runtimes to remove in ${result.runtimeDir}.`);
5229
5237
  return lines.join("\n");
5230
5238
  }
5231
- /** The terminal command that approves a change, for the channel that asked. */
5232
- function approveCommandOf(options) {
5233
- return options.approveCommand ?? "agentcomms approve";
5239
+ /** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
5240
+ function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
5241
+ return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
5234
5242
  }
5235
5243
  /**
5236
5244
  * The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
@@ -5341,7 +5349,7 @@ async function prepareChange(core, request, options) {
5341
5349
  change: binding
5342
5350
  }),
5343
5351
  expiresAt: record.expiresAt,
5344
- next: nextStep(record.approvalId, policy, approveCommandOf(options))
5352
+ next: nextStep(record.approvalId, policy, options)
5345
5353
  };
5346
5354
  } catch (error) {
5347
5355
  await auditRefusal(core, "change.prepare", error, {
@@ -5372,7 +5380,10 @@ async function claimChange(core, approvalId, expect, options) {
5372
5380
  const record = await core.approvals.claimForChange(approvalId, {
5373
5381
  change: binding,
5374
5382
  policy
5375
- }, { pendingHint: `Ask the user to run \`${approveCommandOf(options)} ${approvalId}\` in their own terminal, then try again with the same approval.` });
5383
+ }, {
5384
+ pendingHint: `Ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal, then try again with the same approval.`,
5385
+ platform: options.platform
5386
+ });
5376
5387
  const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
5377
5388
  await auditChange(core, {
5378
5389
  operation: "change.claim",
@@ -5420,7 +5431,7 @@ async function beginChangeApproval(core, approvalId, options) {
5420
5431
  let record;
5421
5432
  try {
5422
5433
  record = await changeRecord(core, approvalId);
5423
- const challenge = await core.approvals.issueChallenge(approvalId, "change");
5434
+ const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
5424
5435
  return {
5425
5436
  approvalId,
5426
5437
  preview: renderChangePreview(record),
@@ -5445,7 +5456,7 @@ async function finishChangeApproval(core, approvalId, answer, options) {
5445
5456
  const approved = await core.approvals.approve(approvalId, "terminal", {
5446
5457
  draftMessageId: digest,
5447
5458
  digest
5448
- }, answer, "change");
5459
+ }, answer, "change", options.platform);
5449
5460
  await auditChange(core, {
5450
5461
  operation: "change.approve",
5451
5462
  outcome: "ok",
@@ -5501,8 +5512,8 @@ async function revokeChange(core, approvalId, reason, options) {
5501
5512
  function changePolicyOf(record) {
5502
5513
  return record.requiredPolicy === "chat" ? "chat" : "confirm";
5503
5514
  }
5504
- function nextStep(approvalId, policy, approveCommand) {
5505
- return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run \`${approveCommand} ${approvalId}\` in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
5515
+ function nextStep(approvalId, policy, options) {
5516
+ return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${inlineCommand(changeApprovalCommand(options.approveCommand, approvalId, options.platform))} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`;
5506
5517
  }
5507
5518
  /**
5508
5519
  * One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
@@ -5651,12 +5662,14 @@ async function gatedChange(core, change, options) {
5651
5662
  status: "approval-required",
5652
5663
  prepared: await prepareChange(core, request, {
5653
5664
  surface: options.surface,
5654
- approveCommand: options.approveCommand
5665
+ approveCommand: options.approveCommand,
5666
+ platform: options.platform
5655
5667
  })
5656
5668
  };
5657
5669
  const consent = await claimChange(core, options.approvalId, request, {
5658
5670
  surface: options.surface,
5659
- approveCommand: options.approveCommand
5671
+ approveCommand: options.approveCommand,
5672
+ platform: options.platform
5660
5673
  });
5661
5674
  return {
5662
5675
  status: "applied",
@@ -5677,7 +5690,8 @@ async function gatedChangeAtTerminal(core, change, options) {
5677
5690
  const first = await gatedChange(core, change, {
5678
5691
  surface: "cli",
5679
5692
  approvalId: options.approvalId,
5680
- approveCommand
5693
+ approveCommand,
5694
+ platform: options.output.platform
5681
5695
  });
5682
5696
  if (first.status === "applied") return first.result;
5683
5697
  const { prepared } = first;
@@ -5686,7 +5700,7 @@ async function gatedChangeAtTerminal(core, change, options) {
5686
5700
  const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
5687
5701
  const { command } = options;
5688
5702
  throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
5689
- hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand),
5703
+ hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand, options.output.platform),
5690
5704
  details: {
5691
5705
  approvalId: prepared.approvalId,
5692
5706
  policy: prepared.policy,
@@ -5707,7 +5721,8 @@ async function gatedChangeAtTerminal(core, change, options) {
5707
5721
  const second = await gatedChange(core, change, {
5708
5722
  surface: "cli",
5709
5723
  approvalId: prepared.approvalId,
5710
- approveCommand
5724
+ approveCommand,
5725
+ platform: options.output.platform
5711
5726
  });
5712
5727
  if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
5713
5728
  return second.result;
@@ -5719,9 +5734,9 @@ async function gatedChangeAtTerminal(core, change, options) {
5719
5734
  * Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
5720
5735
  * its registration step and says why in its report — so the two say it in the same words.
5721
5736
  */
5722
- function approvalHint(prepared, rerun, approveCommand) {
5737
+ function approvalHint(prepared, rerun, approveCommand, platform = typeof rerun === "string" ? process.platform : rerun.platform) {
5723
5738
  const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
5724
- return prepared.policy === "confirm" ? `Show the person the preview. They run \`${approveCommandOf({ approveCommand })} ${prepared.approvalId}\`; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
5739
+ return prepared.policy === "confirm" ? `Show the person the preview. They run ${inlineCommand(changeApprovalCommand(approveCommand, prepared.approvalId, platform))}; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
5725
5740
  }
5726
5741
  /**
5727
5742
  * Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
@@ -5741,7 +5756,11 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
5741
5756
  refuseUnlessPerson(env, streams, {
5742
5757
  refusedToAgent: "only a person can approve a change, not an agent",
5743
5758
  refusedWithoutTerminal: "approving a change needs an interactive terminal",
5744
- command: `agentcomms approve ${approvalId}`,
5759
+ command: commandText(shellCommand([
5760
+ "agentcomms",
5761
+ "approve",
5762
+ approvalId
5763
+ ], output.platform)),
5745
5764
  color: output.color,
5746
5765
  json: output.json
5747
5766
  });
@@ -6258,7 +6277,7 @@ function isPrerelease(version) {
6258
6277
  return VERSION_PATTERN.exec(version)?.[4] !== void 0;
6259
6278
  }
6260
6279
  /** The package version, read from package.json at build time. */
6261
- const VERSION = "0.12.2";
6280
+ const VERSION = "0.12.3";
6262
6281
  /**
6263
6282
  * Registering, pruning and listing the MCP servers of every channel, from the core.
6264
6283
  *
@@ -6516,7 +6535,7 @@ function serverPruneChange(core, env, request, own) {
6516
6535
  };
6517
6536
  const { packageName, version } = ownProduct(request.channel, own) ?? {
6518
6537
  packageName: channelServer(request.channel).packageName,
6519
- version: "0.12.2"
6538
+ version: "0.12.3"
6520
6539
  };
6521
6540
  const product = {
6522
6541
  packageName,
@@ -7140,7 +7159,7 @@ async function askLine(streams, question) {
7140
7159
  }
7141
7160
  channelServer("core").packageName;
7142
7161
  //#endregion
7143
- //#region ../core/dist/tool-arguments-BMmJnuY4.mjs
7162
+ //#region ../core/dist/tool-arguments-BMgRZ6DZ.mjs
7144
7163
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
7145
7164
  const TARGET = "draft-2020-12";
7146
7165
  /**
@@ -8561,13 +8580,17 @@ function notFound(config, name) {
8561
8580
  * is not a store path this package would read, is refused rather than acted on: nothing here would know what it
8562
8581
  * means, and the vocabulary is closed (design §5).
8563
8582
  */
8564
- function requireAccount(config, name) {
8583
+ function requireAccount(config, name, platform = process.platform) {
8565
8584
  if (name === void 0 || name === "") throw new CommsError("USAGE", "which WhatsApp account? there is no default", { hint: "Pass the account, as `organisation/whatsapp`." });
8566
8585
  requireNamedConfig(config);
8567
8586
  const { account } = resolveName(config, "account", name, notFound(config, name));
8568
8587
  if (account.platform !== "whatsapp") throw notFound(config, name)();
8569
8588
  const mode = account.mode ?? account.tier;
8570
- if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with \`agent-whatsapp remove ${name}\` and add it again.` });
8589
+ if (mode !== "read") throw new CommsError("CONFIG", `"${name}" is recorded in mode "${String(mode)}", and WhatsApp accounts only read`, { hint: `Nothing was read. Remove it with ${inlineCommand(shellCommand([
8590
+ "agent-whatsapp",
8591
+ "remove",
8592
+ name
8593
+ ], platform))} and add it again.` });
8571
8594
  const source = account.source;
8572
8595
  if (source !== void 0 && (typeof source !== "string" || basename(source) !== "ChatStorage.sqlite")) throw new CommsError("CONFIG", `"${name}" names a store this package does not read`, { hint: `Only a file called ${STORE_FILE} is read. Remove the account and add it again.` });
8573
8596
  return {
@@ -9219,13 +9242,18 @@ var WhatsAppIndex = class WhatsAppIndex {
9219
9242
  this.#db = db;
9220
9243
  }
9221
9244
  /** Every read opens the index through here, and cannot without the account's lists. */
9222
- static async open(directory, accountName, visibility) {
9245
+ static async open(directory, accountName, visibility, platform = process.platform) {
9223
9246
  const path = join(directory, INDEX_FILE);
9224
9247
  try {
9225
9248
  await stat(path);
9226
9249
  } catch {
9227
9250
  throw new CommsError("NOT_FOUND", `"${accountName}" has not been synced yet, so there is nothing to read`, {
9228
- hint: `Run \`agent-whatsapp sync --account ${accountName}\` (or the whatsapp_sync tool) first.`,
9251
+ hint: `Run ${inlineCommand(shellCommand([
9252
+ "agent-whatsapp",
9253
+ "sync",
9254
+ "--account",
9255
+ accountName
9256
+ ], platform))} (or the whatsapp_sync tool) first.`,
9229
9257
  details: { reason: "NOT_SYNCED" }
9230
9258
  });
9231
9259
  }
@@ -9233,7 +9261,12 @@ var WhatsAppIndex = class WhatsAppIndex {
9233
9261
  const db = await openDatabase(path, { readOnly: true });
9234
9262
  if (db.prepare("SELECT value FROM meta WHERE key = 'format'").get()?.value !== String(2)) {
9235
9263
  db.close();
9236
- throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run \`agent-whatsapp sync --account ${accountName}\` to rebuild it.` });
9264
+ throw new CommsError("CONFIG", `the index for "${accountName}" was written by another version of this package`, { hint: `Run ${inlineCommand(shellCommand([
9265
+ "agent-whatsapp",
9266
+ "sync",
9267
+ "--account",
9268
+ accountName
9269
+ ], platform))} to rebuild it.` });
9237
9270
  }
9238
9271
  db.function("agent_sees_chat", { deterministic: true }, (id) => visibility.seesChat(String(id)) ? 1 : 0);
9239
9272
  db.function("agent_kind", { deterministic: true }, (id) => chatKindOf(String(id)));
@@ -9701,6 +9734,7 @@ var WhatsAppContext = class {
9701
9734
  env;
9702
9735
  now;
9703
9736
  surface;
9737
+ platform;
9704
9738
  core;
9705
9739
  lists;
9706
9740
  /** The name a pinned server was started for, as given. */
@@ -9713,6 +9747,7 @@ var WhatsAppContext = class {
9713
9747
  this.env = options.env ?? process.env;
9714
9748
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
9715
9749
  this.surface = options.surface ?? "cli";
9750
+ this.platform = options.platform ?? process.platform;
9716
9751
  this.core = openCore({
9717
9752
  env: this.env,
9718
9753
  now: this.now
@@ -9723,7 +9758,7 @@ var WhatsAppContext = class {
9723
9758
  this.#source = {
9724
9759
  io: options.sourceIo,
9725
9760
  timeoutMs: options.sourceTimeoutMs,
9726
- platform: options.platform
9761
+ platform: this.platform
9727
9762
  };
9728
9763
  }
9729
9764
  /** The spike's accounts moved in, once per process; what happened, when there was a spike file to move. */
@@ -9753,7 +9788,7 @@ var WhatsAppContext = class {
9753
9788
  */
9754
9789
  async checkPin() {
9755
9790
  if (this.pinned === void 0) return;
9756
- this.#pinnedId = requireAccount(await this.config(), this.pinned).account.id;
9791
+ this.#pinnedId = requireAccount(await this.config(), this.pinned, this.platform).account.id;
9757
9792
  }
9758
9793
  /** The name a call acts on: the one it gave, checked against the pin when there is one. */
9759
9794
  async #nameFor(config, named) {
@@ -9769,7 +9804,7 @@ var WhatsAppContext = class {
9769
9804
  /** One account, resolved and checked, with the person's lists for it — which every read applies. */
9770
9805
  async account(named) {
9771
9806
  const config = await this.config();
9772
- const { name, account } = requireAccount(config, await this.#nameFor(config, named));
9807
+ const { name, account } = requireAccount(config, await this.#nameFor(config, named), this.platform);
9773
9808
  return {
9774
9809
  name,
9775
9810
  account,
@@ -9991,7 +10026,7 @@ async function draftMessage(context, request) {
9991
10026
  for (const { name, account, lists } of scope) {
9992
10027
  let index;
9993
10028
  try {
9994
- index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
10029
+ index = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
9995
10030
  } catch (error) {
9996
10031
  if (scope.length > 1 && isCommsError(error) && error.details?.reason === "NOT_SYNCED") continue;
9997
10032
  throw error;
@@ -10176,7 +10211,7 @@ async function openIndex(context, accountName) {
10176
10211
  const { name, account, lists } = await context.account(accountName);
10177
10212
  return {
10178
10213
  name,
10179
- index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists))
10214
+ index: await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform)
10180
10215
  };
10181
10216
  }
10182
10217
  async function listChats(context, request) {
@@ -10616,7 +10651,7 @@ async function statusOf(context, named, check) {
10616
10651
  }
10617
10652
  let index = { synced: false };
10618
10653
  try {
10619
- const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists));
10654
+ const opened = await WhatsAppIndex.open(context.accountDir(account), name, new Visibility(lists), context.platform);
10620
10655
  try {
10621
10656
  const stats = opened.stats();
10622
10657
  index = {
@@ -10747,4 +10782,4 @@ async function syncAccount(context, request) {
10747
10782
  });
10748
10783
  }
10749
10784
  //#endregion
10750
- export { renderPrune as $, CommsError as A, colorEnabled as B, checkStorePath as C, strictToolArguments as D, UNTRUSTED_NOTICE as E, approvalsOf as F, installExitStatus as G, escapeForDisplay as H, approveChangeAtTerminal as I, nameAvailable as J, isCommsError as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNELS as O, approvalKind as P, renderInstall as Q, channelManifest as R, storeIdentity as S, expandSource as T, exemptFromUpdateGate as U, commandPathOf as V, gatedChangeAtTerminal as W, openCore as X, nameShapeProblem as Y, paint as Z, chatRefOf as _, listChats as a, updateGateAtTerminal as at, requireAccount as b, Presenter as c, withFileLock as ct, draftMessage as d, runCommand as et, Visibility as f, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, truncateDisplay as it, EXIT_CODES as j, CHANNEL_SERVERS as k, innerText as l, writeResult as lt, WhatsAppContext as m, whatsappStatus as n, serverPruneChange as nt, readChat as o, updateToolGate as ot, listKey as p, lookupName as q, probeStore as r, toCommsError as rt, searchMessages as s, windowsSystemProgram as st, syncAccount as t, serverInstallChange as tt, composeDraft as u, VERSION$1 as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
10785
+ export { paint as $, CommsError as A, colorEnabled as B, checkStorePath as C, strictToolArguments as D, UNTRUSTED_NOTICE as E, approvalsOf as F, gatedChangeAtTerminal as G, commandText as H, approveChangeAtTerminal as I, isCommsError as J, inlineCommand as K, canPrompt as L, absoluteSearchPath as M, agentMarker as N, CHANNELS as O, approvalKind as P, openCore as Q, channelManifest as R, storeIdentity as S, expandSource as T, escapeForDisplay as U, commandPathOf as V, exemptFromUpdateGate as W, nameAvailable as X, lookupName as Y, nameShapeProblem as Z, chatRefOf as _, listChats as a, shellCommand as at, requireAccount as b, Presenter as c, updateGateAtTerminal as ct, draftMessage as d, withFileLock as dt, renderInstall as et, Visibility as f, writeResult as ft, requireSupportedNode as g, WhatsAppIndex as h, CHAT_KINDS as i, serverPruneChange as it, EXIT_CODES as j, CHANNEL_SERVERS as k, innerText as l, updateToolGate as lt, WhatsAppContext as m, whatsappStatus as n, runCommand as nt, readChat as o, toCommsError as ot, listKey as p, VERSION$1 as pt, installExitStatus as q, probeStore as r, serverInstallChange as rt, searchMessages as s, truncateDisplay as st, syncAccount as t, renderPrune as tt, composeDraft as u, windowsSystemProgram as ut, PLATFORM as v, defaultStorePath as w, requireNamedConfig as x, newWhatsAppAccount as y, childEnvironment as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcomms/whatsapp",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
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.12.2"
41
+ "@agentcomms/core": "0.12.3"
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": "224763228ecc63ce403f0d12988865cfaa981f2a"
90
+ "gitHead": "1945e1e3a0532c20d3da33ad9f84ceeda88c30a2"
91
91
  }