@agentcomms/whatsapp 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$2 = "0.22.0";
14
+ const VERSION$2 = "0.24.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -28,7 +28,7 @@ const CALLER = Object.freeze({
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
30
  //#endregion
31
- //#region ../core/dist/update-check-DQ7AAwLg.mjs
31
+ //#region ../core/dist/update-check-BY5xNUZa.mjs
32
32
  /**
33
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -96,6 +96,16 @@ const ERROR_REGISTRY = {
96
96
  retryable: false,
97
97
  summary: "a behaviour rule does not allow this act here"
98
98
  },
99
+ STANDING_REFUSED: {
100
+ exit: EXIT_CODES.APPROVAL,
101
+ retryable: false,
102
+ summary: "this standing approval does not cover this act"
103
+ },
104
+ STANDING_LIMIT: {
105
+ exit: EXIT_CODES.APPROVAL,
106
+ retryable: false,
107
+ summary: "a standing approval's limit is reached"
108
+ },
99
109
  RATE_CAPPED: {
100
110
  exit: EXIT_CODES.APPROVAL,
101
111
  retryable: true,
@@ -1019,6 +1029,9 @@ function nameProblem(name) {
1019
1029
  if (FORBIDDEN_NAME_CHARACTERS.test(name)) return "the name holds a parenthesis or one of <>\"@,;:\\";
1020
1030
  for (const char of name) if (isDangerous(char.codePointAt(0) ?? 0)) return "the name holds a hidden or control character";
1021
1031
  }
1032
+ /** `<name> (<word>)`: a name holds no parenthesis or line break (`nameProblem`), so the first `(` is the word's. */
1033
+ const LABEL_PATTERN = `[^()\\n]{1,160} \\((?:${AGENT_LABEL_WORDS.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\)`;
1034
+ new RegExp(`^\\s*(?:${LABEL_PATTERN}: |Sent by ${LABEL_PATTERN}(?:$|\\s))`, "iu");
1022
1035
  const ruleKey = (rule) => JSON.stringify([
1023
1036
  rule.account,
1024
1037
  rule.accountId ?? null,
@@ -1428,12 +1441,17 @@ function sameDisclosureBinding(left, right) {
1428
1441
  return false;
1429
1442
  }
1430
1443
  }
1431
- /** The lifetime profile, in milliseconds: pending on each route, approved, and a download's question. */
1444
+ /**
1445
+ * The lifetime profile, in milliseconds: pending on each route, approved, a download's question, and a standing
1446
+ * approval's child — usable once, within two minutes of the use that minted it. A standing record's own approved
1447
+ * lifetime is its days (`approvedMs`, checked against its limits), not one of these.
1448
+ */
1432
1449
  const APPROVAL_LIFETIMES = Object.freeze({
1433
1450
  chat: 6e5,
1434
1451
  confirm: 18e5,
1435
1452
  approved: 864e5,
1436
- download: 18e5
1453
+ download: 18e5,
1454
+ standingChild: 12e4
1437
1455
  });
1438
1456
  /**
1439
1457
  * The sending lease (design 2026-10-05 §D1): a `sending` record whose claimant has not renewed it for this long reads
@@ -1452,8 +1470,9 @@ function unknownAtOf(record) {
1452
1470
  const at = Date.parse(renewed);
1453
1471
  return Number.isFinite(at) ? new Date(at + SENDING_LEASE_MS).toISOString() : void 0;
1454
1472
  }
1455
- /** How long a pending send or change on `route` stays open. */
1473
+ /** How long a pending send or change on `route` stays open; a standing child is never pending, and lasts its own. */
1456
1474
  function pendingMsOf(route) {
1475
+ if (route === "standing") return APPROVAL_LIFETIMES.standingChild;
1457
1476
  return route === "confirm" ? APPROVAL_LIFETIMES.confirm : APPROVAL_LIFETIMES.chat;
1458
1477
  }
1459
1478
  /** Whether `channel` is one this release knows: a manifest channel of the generated snapshot. */
@@ -1464,9 +1483,20 @@ function isKnownChannel(channel) {
1464
1483
  function requireKnownChannel(channel) {
1465
1484
  if (!isKnownChannel(channel)) throw new CommsError("UNEXPECTED", `an approval cannot be made for the channel "${channel}": this release does not know it`, { hint: "This is a bug — please report it." });
1466
1485
  }
1486
+ /** Why `use` is not a child's use of its standing approval, or null: three strings, its content a SHA-256. */
1487
+ function standingUseProblem(use) {
1488
+ if (typeof use !== "object" || use === null || Array.isArray(use)) return "it names no use";
1489
+ const fields = use;
1490
+ if (Object.keys(fields).some((key) => key !== "channel" && key !== "thread" && key !== "content")) return "it names more than its use";
1491
+ for (const field of ["channel", "thread"]) if (typeof fields[field] !== "string" || fields[field] === "") return `its use has no ${field}`;
1492
+ if (typeof fields.content !== "string" || !/^[0-9a-f]{64}$/.test(fields.content)) return "its use names no content";
1493
+ return null;
1494
+ }
1467
1495
  /**
1468
1496
  * The identity a record is bound to, exactly as stored. An absent `inboxSub` is left out rather than kept as
1469
- * `undefined`; a send always carries its epoch.
1497
+ * `undefined`; a send and a standing record always carry their send epoch. A standing record and its children carry
1498
+ * the standing epoch, and a child its standing id — each only when present, so every other record's identity, and its
1499
+ * digest, is exactly what it was before standing approvals existed.
1470
1500
  */
1471
1501
  function identityOf(record) {
1472
1502
  return {
@@ -1483,7 +1513,14 @@ function identityOf(record) {
1483
1513
  bcc: [...record.expect.bcc],
1484
1514
  subject: record.expect.subject
1485
1515
  },
1486
- ...record.kind === "send" ? { sendEpoch: record.sendEpoch } : {}
1516
+ ...record.kind === "send" || record.kind === "standing" ? { sendEpoch: record.sendEpoch } : {},
1517
+ ...record.standingEpoch === void 0 ? {} : { standingEpoch: record.standingEpoch },
1518
+ ...record.standingId === void 0 ? {} : { standingId: record.standingId },
1519
+ ...record.standingUse === void 0 ? {} : { standingUse: {
1520
+ channel: record.standingUse.channel,
1521
+ thread: record.standingUse.thread,
1522
+ content: record.standingUse.content
1523
+ } }
1487
1524
  };
1488
1525
  }
1489
1526
  /** One listed file, as stored: its name and size, and its rename reason and flags only when it has them. */
@@ -1658,8 +1695,12 @@ function programNeedsQuotes(command) {
1658
1695
  const [program] = command.words;
1659
1696
  return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
1660
1697
  }
1698
+ /**
1699
+ * One word for a POSIX shell: as it is when nothing in it is special, otherwise in single quotes. A word that starts
1700
+ * with `=` or `~` is quoted too: zsh reads `=ls` as the path of `ls`, and `~` as a home.
1701
+ */
1661
1702
  function posixShellWord(word) {
1662
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1703
+ return /^(?![=~])[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1663
1704
  }
1664
1705
  /**
1665
1706
  * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
@@ -2986,12 +3027,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
2986
3027
  function collectFromToml(text, client, path) {
2987
3028
  const servers = parseToml(text).mcp_servers;
2988
3029
  if (servers === void 0) return [];
2989
- if (!isRecord$1(servers)) throw new TomlError("mcp_servers is not a table");
3030
+ if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
2990
3031
  const found = [];
2991
3032
  for (const [name, entry] of Object.entries(servers)) {
2992
- if (!isRecord$1(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3033
+ if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
2993
3034
  if (entry.args !== void 0 && !Array.isArray(entry.args)) throw new TomlError(`mcp_servers.${name}.args is not an array`);
2994
- if (entry.env !== void 0 && !isRecord$1(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
3035
+ if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
2995
3036
  found.push({
2996
3037
  client,
2997
3038
  path,
@@ -2999,12 +3040,12 @@ function collectFromToml(text, client, path) {
2999
3040
  command: typeof entry.command === "string" ? entry.command : "",
3000
3041
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3001
3042
  ...typeof entry.url === "string" ? { url: entry.url } : {},
3002
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3043
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3003
3044
  });
3004
3045
  }
3005
3046
  return found;
3006
3047
  }
3007
- function isRecord$1(value) {
3048
+ function isRecord$2(value) {
3008
3049
  return typeof value === "object" && value !== null && !Array.isArray(value);
3009
3050
  }
3010
3051
  /**
@@ -3017,8 +3058,8 @@ function isRecord$1(value) {
3017
3058
  */
3018
3059
  function codexServerFromGet(name, stdout, path) {
3019
3060
  const answer = JSON.parse(stdout);
3020
- if (!isRecord$1(answer)) throw new SyntaxError("not an object");
3021
- const entry = isRecord$1(answer.transport) ? answer.transport : answer;
3061
+ if (!isRecord$2(answer)) throw new SyntaxError("not an object");
3062
+ const entry = isRecord$2(answer.transport) ? answer.transport : answer;
3022
3063
  const command = typeof entry.command === "string" ? entry.command : "";
3023
3064
  const url = typeof entry.url === "string" ? entry.url : void 0;
3024
3065
  if (!command && !url) throw new SyntaxError("neither a command nor a URL");
@@ -3030,7 +3071,7 @@ function codexServerFromGet(name, stdout, path) {
3030
3071
  command,
3031
3072
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3032
3073
  ...url ? { url } : {},
3033
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3074
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3034
3075
  };
3035
3076
  return {
3036
3077
  ...server,
@@ -3136,7 +3177,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
3136
3177
  continue;
3137
3178
  }
3138
3179
  add(collectFromJson(parsed, file.client, file.path));
3139
- if (file.client !== "claude-code" || !isRecord$1(parsed) || !isRecord$1(parsed.projects)) continue;
3180
+ if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
3140
3181
  for (const root of Object.keys(parsed.projects)) {
3141
3182
  if (!isAbsolute(root)) continue;
3142
3183
  const path = join(root, ".mcp.json");
@@ -6141,7 +6182,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
6141
6182
  "file",
6142
6183
  "send"
6143
6184
  ]);
6144
- function isRecord$2(value) {
6185
+ function isRecord$1(value) {
6145
6186
  return typeof value === "object" && value !== null && !Array.isArray(value);
6146
6187
  }
6147
6188
  /**
@@ -6188,7 +6229,7 @@ function accountNamed(written, names) {
6188
6229
  /** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
6189
6230
  function readRule(raw, index, names) {
6190
6231
  const at = `rule ${index + 1}`;
6191
- if (!isRecord$2(raw)) return { unreadable: {
6232
+ if (!isRecord$1(raw)) return { unreadable: {
6192
6233
  account: null,
6193
6234
  reason: `${at} is not an object`
6194
6235
  } };
@@ -6244,7 +6285,7 @@ function readBehaviourSettings(config) {
6244
6285
  rules: [],
6245
6286
  unreadable: []
6246
6287
  };
6247
- if (!isRecord$2(stored)) return unreadableWhole("the behaviour setting is not an object");
6288
+ if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
6248
6289
  const rules = stored.rules;
6249
6290
  if (rules === void 0) return {
6250
6291
  rules: [],
@@ -6551,7 +6592,7 @@ Object.freeze({
6551
6592
  */
6552
6593
  function behaviourRenamed(config, id, from, to) {
6553
6594
  const stored = config.behaviour;
6554
- if (!isRecord$2(stored)) return stored;
6595
+ if (!isRecord$1(stored)) return stored;
6555
6596
  const rules = stored.rules;
6556
6597
  if (!Array.isArray(rules)) return stored;
6557
6598
  const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
@@ -6562,7 +6603,7 @@ function behaviourRenamed(config, id, from, to) {
6562
6603
  };
6563
6604
  return {
6564
6605
  ...stored,
6565
- rules: rules.map((rule) => isRecord$2(rule) && holds(rule) ? {
6606
+ rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
6566
6607
  ...rule,
6567
6608
  account: to
6568
6609
  } : rule)
@@ -6949,6 +6990,7 @@ const configV2Schema = looseObject({
6949
6990
  /** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
6950
6991
  const v3Shape = {
6951
6992
  sendEpochs: record(string().min(1), number().int().min(0)).optional(),
6993
+ standingEpoch: number().int().min(0).optional(),
6952
6994
  legacyDrain: looseObject({
6953
6995
  since: absoluteTimeSchema,
6954
6996
  tracked: record(string().min(1), _enum(["open", ...[
@@ -7192,7 +7234,7 @@ var ConfigStore = class {
7192
7234
  });
7193
7235
  }
7194
7236
  }
7195
- const written = withSendEpochs(current, parsed.data);
7237
+ const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
7196
7238
  const serialized = `${JSON.stringify(written, null, 2)}\n`;
7197
7239
  await writeFileAtomic(this.path, serialized, 384, options.signal);
7198
7240
  committed = written;
@@ -7375,6 +7417,46 @@ function withSendEpochs(before, after) {
7375
7417
  sendEpochs: epochs
7376
7418
  };
7377
7419
  }
7420
+ /**
7421
+ * The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
7422
+ * or when it is not a whole number of 0 or more, which no write of this release leaves.
7423
+ */
7424
+ function standingEpochOf(config) {
7425
+ const epoch = config.standingEpoch;
7426
+ return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
7427
+ }
7428
+ /** The change policy of every inbox and account by id, each its own or the default. */
7429
+ function changePoliciesById(config) {
7430
+ const policies = /* @__PURE__ */ new Map();
7431
+ for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
7432
+ for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
7433
+ return policies;
7434
+ }
7435
+ /**
7436
+ * Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
7437
+ * account there before and after, by id.
7438
+ */
7439
+ function tightensAChangePolicy(before, after) {
7440
+ if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
7441
+ const was = changePoliciesById(before);
7442
+ for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
7443
+ return false;
7444
+ }
7445
+ /**
7446
+ * `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
7447
+ * of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
7448
+ * change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
7449
+ * made on version 3.
7450
+ */
7451
+ function withStandingEpoch(before, after) {
7452
+ if (before.version !== 3 || after.version !== 3) return after;
7453
+ const held = Math.max(standingEpochOf(before), standingEpochOf(after));
7454
+ const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
7455
+ return kept === 0 && after.standingEpoch === void 0 ? after : {
7456
+ ...after,
7457
+ standingEpoch: kept
7458
+ };
7459
+ }
7378
7460
  async function readFileIfExists(path) {
7379
7461
  try {
7380
7462
  return await readFile(path, "utf8");
@@ -7859,23 +7941,53 @@ function ownerEntry(config, ownerId) {
7859
7941
  */
7860
7942
  function liveGateOf(config, record) {
7861
7943
  const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
7944
+ const standingEpoch = standingEpochOf(config);
7862
7945
  if (record.ownerScope !== "owner") return {
7863
7946
  owner: "none",
7864
- changePolicy: governing(defaultChangePolicy(config))
7947
+ changePolicy: governing(defaultChangePolicy(config)),
7948
+ standingEpoch
7865
7949
  };
7866
7950
  const entry = ownerEntry(config, record.inboxId);
7867
- if (entry === void 0) return { owner: "removed" };
7951
+ if (entry === void 0) return {
7952
+ owner: "removed",
7953
+ standingEpoch
7954
+ };
7868
7955
  return {
7869
7956
  owner: "present",
7870
7957
  sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
7871
7958
  changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
7872
- sendEpoch: sendEpochOf(config, record.inboxId)
7959
+ sendEpoch: sendEpochOf(config, record.inboxId),
7960
+ standingEpoch
7873
7961
  };
7874
7962
  }
7875
7963
  /** The reason a record whose owner was removed is revoked with. */
7876
7964
  const OWNER_REMOVED_REASON = "its mailbox or account was removed";
7877
7965
  /** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
7878
7966
  const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
7967
+ /**
7968
+ * The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
7969
+ * since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
7970
+ */
7971
+ const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
7972
+ /**
7973
+ * The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
7974
+ * approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
7975
+ * standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
7976
+ * 2026-10-19-standing-approvals, S8, the lead's call after review).
7977
+ */
7978
+ const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
7979
+ /** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
7980
+ function changePolicyTightened(approved, live) {
7981
+ return live === "confirm" && approved !== "confirm";
7982
+ }
7983
+ /**
7984
+ * Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
7985
+ * Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
7986
+ * action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
7987
+ */
7988
+ function writtenAtALook(outcome) {
7989
+ return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
7990
+ }
7879
7991
  /** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
7880
7992
  const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
7881
7993
  /** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
@@ -7884,7 +7996,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
7884
7996
  const sentAtWords = (usedAt) => `sent at ${usedAt}`;
7885
7997
  /** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
7886
7998
  function refusalOf(kind) {
7887
- const [prefix, idKey, hint] = kind === "disclosure" ? [
7999
+ const [prefix, idKey, hint] = kind === "standing" ? [
8000
+ "nothing was sent",
8001
+ "approvalId",
8002
+ "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
8003
+ ] : kind === "disclosure" ? [
7888
8004
  "nothing was disclosed",
7889
8005
  "approvalId",
7890
8006
  "Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
@@ -7915,7 +8031,7 @@ function refusalOf(kind) {
7915
8031
  * or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
7916
8032
  */
7917
8033
  function approvalNotFound(approvalId, kind) {
7918
- return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
8034
+ return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "standing" ? "nothing was sent" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
7919
8035
  hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
7920
8036
  details: { approval: null }
7921
8037
  });
@@ -8083,12 +8199,20 @@ function classifyV2(read, context) {
8083
8199
  const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
8084
8200
  const active = read.state === "pending" || read.state === "approved";
8085
8201
  const acting = action === "approve" || action === "claim";
8086
- const never = read.kind === "send" && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
8202
+ const sends = read.kind === "send" || read.kind === "standing";
8203
+ const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
8204
+ const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
8087
8205
  let derived = null;
8206
+ let revokedByNever = false;
8088
8207
  if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
8089
8208
  else if (active && never) {
8090
- if (acting) derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
8091
- } else if (active && read.kind === "send" && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
8209
+ if (acting) {
8210
+ derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
8211
+ revokedByNever = true;
8212
+ }
8213
+ } else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
8214
+ else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
8215
+ else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
8092
8216
  const record = derived === null ? read : {
8093
8217
  ...read,
8094
8218
  state: "revoked",
@@ -8096,7 +8220,6 @@ function classifyV2(read, context) {
8096
8220
  revokedAt: at,
8097
8221
  challengeHash: void 0
8098
8222
  };
8099
- const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
8100
8223
  let claimable = false;
8101
8224
  let reason = record.reason;
8102
8225
  if (record.state === "pending" || record.state === "approved") {
@@ -8162,6 +8285,7 @@ function isClaimable(record, live) {
8162
8285
  case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
8163
8286
  case "download": return downloadClaimable(record, live.changePolicy);
8164
8287
  case "disclosure": return record.state === "approved";
8288
+ case "standing": return false;
8165
8289
  }
8166
8290
  }
8167
8291
  /**
@@ -8210,7 +8334,7 @@ function errorOf(record, approval, context) {
8210
8334
  if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
8211
8335
  const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
8212
8336
  return new CommsError("APPROVAL_EXPIRED", record.reason === "clock-anomaly" ? `the clock moved backwards; this approval was expired safely at ${record.expiredAt}; ${nothing}` : record.approvedAt !== void 0 ? `this approval expired; ${nothing}: approved at ${record.approvedAt}, expired unused at ${record.expiredAt}` : `this approval expired; ${nothing}: prepared at ${record.createdAt}, expired at ${record.expiredAt}`, {
8213
- hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : "Prepare the send again and show the new preview to the user.",
8337
+ hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : record.kind === "standing" ? "This standing approval has ended. Ask the person instead; only they can set up another, at their own terminal." : "Prepare the send again and show the new preview to the user.",
8214
8338
  details: {
8215
8339
  approvalId: record.approvalId,
8216
8340
  state: record.state,
@@ -8294,6 +8418,578 @@ var AuditLog = class {
8294
8418
  }
8295
8419
  };
8296
8420
  /**
8421
+ * The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
8422
+ * standard Unicode names, each with its code point, shipped in the code.
8423
+ *
8424
+ * Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
8425
+ * have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
8426
+ * one here is a word the agent may say on the person's behalf without asking.
8427
+ */
8428
+ const STANDARD_EMOJI = Object.freeze({
8429
+ eyes: "U+1F440",
8430
+ white_check_mark: "U+2705",
8431
+ heavy_check_mark: "U+2714",
8432
+ ballot_box_with_check: "U+2611",
8433
+ "+1": "U+1F44D",
8434
+ thumbsup: "U+1F44D",
8435
+ ok: "U+1F197",
8436
+ ok_hand: "U+1F44C",
8437
+ "100": "U+1F4AF",
8438
+ heavy_plus_sign: "U+2795",
8439
+ handshake: "U+1F91D",
8440
+ raised_hands: "U+1F64C",
8441
+ pray: "U+1F64F",
8442
+ clap: "U+1F44F",
8443
+ wave: "U+1F44B",
8444
+ memo: "U+1F4DD",
8445
+ pencil2: "U+270F",
8446
+ hourglass_flowing_sand: "U+23F3",
8447
+ stopwatch: "U+23F1",
8448
+ inbox_tray: "U+1F4E5",
8449
+ pushpin: "U+1F4CC",
8450
+ mag: "U+1F50D",
8451
+ bulb: "U+1F4A1",
8452
+ speech_balloon: "U+1F4AC",
8453
+ thinking_face: "U+1F914",
8454
+ slightly_smiling_face: "U+1F642",
8455
+ heart: "U+2764",
8456
+ tada: "U+1F389",
8457
+ rocket: "U+1F680",
8458
+ sparkles: "U+2728"
8459
+ });
8460
+ /**
8461
+ * The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
8462
+ * can start on a reaction (S3). Allowed, with a warning in the preview.
8463
+ */
8464
+ const READS_AS_APPROVAL = Object.freeze([
8465
+ "white_check_mark",
8466
+ "heavy_check_mark",
8467
+ "ballot_box_with_check",
8468
+ "+1",
8469
+ "thumbsup",
8470
+ "ok",
8471
+ "ok_hand",
8472
+ "100",
8473
+ "heavy_plus_sign",
8474
+ "handshake"
8475
+ ]);
8476
+ /** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
8477
+ function isStandardEmoji(name) {
8478
+ return Object.hasOwn(STANDARD_EMOJI, name);
8479
+ }
8480
+ Object.freeze(["reaction", "reply"]);
8481
+ /** S3: what a standing approval holds when no content is given. */
8482
+ const STANDING_DEFAULT_REACTION = "eyes";
8483
+ /** One day, the unit a standing approval's expiry is counted in. */
8484
+ const STANDING_DAY_MS = 864e5;
8485
+ /** S6's table: each limit's default and the range a person may set it to. */
8486
+ const STANDING_LIMITS = Object.freeze({
8487
+ perThread: Object.freeze({
8488
+ default: 1,
8489
+ min: 1,
8490
+ max: 3
8491
+ }),
8492
+ perHour: Object.freeze({
8493
+ default: 10,
8494
+ min: 1,
8495
+ max: 30
8496
+ }),
8497
+ perDay: Object.freeze({
8498
+ default: 30,
8499
+ min: 1,
8500
+ max: 100
8501
+ }),
8502
+ perSender: Object.freeze({
8503
+ default: 3,
8504
+ min: 1,
8505
+ max: 10
8506
+ }),
8507
+ expiresDays: Object.freeze({
8508
+ default: 7,
8509
+ min: 1,
8510
+ max: 30
8511
+ })
8512
+ });
8513
+ const LIMIT_NAMES$1 = [
8514
+ "perThread",
8515
+ "perHour",
8516
+ "perDay",
8517
+ "perSender",
8518
+ "expiresDays"
8519
+ ];
8520
+ const CONTENT_WORDS = Object.freeze({
8521
+ "too-many-reactions": `a standing approval holds at most 5 reactions`,
8522
+ "too-many-replies": `a standing approval holds at most 5 replies`,
8523
+ "custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
8524
+ empty: "a standing reply has no words",
8525
+ "too-long": `a standing reply is at most 200 characters`,
8526
+ "multi-line": "a standing reply is one line",
8527
+ "control-character": "a standing reply holds a control or invisible character",
8528
+ mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
8529
+ link: "a standing reply holds no link, address or domain",
8530
+ placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
8531
+ markup: "a standing reply is plain text: no formatting characters",
8532
+ "emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
8533
+ pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
8534
+ duplicate: "a standing approval holds each content once"
8535
+ });
8536
+ /** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
8537
+ function contentRefused(reason, where) {
8538
+ return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
8539
+ hint: reason === "custom-emoji" ? `Choose from the standard emoji: ${Object.keys(STANDARD_EMOJI).join(", ")}.` : "Write each reply as plain words on one line, at most 200 characters: no links, mentions, markup, placeholders, emoji or emoji codes. A reply is sent exactly as written.",
8540
+ details: {
8541
+ reason,
8542
+ ...where === void 0 ? {} : {
8543
+ kind: where.kind,
8544
+ position: where.position
8545
+ }
8546
+ }
8547
+ });
8548
+ }
8549
+ /** The digest of one content: its kind and exact text. */
8550
+ function standingContentDigest(kind, text) {
8551
+ return sha256Hex(canonicalJson$1({
8552
+ kind,
8553
+ text
8554
+ }));
8555
+ }
8556
+ /** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
8557
+ function reactionName(written, position) {
8558
+ const trimmed = written.trim();
8559
+ const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
8560
+ if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
8561
+ kind: "reaction",
8562
+ position
8563
+ });
8564
+ return name;
8565
+ }
8566
+ /** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
8567
+ const REPLY_RULES = Object.freeze([
8568
+ ["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
8569
+ ["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
8570
+ ["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
8571
+ ["mention", (text) => /@|<[@!#]/.test(text)],
8572
+ ["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
8573
+ ["placeholder", (text) => /[{}[\]]/.test(text)],
8574
+ ["markup", (text) => /[<>*_~`|]/.test(text)],
8575
+ ["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
8576
+ ]);
8577
+ /**
8578
+ * Words that read as a yes in a reply (the lead's call, after review): "OK" or "Done" on "can I deploy?" is taken for
8579
+ * approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
8580
+ */
8581
+ const APPROVAL_WORDS = Object.freeze([
8582
+ ["yes", /\byes\b/i],
8583
+ ["approved", /\bapproved\b/i],
8584
+ ["approve", /\bapprove\b/i],
8585
+ ["go ahead", /\bgo\s+ahead\b/i],
8586
+ ["lgtm", /\blgtm\b/i],
8587
+ ["ok", /\bok\b/i],
8588
+ ["done", /\bdone\b/i],
8589
+ ["confirmed", /\bconfirmed\b/i]
8590
+ ]);
8591
+ /** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
8592
+ function replyWarningPrefix(text) {
8593
+ return `The reply "${text}" can read as approval:`;
8594
+ }
8595
+ /** S3's warning for a reply holding words that read as a yes, or null. */
8596
+ function replyReadsAsApproval(text) {
8597
+ const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
8598
+ if (said.length === 0) return null;
8599
+ return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
8600
+ }
8601
+ /** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
8602
+ function replyText(written, position) {
8603
+ const where = {
8604
+ kind: "reply",
8605
+ position
8606
+ };
8607
+ const text = written.trim();
8608
+ if (text === "") throw contentRefused("empty", where);
8609
+ for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
8610
+ if ([...text].length > 200) throw contentRefused("too-long", where);
8611
+ return text;
8612
+ }
8613
+ /** S3's warning for an emoji that reads as approval, in the spec's words. */
8614
+ function readsAsApprovalWarning(name) {
8615
+ return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
8616
+ }
8617
+ /**
8618
+ * The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
8619
+ * the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
8620
+ * for anything S3 does not allow.
8621
+ */
8622
+ function checkedContents(input) {
8623
+ const reactions = input.reactions ?? [];
8624
+ const replies = input.replies ?? [];
8625
+ if (reactions.length > 5) throw contentRefused("too-many-reactions");
8626
+ if (replies.length > 5) throw contentRefused("too-many-replies");
8627
+ const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
8628
+ const texts = replies.map((written, index) => replyText(written, index + 1));
8629
+ const contents = [...names.map((text) => ({
8630
+ kind: "reaction",
8631
+ text,
8632
+ digest: standingContentDigest("reaction", text)
8633
+ })), ...texts.map((text) => ({
8634
+ kind: "reply",
8635
+ text,
8636
+ digest: standingContentDigest("reply", text)
8637
+ }))];
8638
+ if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
8639
+ return {
8640
+ contents,
8641
+ warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
8642
+ };
8643
+ }
8644
+ /** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
8645
+ function checkedLimits(input) {
8646
+ const limits = {};
8647
+ for (const name of LIMIT_NAMES$1) {
8648
+ const { default: fallback, min, max } = STANDING_LIMITS[name];
8649
+ const given = input[name];
8650
+ const value = given ?? fallback;
8651
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new CommsError("BAD_DATA", `nothing was prepared: ${name} is a whole number from ${min} to ${max}`, {
8652
+ hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
8653
+ details: {
8654
+ reason: "limit-out-of-range",
8655
+ limit: name,
8656
+ min,
8657
+ max,
8658
+ given: given ?? null
8659
+ }
8660
+ });
8661
+ limits[name] = value;
8662
+ }
8663
+ return limits;
8664
+ }
8665
+ /** The canonical form of a place, as the digest binds it. */
8666
+ function canonicalPlaces(places) {
8667
+ if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
8668
+ return {
8669
+ kind: "exact",
8670
+ places: places.places.map((place) => ({
8671
+ id: place.id,
8672
+ name: place.name,
8673
+ members: place.members,
8674
+ shared: {
8675
+ extShared: place.shared.extShared,
8676
+ pendingExtShared: place.shared.pendingExtShared,
8677
+ externalMembers: place.shared.externalMembers
8678
+ }
8679
+ }))
8680
+ };
8681
+ }
8682
+ /**
8683
+ * The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
8684
+ * with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
8685
+ * the person was shown.
8686
+ */
8687
+ function standingDigest(binding) {
8688
+ return sha256Hex(canonicalJson$1({
8689
+ kind: "standing",
8690
+ target: {
8691
+ kind: binding.target.kind,
8692
+ name: binding.target.name,
8693
+ id: binding.target.id
8694
+ },
8695
+ contents: binding.contents.map((content) => ({
8696
+ kind: content.kind,
8697
+ text: content.text,
8698
+ digest: content.digest
8699
+ })),
8700
+ places: canonicalPlaces(binding.places),
8701
+ limits: {
8702
+ perThread: binding.limits.perThread,
8703
+ perHour: binding.limits.perHour,
8704
+ perDay: binding.limits.perDay,
8705
+ perSender: binding.limits.perSender,
8706
+ expiresDays: binding.limits.expiresDays
8707
+ },
8708
+ proposedBy: binding.proposedBy,
8709
+ replaces: binding.replaces ?? null,
8710
+ warnings: [...binding.warnings]
8711
+ }));
8712
+ }
8713
+ /** A narrowing that is not one: USAGE, saying what to do instead. */
8714
+ function notANarrowing(message) {
8715
+ return new CommsError("USAGE", `nothing was narrowed: ${message}`, { hint: "A narrowing keeps some of what the standing approval holds. To end it altogether, revoke it." });
8716
+ }
8717
+ /**
8718
+ * The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
8719
+ * places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
8720
+ * or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
8721
+ */
8722
+ function effectiveStanding(binding, narrowed) {
8723
+ if (narrowed === void 0) return binding;
8724
+ let contents = binding.contents;
8725
+ if (narrowed.keep !== void 0) {
8726
+ const keep = new Set(narrowed.keep);
8727
+ for (const digest of keep) if (!binding.contents.some((content) => content.digest === digest)) throw notANarrowing("it keeps a content this standing approval does not hold");
8728
+ contents = binding.contents.filter((content) => keep.has(content.digest));
8729
+ if (contents.length === 0) throw notANarrowing("it would keep no content at all");
8730
+ }
8731
+ let places = binding.places;
8732
+ if (narrowed.places !== void 0) {
8733
+ if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
8734
+ const keep = new Set(narrowed.places);
8735
+ const held = binding.places.places;
8736
+ for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
8737
+ const kept = held.filter((place) => keep.has(place.id));
8738
+ if (kept.length === 0) throw notANarrowing("it would keep no place at all");
8739
+ places = {
8740
+ kind: "exact",
8741
+ places: kept
8742
+ };
8743
+ }
8744
+ const limits = { ...binding.limits };
8745
+ for (const name of LIMIT_NAMES$1) {
8746
+ const lower = narrowed.limits?.[name];
8747
+ if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
8748
+ }
8749
+ return {
8750
+ ...binding,
8751
+ contents,
8752
+ places,
8753
+ limits
8754
+ };
8755
+ }
8756
+ /**
8757
+ * The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
8758
+ * store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
8759
+ * only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
8760
+ * or a place not held — and BAD_DATA for a limit outside the table.
8761
+ */
8762
+ function narrowedFurther(binding, narrowed, narrowing) {
8763
+ const before = effectiveStanding(binding, narrowed);
8764
+ const kept = effectiveStanding(before, {
8765
+ ...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
8766
+ ...narrowing.places === void 0 ? {} : { places: narrowing.places }
8767
+ });
8768
+ const asked = { ...kept.limits };
8769
+ for (const name of LIMIT_NAMES$1) {
8770
+ const value = narrowing.limits?.[name];
8771
+ if (value !== void 0) asked[name] = value;
8772
+ }
8773
+ const after = {
8774
+ ...kept,
8775
+ limits: checkedLimits(asked)
8776
+ };
8777
+ if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
8778
+ hint: "A narrowing only lowers a limit, ends it sooner, or keeps fewer contents or places. Anything more is a new standing approval, which the person approves at their terminal with its code.",
8779
+ details: { reason: "widens" }
8780
+ });
8781
+ return {
8782
+ keep: after.contents.map((content) => content.digest),
8783
+ ...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
8784
+ limits: { ...after.limits }
8785
+ };
8786
+ }
8787
+ /** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
8788
+ function standingDays(binding, narrowed) {
8789
+ const lower = narrowed?.limits?.expiresDays;
8790
+ return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
8791
+ }
8792
+ /** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
8793
+ const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
8794
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8795
+ const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
8796
+ /** Why `shared` is not a recorded sharing state, or null. */
8797
+ function sharedProblem(shared) {
8798
+ if (!isRecord$3(shared)) return "a place has no sharing state";
8799
+ if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
8800
+ if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
8801
+ return null;
8802
+ }
8803
+ /** Why `places` is not a standing approval's places, or null. */
8804
+ function placesProblem(places) {
8805
+ if (!isRecord$3(places)) return "it has no places";
8806
+ if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
8807
+ if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
8808
+ if (places.places.length === 0) return "it names no place";
8809
+ const seen = /* @__PURE__ */ new Set();
8810
+ for (const place of places.places) {
8811
+ if (!isRecord$3(place) || typeof place.id !== "string" || !STANDING_PLACE_PATTERN.test(place.id)) return "a place is not a Slack channel or DM id";
8812
+ if (seen.has(place.id)) return "a place is named twice";
8813
+ seen.add(place.id);
8814
+ if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
8815
+ if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
8816
+ const shared = sharedProblem(place.shared);
8817
+ if (shared !== null) return shared;
8818
+ }
8819
+ return null;
8820
+ }
8821
+ /**
8822
+ * Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
8823
+ * texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
8824
+ * of one kind, each a Slack id once, with its member count and sharing; an account by its id; and who proposed it. The store refuses one before writing it, and
8825
+ * the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
8826
+ * reply with a link — can become a standing approval.
8827
+ */
8828
+ function standingBindingProblem(binding) {
8829
+ if (!isRecord$3(binding)) return "it is not a standing approval";
8830
+ if (typeof binding.summary !== "string") return "it has no summary";
8831
+ const target = binding.target;
8832
+ if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
8833
+ if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
8834
+ if (!Array.isArray(binding.contents)) return "it has no contents";
8835
+ const written = binding.contents;
8836
+ if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
8837
+ const contents = written;
8838
+ let checked;
8839
+ let warnings;
8840
+ try {
8841
+ ({contents: checked, warnings} = checkedContents({
8842
+ reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
8843
+ replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
8844
+ }));
8845
+ } catch {
8846
+ return "a content is not one a standing approval may hold";
8847
+ }
8848
+ if (contents.length === 0) return "it holds no content";
8849
+ const canonical = (list) => canonicalJson$1(list.map((content) => ({
8850
+ kind: content.kind,
8851
+ text: content.text,
8852
+ digest: content.digest
8853
+ })));
8854
+ if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
8855
+ const places = placesProblem(binding.places);
8856
+ if (places !== null) return places;
8857
+ if (!isRecord$3(binding.limits)) return "it has no limits";
8858
+ try {
8859
+ if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
8860
+ } catch {
8861
+ return "a limit is outside the table";
8862
+ }
8863
+ if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
8864
+ if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
8865
+ if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
8866
+ if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
8867
+ return null;
8868
+ }
8869
+ /** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
8870
+ function canonicalStandingBinding(binding) {
8871
+ const problem = standingBindingProblem(binding);
8872
+ if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
8873
+ hint: "This is a bug — please report it.",
8874
+ details: { reason: "binding-invalid" }
8875
+ });
8876
+ return {
8877
+ summary: binding.summary,
8878
+ target: {
8879
+ kind: "account",
8880
+ name: binding.target.name,
8881
+ id: binding.target.id
8882
+ },
8883
+ contents: binding.contents.map((content) => ({
8884
+ kind: content.kind,
8885
+ text: content.text,
8886
+ digest: content.digest
8887
+ })),
8888
+ places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
8889
+ kind: "exact",
8890
+ places: binding.places.places.map((place) => ({
8891
+ id: place.id,
8892
+ name: place.name,
8893
+ members: place.members,
8894
+ shared: {
8895
+ extShared: place.shared.extShared,
8896
+ pendingExtShared: place.shared.pendingExtShared,
8897
+ externalMembers: place.shared.externalMembers
8898
+ }
8899
+ }))
8900
+ },
8901
+ limits: { ...binding.limits },
8902
+ proposedBy: binding.proposedBy,
8903
+ ...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
8904
+ warnings: [...binding.warnings]
8905
+ };
8906
+ }
8907
+ /**
8908
+ * Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
8909
+ * content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
8910
+ * record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
8911
+ */
8912
+ function standingNarrowingProblem(binding, narrowed) {
8913
+ if (narrowed === void 0) return null;
8914
+ if (!isRecord$3(narrowed)) return "it is not a narrowing";
8915
+ for (const key of Object.keys(narrowed)) if (![
8916
+ "keep",
8917
+ "places",
8918
+ "limits"
8919
+ ].includes(key)) return "it is not a narrowing";
8920
+ if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
8921
+ if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
8922
+ if (narrowed.limits !== void 0) {
8923
+ if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
8924
+ for (const [name, value] of Object.entries(narrowed.limits)) {
8925
+ if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
8926
+ const limit = name;
8927
+ if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
8928
+ if (value > binding.limits[limit]) return "it raises a limit";
8929
+ }
8930
+ }
8931
+ try {
8932
+ effectiveStanding(binding, narrowed);
8933
+ } catch {
8934
+ return "it keeps what the standing approval does not hold";
8935
+ }
8936
+ return null;
8937
+ }
8938
+ /** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
8939
+ function placeCovered(before, after) {
8940
+ const held = before.find((place) => place.id === after.id);
8941
+ if (held === void 0) return false;
8942
+ if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
8943
+ return after.members < 50 || held.members >= 50;
8944
+ }
8945
+ /**
8946
+ * What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
8947
+ * shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
8948
+ * `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
8949
+ * what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
8950
+ * yes they did not give.
8951
+ */
8952
+ function classifyStandingChange(before, after) {
8953
+ if (before.target.id !== after.target.id) return "widens";
8954
+ const held = new Set(before.contents.map((content) => content.digest));
8955
+ if (after.contents.some((content) => !held.has(content.digest))) return "widens";
8956
+ if (before.places.kind !== after.places.kind) return "widens";
8957
+ if (before.places.kind === "exact" && after.places.kind === "exact") {
8958
+ const approved = before.places.places;
8959
+ if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
8960
+ }
8961
+ for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
8962
+ const kept = new Set(after.contents.map((content) => content.digest));
8963
+ const fewerContents = before.contents.some((content) => !kept.has(content.digest));
8964
+ const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
8965
+ const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
8966
+ const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
8967
+ return fewerContents || fewerPlaces || lower ? "narrows" : "same";
8968
+ }
8969
+ Object.freeze({
8970
+ "not-fresh": "the message is older than 15 minutes, or than this standing approval",
8971
+ "not-newest": "the message is not the newest from someone else in its thread or channel",
8972
+ "already-acknowledged": "this message or thread was already acknowledged under a standing approval",
8973
+ "not-human": "the message, or its thread's first message, is not from a person",
8974
+ interactive: "the message has buttons or menus, or came from a workflow",
8975
+ "place-not-covered": "this standing approval does not cover this place",
8976
+ "big-room": "this place has 50 or more members, and was not approved with that count",
8977
+ "count-unreadable": "how many people are in this place could not be read",
8978
+ external: "someone from another organisation is in this place",
8979
+ "shared-state-changed": "who shares this place changed since it was approved",
8980
+ "reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
8981
+ "app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
8982
+ "content-not-held": "this standing approval holds no such content",
8983
+ "clock-behind": "the clock is earlier than this account's last standing act",
8984
+ unreadable: "the message or its thread could not be read"
8985
+ });
8986
+ Object.freeze({
8987
+ perThread: "per thread",
8988
+ perHour: "an hour",
8989
+ perDay: "a day",
8990
+ perSender: "a day on messages from one person"
8991
+ });
8992
+ /**
8297
8993
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
8298
8994
  * names it showed them under.
8299
8995
  *
@@ -8440,6 +9136,19 @@ function refuseDisclosure(code, reason, record, hint) {
8440
9136
  } : {}
8441
9137
  });
8442
9138
  }
9139
+ /**
9140
+ * The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
9141
+ * never names a command that makes or widens one — only the person does that, at their terminal.
9142
+ */
9143
+ function refuseStanding(code, reason, record, hint) {
9144
+ return new CommsError(code, `nothing was sent: ${reason}`, {
9145
+ hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
9146
+ details: record ? {
9147
+ approvalId: record.approvalId,
9148
+ state: record.state
9149
+ } : {}
9150
+ });
9151
+ }
8443
9152
  /** The refusal in the words of the record's own kind. */
8444
9153
  function refusalFor(record) {
8445
9154
  switch (approvalKind(record)) {
@@ -8447,6 +9156,7 @@ function refusalFor(record) {
8447
9156
  case "change": return refuseChange;
8448
9157
  case "download": return refuseDownload;
8449
9158
  case "disclosure": return refuseDisclosure;
9159
+ case "standing": return refuseStanding;
8450
9160
  }
8451
9161
  }
8452
9162
  /**
@@ -8497,6 +9207,14 @@ function cancelledClaim(record) {
8497
9207
  reason: "cancelled"
8498
9208
  }
8499
9209
  });
9210
+ case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
9211
+ hint: "The standing approval was not used: it is as it was.",
9212
+ details: {
9213
+ approvalId: record.approvalId,
9214
+ state: record.state,
9215
+ reason: "cancelled"
9216
+ }
9217
+ });
8500
9218
  }
8501
9219
  }
8502
9220
  /** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
@@ -8616,7 +9334,11 @@ var ApprovalStore = class {
8616
9334
  reason: CLOCK_ANOMALY,
8617
9335
  expiredAt: observed.toISOString()
8618
9336
  };
8619
- const deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
9337
+ let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
9338
+ if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
9339
+ const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
9340
+ if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
9341
+ }
8620
9342
  if (now >= Date.parse(deadline)) return {
8621
9343
  ...record,
8622
9344
  state: "expired",
@@ -8784,6 +9506,7 @@ var ApprovalStore = class {
8784
9506
  const objectOf = (record) => approvalObjectOf(record, live, this.#now());
8785
9507
  let next;
8786
9508
  try {
9509
+ await options.before?.(current, outcome);
8787
9510
  next = decide(current, outcome, live);
8788
9511
  } catch (error) {
8789
9512
  throw withApproval(error, objectOf(current));
@@ -8824,7 +9547,7 @@ var ApprovalStore = class {
8824
9547
  usedSaid: options.usedSaid
8825
9548
  });
8826
9549
  if (found.form === "v2" && derived.form === "v2") {
8827
- const current = outcome.revokes && action !== "inspect" && action !== "wait" && outcome.record !== null && outcome.record !== null ? outcome.record : derived.record;
9550
+ const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
8828
9551
  if (current.state !== found.record.state) await this.#write({
8829
9552
  ...current,
8830
9553
  updatedAt: this.#now().toISOString()
@@ -8908,6 +9631,10 @@ var ApprovalStore = class {
8908
9631
  hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
8909
9632
  details: detail
8910
9633
  });
9634
+ case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
9635
+ hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
9636
+ details: detail
9637
+ });
8911
9638
  }
8912
9639
  }
8913
9640
  /** Issues a new challenge to show a human; only its hash is kept. */
@@ -9068,7 +9795,7 @@ var ApprovalStore = class {
9068
9795
  async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
9069
9796
  if (kind === "disclosure") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: a standing disclosure approval is only accepted on the trusted terminal or app route");
9070
9797
  let failure = null;
9071
- const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
9798
+ const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
9072
9799
  if (outcome.error) throw outcome.error;
9073
9800
  if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
9074
9801
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
@@ -9085,10 +9812,22 @@ var ApprovalStore = class {
9085
9812
  revokedAt: at.toISOString()
9086
9813
  };
9087
9814
  }
9815
+ if (kind === "standing" && via !== "terminal") {
9816
+ failure = {
9817
+ code: "APPROVAL_VOID",
9818
+ reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
9819
+ };
9820
+ return {
9821
+ ...current,
9822
+ state: "revoked",
9823
+ reason: failure.reason,
9824
+ revokedAt: at.toISOString()
9825
+ };
9826
+ }
9088
9827
  if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
9089
9828
  failure = {
9090
9829
  code: "APPROVAL_VOID",
9091
- reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : "the draft changed after the preview was prepared"
9830
+ reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : kind === "standing" ? "the standing approval shown is not the one that was prepared" : "the draft changed after the preview was prepared"
9092
9831
  };
9093
9832
  return {
9094
9833
  ...current,
@@ -9128,7 +9867,8 @@ var ApprovalStore = class {
9128
9867
  approvedAt: at.toISOString(),
9129
9868
  usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
9130
9869
  approvedBindingDigest: current.bindingDigest,
9131
- challengeHash: void 0
9870
+ challengeHash: void 0,
9871
+ ...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
9132
9872
  };
9133
9873
  }, { usedSaid: options.usedSaid });
9134
9874
  const failed = failure;
@@ -9147,6 +9887,7 @@ var ApprovalStore = class {
9147
9887
  */
9148
9888
  async claimForSend(approvalId, live, options = {}) {
9149
9889
  let failure = null;
9890
+ let parentProblem = null;
9150
9891
  const done = await this.#transition(approvalId, {
9151
9892
  kind: "send",
9152
9893
  owner: live.inboxId
@@ -9167,6 +9908,7 @@ var ApprovalStore = class {
9167
9908
  challengeHash: void 0
9168
9909
  };
9169
9910
  };
9911
+ if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
9170
9912
  if (current.inboxSub && current.inboxSub !== live.inboxSub) return voidWith("APPROVAL_VOID", live.inboxSub ? "the inbox is now connected to a different account" : "the account this was prepared for could not be confirmed");
9171
9913
  if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
9172
9914
  if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
@@ -9178,7 +9920,12 @@ var ApprovalStore = class {
9178
9920
  state: "sending",
9179
9921
  sendingAt: at
9180
9922
  };
9181
- }, { usedSaid: options.usedSaid });
9923
+ }, {
9924
+ usedSaid: options.usedSaid,
9925
+ before: async (current, outcome) => {
9926
+ if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
9927
+ }
9928
+ });
9182
9929
  const failed = failure;
9183
9930
  if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
9184
9931
  const claimToken = randomBytes(16).toString("hex");
@@ -9191,6 +9938,31 @@ var ApprovalStore = class {
9191
9938
  };
9192
9939
  }
9193
9940
  /**
9941
+ * Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
9942
+ * review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
9943
+ * or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
9944
+ * place. Called with the child's lock held: no lock is ever taken the other way round.
9945
+ */
9946
+ async #standingParentProblem(child) {
9947
+ const use = child.standingUse;
9948
+ if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
9949
+ let seen;
9950
+ try {
9951
+ seen = await this.inspect(child.standingId, {
9952
+ kind: "standing",
9953
+ owner: child.inboxId
9954
+ }, { action: "claim" });
9955
+ } catch {
9956
+ return "its standing approval could not be read";
9957
+ }
9958
+ const parent = seen.outcome.record;
9959
+ if (seen.outcome.error !== void 0 || parent === null || parent.state !== "approved" || parent.standing === void 0) return `its standing approval has ended${seen.outcome.reason === void 0 ? "" : ` (${seen.outcome.reason})`}`;
9960
+ const inForce = effectiveStanding(parent.standing, parent.narrowed);
9961
+ if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
9962
+ if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
9963
+ return null;
9964
+ }
9965
+ /**
9194
9966
  * The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
9195
9967
  * claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
9196
9968
  */
@@ -9352,6 +10124,208 @@ var ApprovalStore = class {
9352
10124
  return done.record;
9353
10125
  }
9354
10126
  /**
10127
+ * A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
10128
+ * computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
10129
+ * read when it is made.
10130
+ *
10131
+ * Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
10132
+ * happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
10133
+ * never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
10134
+ * (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
10135
+ *
10136
+ * The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
10137
+ * link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
10138
+ */
10139
+ async createStanding(input) {
10140
+ const standing = canonicalStandingBinding(input.binding);
10141
+ if (typeof input.inboxSub !== "string" || input.inboxSub === "") throw new CommsError("BAD_DATA", "nothing was prepared: a standing approval is made for a known account user", { hint: "This is a bug — please report it." });
10142
+ requireEpochs(input.sendEpoch, input.standingEpoch);
10143
+ requireKnownChannel("slack");
10144
+ await this.ensurePruned();
10145
+ const now = this.#now();
10146
+ const digest = standingDigest(standing);
10147
+ const pendingMs = APPROVAL_LIFETIMES.confirm;
10148
+ const record = this.#bound({
10149
+ approvalId: newApprovalId(),
10150
+ kind: "standing",
10151
+ digestVersion: 2,
10152
+ channel: "slack",
10153
+ ownerScope: "owner",
10154
+ route: "confirm",
10155
+ pendingMs,
10156
+ approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
10157
+ inboxId: standing.target.id,
10158
+ inboxSub: input.inboxSub,
10159
+ draftId: "standing",
10160
+ draftMessageId: digest,
10161
+ contentDigest: digest,
10162
+ sendEpoch: input.sendEpoch,
10163
+ standingEpoch: input.standingEpoch,
10164
+ policy: "confirm",
10165
+ requiredPolicy: "confirm",
10166
+ riskFlags: [],
10167
+ expect: {
10168
+ to: [],
10169
+ cc: [],
10170
+ bcc: [],
10171
+ subject: standing.summary
10172
+ },
10173
+ challengeAttempts: 0,
10174
+ state: "pending",
10175
+ createdAt: now.toISOString(),
10176
+ expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
10177
+ updatedAt: now.toISOString(),
10178
+ standing
10179
+ });
10180
+ await this.#write(record);
10181
+ return record;
10182
+ }
10183
+ /**
10184
+ * Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
10185
+ * use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
10186
+ * a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
10187
+ * refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
10188
+ *
10189
+ * What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
10190
+ * take this record's lock again: it is held.
10191
+ */
10192
+ async withStanding(standingId, owner, use) {
10193
+ const path = this.#path(standingId);
10194
+ const expect = {
10195
+ kind: "standing",
10196
+ owner
10197
+ };
10198
+ return withFileLock(`${path}.lock`, async () => {
10199
+ const config = await this.#config();
10200
+ const found = await this.#read(standingId, config);
10201
+ if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
10202
+ const derived = this.#derived(found);
10203
+ const outcome = approvalOutcome(derived, {
10204
+ action: "claim",
10205
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
10206
+ now: this.#now()
10207
+ });
10208
+ if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
10209
+ const current = outcome.revokes ? outcome.record : derived.record;
10210
+ if (current.state !== found.record.state) await this.#write({
10211
+ ...current,
10212
+ updatedAt: this.#now().toISOString()
10213
+ });
10214
+ return use(current, outcome);
10215
+ });
10216
+ }
10217
+ /**
10218
+ * One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
10219
+ * `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
10220
+ * epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
10221
+ * takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
10222
+ *
10223
+ * Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
10224
+ * record's, after every check there. Checked again here, read without that lock — which the caller holds: the
10225
+ * standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
10226
+ * and not ended, or nothing is written. A child never names anything but what its use was checked for.
10227
+ */
10228
+ async createStandingChild(input) {
10229
+ if (!/^[0-9a-f]{64}$/.test(input.contentDigest)) throw new CommsError("UNEXPECTED", "an approval is bound to a SHA-256 of what it permits, and this is not one", { hint: "This is a bug — please report it." });
10230
+ if (standingUseProblem(input.use) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child names where it acts and what it sends, or nothing", { hint: "This is a bug — please report it." });
10231
+ requireEpochs(input.sendEpoch, input.standingEpoch);
10232
+ requireKnownChannel("slack");
10233
+ const config = await this.#config();
10234
+ if (input.approvalId !== void 0) {
10235
+ if (!APPROVAL_ID_PATTERN.test(input.approvalId) || await this.#read(input.approvalId, config) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child is made under a fresh id, and this is not one", { hint: "This is a bug — please report it." });
10236
+ }
10237
+ const found = await this.#read(input.standingId, config);
10238
+ if (found === null || !matchesExpectation(found, {
10239
+ kind: "standing",
10240
+ owner: input.inboxId
10241
+ })) throw approvalNotFound(input.standingId, "standing");
10242
+ if (found.form !== "v2") throw integrityRefusal(found);
10243
+ const parent = this.#derive(found.record);
10244
+ const live = config === null ? null : liveGateOf(config, parent);
10245
+ const outcome = approvalOutcome({
10246
+ form: "v2",
10247
+ record: parent
10248
+ }, {
10249
+ action: "claim",
10250
+ live,
10251
+ now: this.#now()
10252
+ });
10253
+ if (outcome.error) throw outcome.error;
10254
+ const standing = outcome.record ?? parent;
10255
+ if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
10256
+ const unbound = standing.state !== "approved" ? "the standing approval is not in force" : standing.approvedVia !== "terminal" || standing.approvedBindingDigest !== standing.bindingDigest ? "the standing approval was not approved at a terminal" : standing.standingEpoch !== input.standingEpoch || standing.sendEpoch !== input.sendEpoch ? "the use is not bound to the epochs its standing approval was made under" : standing.inboxSub !== input.inboxSub ? "the use is not for the account user its standing approval was made for" : null;
10257
+ if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
10258
+ const now = this.#now();
10259
+ const at = now.toISOString();
10260
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
10261
+ const until = new Date(now.getTime() + lifetime).toISOString();
10262
+ const sealed = this.#bound({
10263
+ approvalId: input.approvalId ?? newApprovalId(),
10264
+ kind: "send",
10265
+ digestVersion: 2,
10266
+ channel: "slack",
10267
+ ownerScope: "owner",
10268
+ route: "standing",
10269
+ pendingMs: lifetime,
10270
+ approvedMs: lifetime,
10271
+ inboxId: input.inboxId,
10272
+ inboxSub: input.inboxSub,
10273
+ draftId: input.draftId,
10274
+ draftMessageId: input.draftMessageId,
10275
+ contentDigest: input.contentDigest,
10276
+ sendEpoch: input.sendEpoch,
10277
+ standingEpoch: input.standingEpoch,
10278
+ standingId: input.standingId,
10279
+ standingUse: {
10280
+ channel: input.use.channel,
10281
+ thread: input.use.thread,
10282
+ content: input.use.content
10283
+ },
10284
+ policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
10285
+ requiredPolicy: "confirm",
10286
+ riskFlags: [...input.riskFlags],
10287
+ expect: {
10288
+ to: [...input.expect.to],
10289
+ cc: [...input.expect.cc],
10290
+ bcc: [...input.expect.bcc],
10291
+ subject: input.expect.subject
10292
+ },
10293
+ challengeAttempts: 0,
10294
+ state: "approved",
10295
+ createdAt: at,
10296
+ expiresAt: until,
10297
+ updatedAt: at,
10298
+ approvedVia: "standing",
10299
+ approvedAt: at,
10300
+ usableUntil: until
10301
+ });
10302
+ const record = {
10303
+ ...sealed,
10304
+ approvedBindingDigest: sealed.bindingDigest
10305
+ };
10306
+ await this.#write(record);
10307
+ return record;
10308
+ }
10309
+ /**
10310
+ * Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
10311
+ * for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
10312
+ * refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
10313
+ * what is narrowed is kept beside it, where it can only take away.
10314
+ */
10315
+ async narrowStanding(standingId, owner, narrowing) {
10316
+ return (await this.#transition(standingId, {
10317
+ kind: "standing",
10318
+ owner
10319
+ }, "revoke", (current, outcome) => {
10320
+ if (outcome.error) throw outcome.error;
10321
+ if (current.state !== "pending" && current.state !== "approved" || current.standing === void 0) throw refuseStanding("APPROVAL_VOID", "the standing approval has ended, so there is nothing to narrow", current);
10322
+ return {
10323
+ ...current,
10324
+ narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
10325
+ };
10326
+ })).record;
10327
+ }
10328
+ /**
9355
10329
  * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
9356
10330
  * caller, as a change's digest is. It stands in for the draft revision too.
9357
10331
  *
@@ -9636,13 +10610,28 @@ var ApprovalStore = class {
9636
10610
  ...derived.record,
9637
10611
  updatedAt: this.#now().toISOString()
9638
10612
  });
10613
+ const outcome = approvalOutcome(derived, {
10614
+ action: "inspect",
10615
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
10616
+ now: this.#now()
10617
+ });
10618
+ if (writtenAtALook(outcome) && outcome.record !== null) {
10619
+ const ended = {
10620
+ ...outcome.record,
10621
+ updatedAt: this.#now().toISOString()
10622
+ };
10623
+ await this.#write(ended);
10624
+ return {
10625
+ stored: {
10626
+ form: "v2",
10627
+ record: ended
10628
+ },
10629
+ outcome
10630
+ };
10631
+ }
9639
10632
  return {
9640
10633
  stored: derived,
9641
- outcome: approvalOutcome(derived, {
9642
- action: "inspect",
9643
- live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
9644
- now: this.#now()
9645
- })
10634
+ outcome
9646
10635
  };
9647
10636
  });
9648
10637
  if (seen === null) continue;
@@ -9681,6 +10670,10 @@ var ApprovalStore = class {
9681
10670
  return found.sort(byCreation);
9682
10671
  }
9683
10672
  };
10673
+ /** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
10674
+ function requireEpochs(sendEpoch, standingEpoch) {
10675
+ for (const epoch of [sendEpoch, standingEpoch]) if (!Number.isSafeInteger(epoch) || epoch < 0) throw new CommsError("UNEXPECTED", "a standing approval is bound to whole epochs, and this is not one", { hint: "This is a bug — please report it." });
10676
+ }
9684
10677
  /** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
9685
10678
  function byCreation(a, b) {
9686
10679
  const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
@@ -9718,7 +10711,7 @@ function sameExpectation(a, b) {
9718
10711
  const CLOCK_ANOMALY = "clock-anomaly";
9719
10712
  const HEX64 = /^[0-9a-f]{64}$/;
9720
10713
  /** Exactly what `Date.prototype.toISOString` writes, which is all the store ever writes. */
9721
- const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10714
+ const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
9722
10715
  const STATES$1 = [
9723
10716
  "pending",
9724
10717
  "approved",
@@ -9729,7 +10722,10 @@ const STATES$1 = [
9729
10722
  "expired",
9730
10723
  "revoked"
9731
10724
  ];
9732
- /** The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. */
10725
+ /**
10726
+ * The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
10727
+ * approval is never claimed at all — its children are — so it ends expired or revoked.
10728
+ */
9733
10729
  const STATES_OF = {
9734
10730
  send: STATES$1,
9735
10731
  change: [
@@ -9745,8 +10741,24 @@ const STATES_OF = {
9745
10741
  "used",
9746
10742
  "expired",
9747
10743
  "revoked"
10744
+ ],
10745
+ standing: [
10746
+ "pending",
10747
+ "approved",
10748
+ "expired",
10749
+ "revoked"
9748
10750
  ]
9749
10751
  };
10752
+ /** A standing approval's child, approved when it is made: never pending. */
10753
+ const CHILD_STATES = [
10754
+ "approved",
10755
+ "sending",
10756
+ "used",
10757
+ "failed",
10758
+ "unknown",
10759
+ "expired",
10760
+ "revoked"
10761
+ ];
9750
10762
  /** The send states that went through a claim, and so carry `sendingAt`. */
9751
10763
  const CLAIMED_SEND = [
9752
10764
  "sending",
@@ -9754,10 +10766,14 @@ const CLAIMED_SEND = [
9754
10766
  "failed",
9755
10767
  "unknown"
9756
10768
  ];
10769
+ /** Whether a record is one use of a standing approval: a send on the `standing` route. */
10770
+ function isChild(raw) {
10771
+ return raw.kind === "send" && raw.route === "standing";
10772
+ }
9757
10773
  /** A record's timestamp field: absent, invalid, or the time it names. */
9758
10774
  function timeOf(value) {
9759
10775
  if (value === void 0) return null;
9760
- if (typeof value !== "string" || !ISO$1.test(value)) return "invalid";
10776
+ if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
9761
10777
  const at = Date.parse(value);
9762
10778
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
9763
10779
  }
@@ -9804,7 +10820,7 @@ function checkVerified(record, raw) {
9804
10820
  if (raw.digestVersion !== 2) return fail("digest-version");
9805
10821
  const kind = raw.kind;
9806
10822
  if (kind === "disclosure") return checkDisclosure(record, raw);
9807
- if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
10823
+ if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
9808
10824
  if (!STATES$1.includes(raw.state)) return fail("state-impossible");
9809
10825
  const state = raw.state;
9810
10826
  if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
@@ -9814,10 +10830,16 @@ function checkVerified(record, raw) {
9814
10830
  if (kind === "download") {
9815
10831
  if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
9816
10832
  }
10833
+ if (kind === "standing") {
10834
+ if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
10835
+ if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
10836
+ if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
10837
+ } else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
9817
10838
  const identity = checkIdentity(record, raw, kind);
9818
10839
  if (identity !== null) return identity;
9819
10840
  if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
9820
- const lifetime = checkLifetime(raw, kind);
10841
+ if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
10842
+ const lifetime = checkLifetime(record, raw, kind);
9821
10843
  if (lifetime !== null) return lifetime;
9822
10844
  const times = {};
9823
10845
  for (const field of TIMESTAMPS) {
@@ -9892,9 +10914,19 @@ function checkIdentity(record, raw, kind) {
9892
10914
  if (scope !== expected) return fail("identity-contradiction");
9893
10915
  if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
9894
10916
  }
9895
- if (kind === "send") {
9896
- if (typeof raw.sendEpoch !== "number" || !Number.isInteger(raw.sendEpoch) || raw.sendEpoch < 0) return fail("identity-contradiction");
10917
+ const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
10918
+ if (kind === "send" || kind === "standing") {
10919
+ if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
9897
10920
  } else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
10921
+ if (kind === "standing" || isChild(raw)) {
10922
+ if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
10923
+ } else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
10924
+ if (isChild(raw)) {
10925
+ if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
10926
+ if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
10927
+ if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
10928
+ } else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
10929
+ if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
9898
10930
  if (kind === "download") {
9899
10931
  const download = record.download;
9900
10932
  if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
@@ -9904,13 +10936,31 @@ function checkIdentity(record, raw, kind) {
9904
10936
  }
9905
10937
  return null;
9906
10938
  }
9907
- /** The lifetime profile: what the route says, and nothing the record could have chosen for itself. */
9908
- function checkLifetime(raw, kind) {
10939
+ /**
10940
+ * The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
10941
+ * waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
10942
+ * `standingChild`, pending and approved alike.
10943
+ */
10944
+ function checkLifetime(record, raw, kind) {
9909
10945
  if (kind === "download") {
9910
10946
  if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
9911
10947
  if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
9912
10948
  return null;
9913
10949
  }
10950
+ if (kind === "standing") {
10951
+ const days = record.standing.limits.expiresDays;
10952
+ const { min, max } = STANDING_LIMITS.expiresDays;
10953
+ if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
10954
+ if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
10955
+ if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
10956
+ if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
10957
+ return null;
10958
+ }
10959
+ if (isChild(raw)) {
10960
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
10961
+ if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
10962
+ return null;
10963
+ }
9914
10964
  if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
9915
10965
  if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
9916
10966
  if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
@@ -9960,6 +11010,10 @@ function checkTimestamps(raw, kind, state, times) {
9960
11010
  if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
9961
11011
  if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
9962
11012
  }
11013
+ if (isChild(raw)) {
11014
+ if (approvedAt === void 0) return fail("timestamp-missing");
11015
+ if (approvedAt !== createdAt) return fail("timestamp-misordered");
11016
+ }
9963
11017
  const deadline = usableUntil ?? expiresAt;
9964
11018
  const since = approvedAt ?? createdAt;
9965
11019
  const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
@@ -9987,7 +11041,9 @@ function checkTimestamps(raw, kind, state, times) {
9987
11041
  */
9988
11042
  function checkEvidence(record, raw, kind, state) {
9989
11043
  const via = raw.approvedVia;
9990
- if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
11044
+ if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
11045
+ if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
11046
+ if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
9991
11047
  if (kind === "download") {
9992
11048
  const answer = record.download?.answer;
9993
11049
  const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
@@ -10005,7 +11061,9 @@ function checkEvidence(record, raw, kind, state) {
10005
11061
  if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
10006
11062
  if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
10007
11063
  if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
10008
- if (kind === "change" && via !== "terminal") return fail("evidence-invalid");
11064
+ if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
11065
+ if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
11066
+ if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
10009
11067
  if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
10010
11068
  return null;
10011
11069
  }
@@ -10025,9 +11083,11 @@ const KINDS$1 = [
10025
11083
  "download",
10026
11084
  "disclosure"
10027
11085
  ];
11086
+ /** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
11087
+ const V2_KINDS = [...KINDS$1, "standing"];
10028
11088
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10029
- const isString = (value) => typeof value === "string";
10030
- const isStrings = (value) => Array.isArray(value) && value.every(isString);
11089
+ const isString$1 = (value) => typeof value === "string";
11090
+ const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
10031
11091
  const optional = (value, check) => value === void 0 || check(value);
10032
11092
  function unreadable(approvalId, reason) {
10033
11093
  return {
@@ -10040,22 +11100,22 @@ function unreadable(approvalId, reason) {
10040
11100
  };
10041
11101
  }
10042
11102
  function expectationShaped(value) {
10043
- return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
11103
+ return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
10044
11104
  }
10045
11105
  /**
10046
11106
  * The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
10047
11107
  * a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
10048
11108
  */
10049
11109
  function commonShape(raw) {
10050
- if (!isString(raw.inboxId)) return "missing-ownership";
10051
- if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
11110
+ if (!isString$1(raw.inboxId)) return "missing-ownership";
11111
+ if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
10052
11112
  for (const field of [
10053
11113
  "createdAt",
10054
11114
  "expiresAt",
10055
11115
  "updatedAt",
10056
11116
  "draftId",
10057
11117
  "draftMessageId"
10058
- ]) if (!isString(raw[field])) return "wrong-shape";
11118
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
10059
11119
  if (!expectationShaped(raw.expect)) return "wrong-shape";
10060
11120
  for (const field of [
10061
11121
  "inboxSub",
@@ -10066,17 +11126,23 @@ function commonShape(raw) {
10066
11126
  "reason",
10067
11127
  "policy",
10068
11128
  "requiredPolicy"
10069
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11129
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10070
11130
  if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
10071
11131
  if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
10072
11132
  const kind = raw.kind ?? "send";
10073
11133
  if (kind === "change") {
10074
11134
  const change = raw.change;
10075
- if (!isObject(change) || !isString(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
11135
+ if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
11136
+ }
11137
+ if (kind === "standing") {
11138
+ const standing = raw.standing;
11139
+ if (!isObject(standing) || !isString$1(standing.summary) || !isObject(standing.target) || !isString$1(standing.target.id) || !Array.isArray(standing.contents) || !standing.contents.every((content) => isObject(content) && isString$1(content.kind) && isString$1(content.text) && isString$1(content.digest)) || !isObject(standing.places) || !isString$1(standing.places.kind) || !isObject(standing.limits) || !isString$1(standing.proposedBy) || !optional(standing.replaces, isString$1) || !isStrings(standing.warnings)) return "wrong-shape";
11140
+ const narrowed = raw.narrowed;
11141
+ if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
10076
11142
  }
10077
11143
  if (kind === "download") {
10078
11144
  const download = raw.download;
10079
- if (!isObject(download) || !isString(download.summary) || !isObject(download.target) || !isString(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString(download.folders.downloads) || !isString(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
11145
+ if (!isObject(download) || !isString$1(download.summary) || !isObject(download.target) || !isString$1(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString$1(download.folders.downloads) || !isString$1(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
10080
11146
  }
10081
11147
  return null;
10082
11148
  }
@@ -10089,7 +11155,7 @@ function disclosureShape(raw) {
10089
11155
  "expiresAt",
10090
11156
  "updatedAt",
10091
11157
  "bindingDigest"
10092
- ]) if (!isString(raw[field])) return "wrong-shape";
11158
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
10093
11159
  for (const field of [
10094
11160
  "pendingMs",
10095
11161
  "approvedMs",
@@ -10105,9 +11171,9 @@ function disclosureShape(raw) {
10105
11171
  "revokedAt",
10106
11172
  "expiredAt",
10107
11173
  "reason"
10108
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11174
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10109
11175
  const disclosure = raw.disclosure;
10110
- if (!isObject(disclosure) || !isString(disclosure.digest) || !isString(disclosure.activationIntentId) || !isString(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString(entry.kind) && isString(entry.id) && typeof entry.version === "number")) return "wrong-shape";
11176
+ if (!isObject(disclosure) || !isString$1(disclosure.digest) || !isString$1(disclosure.activationIntentId) || !isString$1(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString$1(entry.kind) && isString$1(entry.id) && typeof entry.version === "number")) return "wrong-shape";
10111
11177
  for (const field of [
10112
11178
  "channel",
10113
11179
  "ownerScope",
@@ -10122,6 +11188,8 @@ function disclosureShape(raw) {
10122
11188
  "requiredPolicy",
10123
11189
  "riskFlags",
10124
11190
  "approvedBindingDigest",
11191
+ "approvedChangePolicy",
11192
+ "standingUse",
10125
11193
  "sendingAt",
10126
11194
  "sendingHeartbeatAt",
10127
11195
  "sentAt",
@@ -10139,16 +11207,20 @@ function v2Shape(raw) {
10139
11207
  "channel",
10140
11208
  "ownerScope",
10141
11209
  "contentDigest"
10142
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11210
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10143
11211
  for (const field of [
10144
11212
  "pendingMs",
10145
11213
  "approvedMs",
10146
- "sendEpoch"
11214
+ "sendEpoch",
11215
+ "standingEpoch"
10147
11216
  ]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
11217
+ if (!optional(raw.standingId, isString$1)) return "wrong-shape";
11218
+ if (!optional(raw.standingUse, isObject)) return "wrong-shape";
10148
11219
  for (const field of [
10149
11220
  "route",
10150
11221
  "bindingDigest",
10151
11222
  "approvedBindingDigest",
11223
+ "approvedChangePolicy",
10152
11224
  "approvedAt",
10153
11225
  "usableUntil",
10154
11226
  "sendingAt",
@@ -10158,7 +11230,7 @@ function v2Shape(raw) {
10158
11230
  "failedAt",
10159
11231
  "revokedAt",
10160
11232
  "expiredAt"
10161
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11233
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10162
11234
  return null;
10163
11235
  }
10164
11236
  /**
@@ -10174,7 +11246,7 @@ function decodeStored(fileId, text, config, now) {
10174
11246
  return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
10175
11247
  }
10176
11248
  if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
10177
- if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
11249
+ if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
10178
11250
  if (raw.kind === "disclosure") {
10179
11251
  if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
10180
11252
  const shape = disclosureShape(raw);
@@ -10196,8 +11268,8 @@ function decodeStored(fileId, text, config, now) {
10196
11268
  const shape = commonShape(raw);
10197
11269
  if (shape !== null) return unreadable(fileId, shape);
10198
11270
  if (raw.digestVersion === 1) {
10199
- if (!optional(raw.kind, (kind) => isString(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
10200
- if (!isString(raw.digest)) return unreadable(fileId, "wrong-shape");
11271
+ if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
11272
+ if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
10201
11273
  if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
10202
11274
  const record = raw;
10203
11275
  return {
@@ -10206,7 +11278,7 @@ function decodeStored(fileId, text, config, now) {
10206
11278
  record
10207
11279
  };
10208
11280
  }
10209
- if (!isString(raw.kind) || !KINDS$1.includes(raw.kind)) return unreadable(fileId, "missing-kind");
11281
+ if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
10210
11282
  const v2 = v2Shape(raw);
10211
11283
  if (v2 !== null) return unreadable(fileId, v2);
10212
11284
  const record = raw;
@@ -10330,9 +11402,9 @@ const NEVER = Object.freeze({
10330
11402
  cursor: null
10331
11403
  });
10332
11404
  /** Exactly what `Date.prototype.toISOString` writes. */
10333
- const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
11405
+ const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10334
11406
  function isoTime(value) {
10335
- if (typeof value !== "string" || !ISO.test(value)) return null;
11407
+ if (typeof value !== "string" || !ISO$1.test(value)) return null;
10336
11408
  const at = Date.parse(value);
10337
11409
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
10338
11410
  }
@@ -10932,7 +12004,7 @@ function folding(platform) {
10932
12004
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
10933
12005
  }
10934
12006
  /** The package version, read from package.json at build time. */
10935
- const VERSION = "0.22.0";
12007
+ const VERSION = "0.24.0";
10936
12008
  /**
10937
12009
  * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
10938
12010
  *
@@ -11107,8 +12179,8 @@ function locateCliCommand(request, runtime = {
11107
12179
  const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
11108
12180
  return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
11109
12181
  }
11110
- const callerWorkspace = workspaceOf(callerRoot);
11111
- const targetWorkspace = workspaceOf(installed);
12182
+ const callerWorkspace = workspaceOf$1(callerRoot);
12183
+ const targetWorkspace = workspaceOf$1(installed);
11112
12184
  const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
11113
12185
  const found = {
11114
12186
  root: installed,
@@ -11518,7 +12590,7 @@ function installedPackage(start, name) {
11518
12590
  * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
11519
12591
  * installed copy in its `node_modules` — is not a workspace package of it.
11520
12592
  */
11521
- function workspaceOf(root) {
12593
+ function workspaceOf$1(root) {
11522
12594
  let dir = dirname(root);
11523
12595
  for (;;) {
11524
12596
  const globs = workspaceGlobs(dir);
@@ -12396,7 +13468,7 @@ async function gatedChangeAtTerminal(core, change, options) {
12396
13468
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
12397
13469
  } else if (options.answered !== true) {
12398
13470
  streams.stdout.write(`${prepared.preview}\n\n`);
12399
- if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
13471
+ if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
12400
13472
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
12401
13473
  surface: "cli",
12402
13474
  disposition: "person"
@@ -12461,7 +13533,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
12461
13533
  }
12462
13534
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
12463
13535
  streams.stdout.write(`${prompt.preview}\n\n`);
12464
- const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
13536
+ const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
12465
13537
  if (!answer.trim()) {
12466
13538
  await revokeChange(core, approvalId, "cancelled at the terminal", {
12467
13539
  surface: "cli",
@@ -12481,7 +13553,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
12481
13553
  function cancelled() {
12482
13554
  return new CommsError("USAGE", "cancelled: nothing was changed");
12483
13555
  }
12484
- async function askLine(streams, question) {
13556
+ async function askLine$1(streams, question) {
12485
13557
  const { createInterface } = await import("node:readline/promises");
12486
13558
  const rl = createInterface({
12487
13559
  input: streams.stdin,
@@ -12816,6 +13888,186 @@ async function openSecretStore(kind, options) {
12816
13888
  if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
12817
13889
  return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
12818
13890
  }
13891
+ /** Exactly what `Date.prototype.toISOString` writes. */
13892
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13893
+ const OUTCOMES = [
13894
+ "sent",
13895
+ "failed",
13896
+ "unknown"
13897
+ ];
13898
+ const isString = (value) => typeof value === "string";
13899
+ const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
13900
+ /** One line as an entry, or null when it is not one this release wrote. */
13901
+ function entryOf$2(line) {
13902
+ let raw;
13903
+ try {
13904
+ raw = JSON.parse(line);
13905
+ } catch {
13906
+ return null;
13907
+ }
13908
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
13909
+ const entry = raw;
13910
+ if (!isTime$2(entry.at)) return null;
13911
+ if (entry.kind === "use") {
13912
+ if (![
13913
+ "standingId",
13914
+ "childId",
13915
+ "contentDigest",
13916
+ "channel",
13917
+ "ts",
13918
+ "thread",
13919
+ "sender",
13920
+ "targetDigest"
13921
+ ].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
13922
+ if (entry.act !== "reaction" && entry.act !== "reply") return null;
13923
+ if (typeof entry.edited !== "boolean") return null;
13924
+ return {
13925
+ kind: "use",
13926
+ at: entry.at,
13927
+ standingId: entry.standingId,
13928
+ childId: entry.childId,
13929
+ act: entry.act,
13930
+ contentDigest: entry.contentDigest,
13931
+ channel: entry.channel,
13932
+ ts: entry.ts,
13933
+ thread: entry.thread,
13934
+ sender: entry.sender,
13935
+ targetDigest: entry.targetDigest,
13936
+ edited: entry.edited
13937
+ };
13938
+ }
13939
+ if (entry.kind === "outcome") {
13940
+ if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
13941
+ if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
13942
+ if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
13943
+ return {
13944
+ kind: "outcome",
13945
+ at: entry.at,
13946
+ ...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
13947
+ childId: entry.childId,
13948
+ outcome: entry.outcome,
13949
+ ...entry.link === void 0 ? {} : { link: entry.link },
13950
+ notice: entry.notice
13951
+ };
13952
+ }
13953
+ return null;
13954
+ }
13955
+ var AckLedger = class {
13956
+ directory;
13957
+ #now;
13958
+ constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
13959
+ this.directory = join(stateDir, "standing");
13960
+ this.#now = now;
13961
+ }
13962
+ /** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
13963
+ now() {
13964
+ return this.#now();
13965
+ }
13966
+ /** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
13967
+ #path(accountId) {
13968
+ if (typeof accountId !== "string" || !ACCOUNT_ID_PATTERN.test(accountId)) throw new CommsError("USAGE", `"${String(accountId)}" is not an account id`, { hint: "A standing approval’s ledger is kept per account, by the account’s id." });
13969
+ return join(this.directory, `${accountId}.jsonl`);
13970
+ }
13971
+ /** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
13972
+ async #read(path) {
13973
+ let text = "";
13974
+ try {
13975
+ text = await readFile(path, "utf8");
13976
+ } catch (error) {
13977
+ if (error.code !== "ENOENT") throw error;
13978
+ }
13979
+ const entries = [];
13980
+ const unreadable = [];
13981
+ for (const [index, line] of text.split("\n").entries()) {
13982
+ if (line.trim() === "") continue;
13983
+ const entry = entryOf$2(line);
13984
+ if (entry === null) unreadable.push(index + 1);
13985
+ else entries.push(entry);
13986
+ }
13987
+ return {
13988
+ entries,
13989
+ unreadable
13990
+ };
13991
+ }
13992
+ /** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
13993
+ pathOf(accountId) {
13994
+ return this.#path(accountId);
13995
+ }
13996
+ /**
13997
+ * Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
13998
+ * line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
13999
+ * standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
14000
+ * without the lock: a look, and every line it reads was written whole.
14001
+ */
14002
+ async unreadable() {
14003
+ let names;
14004
+ try {
14005
+ names = await readdir(this.directory);
14006
+ } catch (error) {
14007
+ if (error.code === "ENOENT") return [];
14008
+ throw error;
14009
+ }
14010
+ const found = [];
14011
+ for (const name of names.sort()) {
14012
+ if (!name.endsWith(".jsonl")) continue;
14013
+ const accountId = name.slice(0, -6);
14014
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
14015
+ const path = this.#path(accountId);
14016
+ const { unreadable } = await this.#read(path);
14017
+ if (unreadable.length > 0) found.push({
14018
+ accountId,
14019
+ path,
14020
+ lines: unreadable
14021
+ });
14022
+ }
14023
+ return found;
14024
+ }
14025
+ /**
14026
+ * Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
14027
+ * line under the lock already held. `fn` must never take this lock again.
14028
+ *
14029
+ * A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
14030
+ * be lower than the truth, and a count is never guessed downwards.
14031
+ */
14032
+ withLock(accountId, fn) {
14033
+ let path;
14034
+ try {
14035
+ path = this.#path(accountId);
14036
+ } catch (error) {
14037
+ return Promise.reject(error);
14038
+ }
14039
+ return withFileLock(`${path}.lock`, async () => {
14040
+ const { entries, unreadable } = await this.#read(path);
14041
+ if (unreadable.length > 0) throw new CommsError("BAD_DATA", "nothing was sent: this account’s standing acknowledgement ledger has a line that cannot be read", {
14042
+ hint: "No standing approval of this account can be used until the ledger reads whole: what it counts cannot be known. Ask the person instead; the doctor says which line, and how to recover.",
14043
+ details: {
14044
+ unreadable: unreadable.length,
14045
+ lines: unreadable
14046
+ }
14047
+ });
14048
+ const append = async (entry) => {
14049
+ if (entry.kind !== "use" || entryOf$2(JSON.stringify(entry)) === null) throw new CommsError("UNEXPECTED", "a standing use is written whole, or not at all", { hint: "This is a bug — please report it." });
14050
+ await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
14051
+ };
14052
+ return fn(entries, append);
14053
+ });
14054
+ }
14055
+ /** Records what became of a use's act, taking the lock itself. It never frees a count. */
14056
+ async recordOutcome(accountId, entry) {
14057
+ const path = this.#path(accountId);
14058
+ const written = {
14059
+ ...entry,
14060
+ kind: "outcome",
14061
+ at: entry.at ?? this.#now().toISOString()
14062
+ };
14063
+ if (entryOf$2(JSON.stringify(written)) === null) throw new CommsError("UNEXPECTED", "a standing outcome is written whole, or not at all", { hint: "This is a bug — please report it." });
14064
+ await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
14065
+ }
14066
+ /** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
14067
+ async entries(accountId) {
14068
+ return (await this.#read(this.#path(accountId))).entries;
14069
+ }
14070
+ };
12819
14071
  var InboxStateStore = class {
12820
14072
  directory;
12821
14073
  constructor(stateDir) {
@@ -13629,6 +14881,7 @@ function openCore(options = {}) {
13629
14881
  answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
13630
14882
  }),
13631
14883
  ledger: new SendLedger(paths.stateDir, now),
14884
+ acks: new AckLedger(paths.stateDir, now),
13632
14885
  plans: new PlanStore(paths.stateDir, now),
13633
14886
  taint: new TaintStore(paths.stateDir, now),
13634
14887
  audit,
@@ -14414,7 +15667,7 @@ function serverPruneChange(core, env, request, own) {
14414
15667
  };
14415
15668
  const { packageName, version } = ownProduct(request.channel, own) ?? {
14416
15669
  packageName: serverFacts(request.channel).packageName,
14417
- version: "0.22.0"
15670
+ version: "0.24.0"
14418
15671
  };
14419
15672
  const product = {
14420
15673
  packageName,
@@ -14562,6 +15815,12 @@ function everyServerPruneChange(core, env, request) {
14562
15815
  }
14563
15816
  };
14564
15817
  }
15818
+ Object.freeze([
15819
+ "narrow",
15820
+ "revoke",
15821
+ "off"
15822
+ ]);
15823
+ Object.keys(STANDING_LIMITS);
14565
15824
  /**
14566
15825
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
14567
15826
  *
@@ -14615,8 +15874,9 @@ function updateToolNotice(options) {
14615
15874
  let running = null;
14616
15875
  let given = false;
14617
15876
  let turn = Promise.resolve();
14618
- return (tool) => {
15877
+ return (tool, args) => {
14619
15878
  if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
15879
+ if (options.quiet?.(tool, args)) return Promise.resolve(null);
14620
15880
  if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
14621
15881
  running = null;
14622
15882
  });
@@ -14946,7 +16206,7 @@ function commandDecisionLine(core, decision, channel, platform) {
14946
16206
  }
14947
16207
  channelServer("core").packageName;
14948
16208
  //#endregion
14949
- //#region ../core/dist/tool-arguments-H5H3FITT.mjs
16209
+ //#region ../core/dist/tool-arguments-5t8Xd2eK.mjs
14950
16210
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
14951
16211
  const TARGET = "draft-2020-12";
14952
16212
  /**
@@ -14989,7 +16249,7 @@ function strictToolArguments(server, refuse, notice, retired = {}) {
14989
16249
  const parsed = await schema.safeParseAsync(input ?? {});
14990
16250
  if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
14991
16251
  const result = await (declared ? handler(parsed.data, context) : handler(context));
14992
- const line = notice ? await notice(name).catch(() => null) : null;
16252
+ const line = notice ? await notice(name, parsed.data).catch(() => null) : null;
14993
16253
  return line === null ? result : withUpdateNotice(result, line, room);
14994
16254
  });
14995
16255
  };
@@ -16130,6 +17390,27 @@ strictObject({
16130
17390
  if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
16131
17391
  }
16132
17392
  });
17393
+ /** The environment values the Windows script reads the title and the body from. */
17394
+ const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
17395
+ const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
17396
+ Object.freeze([
17397
+ "-e",
17398
+ "on run argv",
17399
+ "-e",
17400
+ "display notification (item 1 of argv) with title (item 2 of argv)",
17401
+ "-e",
17402
+ "end run"
17403
+ ]);
17404
+ [
17405
+ "$ErrorActionPreference = 'Stop'",
17406
+ "$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
17407
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
17408
+ "$text = $xml.GetElementsByTagName('text')",
17409
+ `$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
17410
+ `$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
17411
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
17412
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
17413
+ ].join("; ");
16133
17414
  const URL_SHORTENERS = /* @__PURE__ */ new Set([
16134
17415
  "bit.ly",
16135
17416
  "tinyurl.com",
@@ -16260,6 +17541,10 @@ function analyseLink(text, href) {
16260
17541
  flags
16261
17542
  };
16262
17543
  }
17544
+ Object.freeze({
17545
+ min: 5e3,
17546
+ max: 3e4
17547
+ });
16263
17548
  Object.freeze({
16264
17549
  complete: "not sent with any approval in the last 90 days",
16265
17550
  window: "not sent with any of the 500 most recently changed approval records",