@agentcomms/resend 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as openCore, B as approvalKind, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approveChangeAtTerminal, I as CommsError, J as escapeForDisplay, K as colorEnabled, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as installExitStatus, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as canPrompt, V as approvalsOf, Y as exemptFromUpdateGate, Z as gatedChangeAtTerminal, _ as runDoctor, _t as VERSION, a as revokeSendApproval, at as serverInstallChange, b as listAccounts, c as downloadReceived, d as listReceived, et as paint, f as listScheduled, ft as writeResult, g as showSentEmail, h as showReceived, i as prepareSend, it as runCommand, k as ResendContext, l as getMetrics, lt as truncateDisplay, m as listSuppressions, n as executeSend, nt as renderInstall, o as sendStatus, ot as serverPruneChange, p as listSentEmails, q as commandPathOf, r as finishSendApproval, rt as renderPrune, s as cancelScheduledChange, st as terminalUpdateHooks, t as beginSendApproval, tt as refuseUnclaimedApproval, u as listDomains, ut as updateGateAtTerminal, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, z as agentMarker } from "./send-D9aAM9kv.mjs";
2
+ import { $ as openCore, B as approvalKind, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approveChangeAtTerminal, I as CommsError, J as escapeForDisplay, K as colorEnabled, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as installExitStatus, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as canPrompt, V as approvalsOf, Y as exemptFromUpdateGate, Z as gatedChangeAtTerminal, _ as runDoctor, _t as VERSION, a as revokeSendApproval, at as serverInstallChange, b as listAccounts, c as downloadReceived, d as listReceived, et as paint, f as listScheduled, ft as writeResult, g as showSentEmail, h as showReceived, i as prepareSend, it as runCommand, k as ResendContext, l as getMetrics, lt as truncateDisplay, m as listSuppressions, n as executeSend, nt as renderInstall, o as sendStatus, ot as serverPruneChange, p as listSentEmails, q as commandPathOf, r as finishSendApproval, rt as renderPrune, s as cancelScheduledChange, st as terminalUpdateHooks, t as beginSendApproval, tt as refuseUnclaimedApproval, u as listDomains, ut as updateGateAtTerminal, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, z as agentMarker } from "./send-CgN2O8kG.mjs";
3
3
  import path from "node:path";
4
4
  import childProcess from "node:child_process";
5
5
  import { createInterface } from "node:readline/promises";
@@ -3405,7 +3405,7 @@ is out: update first, or put it off (agentcomms update, agentcomms update --late
3405
3405
  }));
3406
3406
  const mcp = program.command("mcp").description("run the MCP server on stdio, for a coding agent to connect to").option("--account <name>", "pin the server to one account; every tool then acts on it and no other").action(async (flags) => {
3407
3407
  ran = true;
3408
- const { startResendStdioServer } = await import("./stdio-entry-F1YLLUXd.mjs");
3408
+ const { startResendStdioServer } = await import("./stdio-entry-u9u7Mn18.mjs");
3409
3409
  await startResendStdioServer({
3410
3410
  ...deps,
3411
3411
  env,
package/dist/index.d.mts CHANGED
@@ -11797,6 +11797,11 @@ interface LooseningConsent {
11797
11797
  */
11798
11798
  changes?: readonly Loosening[] | undefined;
11799
11799
  }
11800
+ /**
11801
+ * Why a saved name has {@link DOWNLOAD_SUFFIX} after it: a name tools read or run on their own (`auto-read`), an
11802
+ * extension that is not one of the inert ones (`type`), or no extension at all (`no-extension`).
11803
+ */
11804
+ type RenameReason = "auto-read" | "type" | "no-extension";
11800
11805
  //#endregion
11801
11806
  //#region src/approvals.d.ts
11802
11807
  /**
@@ -11812,17 +11817,26 @@ interface LooseningConsent {
11812
11817
  *
11813
11818
  * A change approval (`kind: 'change'`) lives in the same store and goes through the same states, except that its
11814
11819
  * claim goes straight to `used`: what it permits is a write to the configuration, which the claimant makes itself.
11820
+ *
11821
+ * So does a download's question (`kind: 'download'`): where to save the files a Gmail or Slack download names. It is
11822
+ * held to the change policy of the mailbox or workspace it is about, as every other change there is. Under `chat` the
11823
+ * person's answer, relayed from the chat, claims it straight from `pending`. Under `confirm` it has to be answered
11824
+ * where an agent cannot answer for them — at their own terminal, or in a form a trusted client shows them — which
11825
+ * records the answer and moves it to `approved`; only then is it claimed, straight to `used`, and the recorded answer
11826
+ * is the one that saves. It gets the store's expiry, its single use and its binding for the reason a change does: an
11827
+ * answer given for three invoices must not save the next conversation's files, nor be spent twice.
11815
11828
  */
11816
11829
  type ApprovalState = "pending" | "approved" | "sending" | "used" | "failed" | "unknown" | "expired" | "revoked";
11817
11830
  type ApprovalChannel = "elicitation" | "terminal";
11818
11831
  /**
11819
- * What an approval permits: a send (a mail, a post, a reaction) or a change to the configuration.
11832
+ * What an approval permits: a send (a mail, a post, a reaction), a change to the configuration, or a download saved
11833
+ * where the person said.
11820
11834
  *
11821
- * One store for both, so a change gets the machinery a send already has — expiry, single use, the typed code — and
11822
- * a kind on every record, so that neither can be spent as the other. Without it, a person who approved a post at a
11835
+ * One store for all three, so a change gets the machinery a send already has — expiry, single use, the typed code —
11836
+ * and a kind on every record, so that none can be spent as another. Without it, a person who approved a post at a
11823
11837
  * terminal would also have approved whatever change an agent claimed under the same id.
11824
11838
  */
11825
- type ApprovalKind = "send" | "change";
11839
+ type ApprovalKind = "send" | "change" | "download";
11826
11840
  /** The inbox or account a change is about. `id` is absent when the change connects it, and it does not exist yet. */
11827
11841
  interface ChangeTarget {
11828
11842
  kind: "inbox" | "account";
@@ -11850,6 +11864,70 @@ interface ChangeBinding {
11850
11864
  /** What it does outside the configuration, in words: a sign-in, a registration, files removed. */
11851
11865
  effects: string[];
11852
11866
  }
11867
+ /**
11868
+ * Exactly which download a question was asked for, stored on the record so the answer can be held to it.
11869
+ *
11870
+ * The files are the ones the question listed, by the platform's own ids — `<message id>/<part id>` for Gmail, the file
11871
+ * id for Slack — in the order they were listed: a claim for any other set is a claim for a download the person was
11872
+ * not asked about. So are the names they would be saved under, in the same order: a Slack file renamed between the
11873
+ * question and the answer would otherwise be saved under a name the person was never shown. The folders are the two
11874
+ * the question showed, as absolute paths. They are not part of the digest, because they are what the answer *means*
11875
+ * rather than what it is for: `downloads` in the answer is the path the person read, even when an agent runs the
11876
+ * download again from another folder.
11877
+ */
11878
+ interface DownloadBinding {
11879
+ /** One line about the download, for a listing: "where to save 2 files from acme/gmail". Not part of the digest. */
11880
+ summary: string;
11881
+ /** The mailbox or workspace the files come from, by the id it has now. */
11882
+ target: {
11883
+ kind: "inbox" | "account";
11884
+ name: string;
11885
+ id: string;
11886
+ };
11887
+ /** Which download asked: `attachments.download`, `files.download`. */
11888
+ operation: string;
11889
+ /** The request as the caller made it, in the words of its own arguments: the selection, and how many at most. */
11890
+ request: Record<string, unknown>;
11891
+ /** The files the question listed, by the platform's ids, in order. */
11892
+ files: string[];
11893
+ /** The names those files would be saved under, as the question listed them, in the same order. */
11894
+ names: string[];
11895
+ /** The two folders the question offered, as absolute paths. */
11896
+ folders: {
11897
+ downloads: string;
11898
+ current: string;
11899
+ };
11900
+ /**
11901
+ * The files as the question listed them to the person — the names they would be saved under, their sizes, why a
11902
+ * name has `.download` after it, and their risk flags — so a terminal or a form can show the question, and its
11903
+ * warnings, again. Not part of the digest: the ids and names above are.
11904
+ */
11905
+ listing?: ListedFile[] | undefined;
11906
+ /** The person's answer, when they gave it where an agent cannot: at a terminal, or in a trusted client's form. */
11907
+ answer?: RecordedSaveAnswer | undefined;
11908
+ }
11909
+ /** One file as a question lists it. */
11910
+ interface ListedFile {
11911
+ /** The name it would be saved under: the sender's made safe, with `.download` after it unless it is inert. */
11912
+ name: string;
11913
+ size: number | null;
11914
+ /** Why `.download` is after its name; absent when the name is the sender's, made safe. */
11915
+ renamed?: RenameReason | undefined;
11916
+ /** Its risk flags, as the file's own listing gives them. */
11917
+ flags?: string[] | undefined;
11918
+ }
11919
+ /**
11920
+ * An answer recorded on a question: one of the two folders it offered, or the folder the person named, as an absolute
11921
+ * path — resolved where they typed it, so it means the folder they read.
11922
+ */
11923
+ type RecordedSaveAnswer = {
11924
+ readonly choice: "downloads" | "current";
11925
+ } | {
11926
+ readonly choice: "other";
11927
+ readonly folder: string;
11928
+ };
11929
+ /** What a claim of a download's question is held to: all of the binding but its summary and the folders it offered. */
11930
+ type DownloadRequest = Pick<DownloadBinding, "target" | "operation" | "request" | "files" | "names">;
11853
11931
  interface Expectation {
11854
11932
  to: string[];
11855
11933
  cc: string[];
@@ -11888,6 +11966,8 @@ interface ApprovalRecord {
11888
11966
  reason?: string | undefined;
11889
11967
  /** For a change: exactly what it permits. `digest` is `changeDigest` of this. */
11890
11968
  change?: ChangeBinding | undefined;
11969
+ /** For a download's question: what it asked about, and the folders it offered. `digest` is `downloadDigest`. */
11970
+ download?: DownloadBinding | undefined;
11891
11971
  }
11892
11972
  interface CreateApprovalInput {
11893
11973
  inboxId: string;
@@ -11933,6 +12013,7 @@ declare class ApprovalStore {
11933
12013
  constructor(stateDir: string, options?: {
11934
12014
  now?: () => Date;
11935
12015
  ttlMs?: number;
12016
+ downloadTtlMs?: number;
11936
12017
  });
11937
12018
  create(input: CreateApprovalInput): Promise<ApprovalRecord>;
11938
12019
  get(approvalId: string): Promise<ApprovalRecord | null>;
@@ -11979,6 +12060,52 @@ declare class ApprovalStore {
11979
12060
  * person to have typed the code at a terminal first; until then the refusal leaves the record as it is.
11980
12061
  */
11981
12062
  claimForChange(approvalId: string, live: LiveChange, options?: ClaimOptions): Promise<ApprovalRecord>;
12063
+ /**
12064
+ * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
12065
+ * caller, as a change's digest is. It stands in for the draft revision too.
12066
+ *
12067
+ * `policy` is the change policy of the mailbox or workspace the files come from, as it stands when the question is
12068
+ * asked: where a stranger's files land on this machine is a change to it, and is answered the way that account's
12069
+ * other changes are approved. A claim holds the question to the stricter of this and the policy then.
12070
+ */
12071
+ createDownload(input: {
12072
+ download: DownloadBinding;
12073
+ policy: ChangePolicy;
12074
+ }): Promise<ApprovalRecord & {
12075
+ download: DownloadBinding;
12076
+ }>;
12077
+ /**
12078
+ * Records the person's answer to a download's question, given where an agent cannot give it: at their own terminal,
12079
+ * or in a form a trusted client showed them. The question moves to `approved`, carrying the answer, and waits for
12080
+ * the download to claim it — which it may then do under `confirm`, and which saves where this answer says.
12081
+ *
12082
+ * Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
12083
+ */
12084
+ answerDownload(approvalId: string, via: ApprovalChannel, answer: RecordedSaveAnswer): Promise<ApprovalRecord & {
12085
+ download: DownloadBinding;
12086
+ }>;
12087
+ /**
12088
+ * Claims a download's question, once, for the download the caller is about to make — and returns it, with the
12089
+ * folders it offered, so that `downloads` and `current` in the answer mean the paths the person read, and with the
12090
+ * answer the person recorded, when they recorded one.
12091
+ *
12092
+ * `live` is the download as the caller computes it now: the same account, the same request, the same files under
12093
+ * the same names — the person answered for those files and no others. Any other is refused, and the question left
12094
+ * open, as it was: a second call that got an argument wrong is the agent's slip, not the person's, and voiding the
12095
+ * question for it would make the person answer again for nothing. It still expires, and is claimed once.
12096
+ *
12097
+ * `options.policy` is the change policy of that account now, and the stricter of it and the one the question was
12098
+ * asked under decides. Under `chat`, a pending question is claimed with the answer the caller carries: the person
12099
+ * gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
12100
+ * form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
12101
+ * expired is refused as an approval in that state is.
12102
+ */
12103
+ claimForDownload(approvalId: string, live: DownloadRequest, options?: {
12104
+ policy?: ChangePolicy | undefined;
12105
+ pendingHint?: string | undefined;
12106
+ }): Promise<ApprovalRecord & {
12107
+ download: DownloadBinding;
12108
+ }>;
11982
12109
  /** Records the outcome of the one send attempt. */
11983
12110
  complete(approvalId: string, outcome: {
11984
12111
  sentMessageId: string;
@@ -12078,7 +12205,10 @@ interface ResolvedPaths {
12078
12205
  secretsDir: string;
12079
12206
  /** Managed runtime installs for MCP clients. */
12080
12207
  dataDir: string;
12081
- /** Default root for attachment downloads and exports. */
12208
+ /**
12209
+ * Default root for exports and for the files Resend's received mail carries. A Gmail attachment or a Slack file is
12210
+ * not saved here: those downloads ask the person where to save (`save-destination.ts`).
12211
+ */
12082
12212
  downloadsDir: string;
12083
12213
  }
12084
12214
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { _t as VERSION, gt as ROUTES, ht as RESEND_DOWNLOAD_ORIGIN, mt as RESEND_API_ORIGIN, pt as REFUSED } from "./send-D9aAM9kv.mjs";
2
- import { t as createResendMcpServer } from "./server-Cr9ZXT1i.mjs";
1
+ import { _t as VERSION, gt as ROUTES, ht as RESEND_DOWNLOAD_ORIGIN, mt as RESEND_API_ORIGIN, pt as REFUSED } from "./send-CgN2O8kG.mjs";
2
+ import { t as createResendMcpServer } from "./server-DYhcD72L.mjs";
3
3
  //#region src/index.ts
4
4
  /**
5
5
  * The library entry of `@agentcomms/resend`.
@@ -1,10 +1,10 @@
1
1
  import { b as strictObject, c as boolean, h as number, i as _enum, m as looseObject, p as literal$1, s as array, x as string, y as record } from "./schemas-DiGKNDNq.mjs";
2
2
  import { access, chmod, constants, link, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, utimes } from "node:fs/promises";
3
- import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
- import { homedir, platform } from "node:os";
7
+ import { homedir, platform, userInfo } from "node:os";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import "node:readline/promises";
10
10
  import { styleText } from "node:util";
@@ -12,7 +12,7 @@ import { constants as constants$1 } from "node:fs";
12
12
  //#endregion
13
13
  //#region src/version.ts
14
14
  /** The package version, read from package.json at build time. */
15
- const VERSION$1 = "0.9.0";
15
+ const VERSION$1 = "0.11.0";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -187,7 +187,7 @@ function refusedBecause(method, pathname) {
187
187
  return null;
188
188
  }
189
189
  //#endregion
190
- //#region ../core/dist/update-gate-BwIKJmH7.mjs
190
+ //#region ../core/dist/update-gate-BMcLWU3V.mjs
191
191
  /**
192
192
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
193
193
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -1278,7 +1278,7 @@ const APP_DIR_NAME = "agent-communications";
1278
1278
  function resolvePaths(options = {}) {
1279
1279
  const env = options.env ?? process.env;
1280
1280
  const platform = options.platform ?? process.platform;
1281
- const home = options.home ?? ((platform === "win32" ? env.USERPROFILE : env.HOME) || homedir());
1281
+ const home = options.home ?? homeOf(env, platform);
1282
1282
  const configDir = resolve(env.AGENT_COMMS_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "agent-communications") : platform === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "agent-communications") : join(home, ".config", "agent-communications")));
1283
1283
  const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
1284
1284
  const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
@@ -1290,10 +1290,40 @@ function resolvePaths(options = {}) {
1290
1290
  downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
1291
1291
  };
1292
1292
  }
1293
- /** Expands a leading `~` to the home directory. Nothing else is expanded. */
1294
- function expandHome(path, home = homedir()) {
1293
+ /**
1294
+ * The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
1295
+ * `USERPROFILE` on Windows. Asking `homedir()` directly ignored an environment passed in, so every test that gave the
1296
+ * harness a temporary HOME still had its data and downloads resolved to the real ones: one day of test runs left 748
1297
+ * backups of fixture entries in the maintainer's own data directory.
1298
+ *
1299
+ * Its own function since a download asks where to save: the person's Downloads folder is `<this>/Downloads`, and a
1300
+ * folder they type as `~/…` is expanded from it — the same home every other path here is resolved from. When the
1301
+ * environment names none, the account's own home is taken ({@link accountHome}); `account` is for a test to say what
1302
+ * that is.
1303
+ */
1304
+ function homeOf(env = process.env, platform = process.platform, account = accountHome) {
1305
+ return (platform === "win32" ? env.USERPROFILE : env.HOME) || account();
1306
+ }
1307
+ /**
1308
+ * The home the account itself has, for when the environment names none: the password database's (`os.userInfo()`),
1309
+ * which no variable moves, rather than `homedir()`, which reads `HOME` first — from this process's environment, not the
1310
+ * one the caller passed, and so from somewhere the caller did not say. Only when there is no such entry — some
1311
+ * containers run as an id with none — is `homedir()` asked instead.
1312
+ */
1313
+ function accountHome() {
1314
+ try {
1315
+ const home = userInfo().homedir;
1316
+ if (home) return home;
1317
+ } catch {}
1318
+ return homedir();
1319
+ }
1320
+ /**
1321
+ * Expands a leading `~` to the home directory. Nothing else is expanded. `joinPaths` is the platform's own, for a path
1322
+ * judged for another platform than this one: a Linux home joined on Windows would otherwise take its backslashes.
1323
+ */
1324
+ function expandHome(path, home = homedir(), joinPaths = join) {
1295
1325
  if (path === "~") return home;
1296
- if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
1326
+ if (path.startsWith("~/") || path.startsWith("~\\")) return joinPaths(home, path.slice(2));
1297
1327
  return path;
1298
1328
  }
1299
1329
  /**
@@ -2131,6 +2161,55 @@ function challengeMatches(answer, storedHash) {
2131
2161
  const b = Buffer.from(storedHash, "hex");
2132
2162
  return a.length === b.length && timingSafeEqual(a, b);
2133
2163
  }
2164
+ /**
2165
+ * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
2166
+ * names it showed them under.
2167
+ *
2168
+ * The files and names keep their order, since the question showed them in it; the request is canonical JSON, so the
2169
+ * same arguments digest the same however an object happened to list its keys.
2170
+ */
2171
+ function downloadDigest(download) {
2172
+ return sha256Hex(canonicalJson$1({
2173
+ kind: "download",
2174
+ target: {
2175
+ kind: download.target.kind,
2176
+ name: download.target.name,
2177
+ id: download.target.id
2178
+ },
2179
+ operation: download.operation,
2180
+ request: download.request,
2181
+ files: [...download.files],
2182
+ names: [...download.names]
2183
+ }));
2184
+ }
2185
+ /**
2186
+ * Why a download claimed is not the one its question was asked for, in a sentence — the first difference found. Only
2187
+ * the words; what refuses is the digest.
2188
+ */
2189
+ function downloadDrift(asked, now) {
2190
+ if (asked.target.id !== now.target.id || asked.target.kind !== now.target.kind) return `the question was about ${asked.target.name}, not ${now.target.name}`;
2191
+ if (asked.operation !== now.operation) return "the question was asked for another kind of download";
2192
+ if (canonicalJson$1(asked.request) !== canonicalJson$1(now.request)) return "the question was asked about a different request: other messages, other files or another limit";
2193
+ if (canonicalJson$1(asked.files) !== canonicalJson$1(now.files)) return "the files are not the ones the question listed";
2194
+ const renamed = asked.files.find((_, index) => asked.names[index] !== now.names[index]);
2195
+ if (renamed !== void 0) return `${renamed} would now be saved under another name than the one the question showed: it was renamed since`;
2196
+ return "the download is not the one the question was asked for";
2197
+ }
2198
+ /**
2199
+ * Why a download's question cannot be claimed under this change policy — the stricter of `livePolicy` and the one it
2200
+ * was asked under — or null. Changes nothing, so a download can ask before it looks at a folder, and the store asks
2201
+ * again as it claims.
2202
+ *
2203
+ * Under `chat` nothing stops it: the person's answer, relayed from the conversation, is the answer. Anything stricter
2204
+ * needs the answer recorded on the question by a channel an agent cannot answer — the person's own terminal, or a
2205
+ * form a trusted client showed them. An answer carried in a tool's arguments or a command's flags is refused however
2206
+ * it was worded, with the command that answers it named, and the question left open for the person.
2207
+ */
2208
+ function downloadClaimRefusal(record, livePolicy, pendingHint) {
2209
+ if (stricterPolicy(livePolicy, record.requiredPolicy) === "chat") return null;
2210
+ if (record.state === "approved" && (record.approvedVia === "terminal" || record.approvedVia === "elicitation") && record.download?.answer !== void 0) return null;
2211
+ return refuseDownload("APPROVAL_PENDING", "the change policy here is confirm, so the person answers where to save themselves — at their own terminal, not through an agent", record, pendingHint ?? "Ask the person to answer it at their own terminal, with the approve command of the channel the files come from and this choice id; then make the download again with the choice id alone.");
2212
+ }
2134
2213
  /** The kind of a record. Absent is a send: every record written before changes had approvals. */
2135
2214
  function approvalKind(record) {
2136
2215
  return record.kind ?? "send";
@@ -2179,6 +2258,11 @@ function changeDrift(approved, now) {
2179
2258
  if (canonicalJson$1(approved.effects) !== canonicalJson$1(now.effects)) return "what it does outside the configuration is not what was approved";
2180
2259
  return "the change is not the one that was approved";
2181
2260
  }
2261
+ /**
2262
+ * What to do with a download's question, for a caller that took it for something else: it is answered, not approved
2263
+ * with a code — in the chat, or at the person's own terminal with the command of the channel that asked.
2264
+ */
2265
+ const DOWNLOAD_ANSWER_HINT = "It is answered, not approved with a code: the person says where in the chat, or — under a confirm change policy — at their own terminal, with the `approve` command of the channel the files come from and this id. The download that asked is then made again with this id.";
2182
2266
  const POLICY_RANK = {
2183
2267
  chat: 0,
2184
2268
  confirm: 1,
@@ -2207,9 +2291,25 @@ function refuseChange(code, reason, record, hint) {
2207
2291
  } : {}
2208
2292
  });
2209
2293
  }
2294
+ /**
2295
+ * The same refusal for a download's question, which saves nothing until it is claimed.
2296
+ *
2297
+ * Its hint says to ask again rather than to prepare anything: what the person is shown again is the question, and it
2298
+ * is the download itself that asks it.
2299
+ */
2300
+ function refuseDownload(code, reason, record, hint) {
2301
+ return new CommsError(code, `nothing was saved: ${reason}`, {
2302
+ hint: hint ?? "Make the download again without an answer, and show the person the new question.",
2303
+ details: record ? {
2304
+ choiceId: record.approvalId,
2305
+ state: record.state
2306
+ } : {}
2307
+ });
2308
+ }
2210
2309
  /** The refusal in the words of the record's own kind. */
2211
2310
  function refusalFor(record) {
2212
- return approvalKind(record) === "change" ? refuseChange : refuse$1;
2311
+ const kind = approvalKind(record);
2312
+ return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse$1;
2213
2313
  }
2214
2314
  /** The record as it may be shown to anyone, agents included: never the challenge hash. */
2215
2315
  function publicView(record) {
@@ -2220,10 +2320,12 @@ var ApprovalStore = class {
2220
2320
  directory;
2221
2321
  #now;
2222
2322
  #ttlMs;
2323
+ #downloadTtlMs;
2223
2324
  constructor(stateDir, options = {}) {
2224
2325
  this.directory = join(stateDir, "approvals");
2225
2326
  this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
2226
2327
  this.#ttlMs = options.ttlMs ?? 6e5;
2328
+ this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
2227
2329
  }
2228
2330
  #path(approvalId, suffix = ".json") {
2229
2331
  if (!APPROVAL_ID_PATTERN.test(approvalId)) throw refuse$1("USAGE", `"${approvalId}" is not an approval id`);
@@ -2311,6 +2413,11 @@ var ApprovalStore = class {
2311
2413
  }
2312
2414
  #stateError(record) {
2313
2415
  const refusal = refusalFor(record);
2416
+ if (approvalKind(record) === "download") {
2417
+ if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the question expired before it was answered", record);
2418
+ if (record.state === "revoked") return refusal("APPROVAL_VOID", `the question was voided (${record.reason ?? "revoked"})`, record);
2419
+ return refusal("APPROVAL_VOID", record.state === "used" ? "the question was answered already, and an answer is used once" : `the question is ${record.state}`, record);
2420
+ }
2314
2421
  if (record.state === "expired") return refusal("APPROVAL_EXPIRED", "the approval expired before it was used", record);
2315
2422
  if (record.state === "revoked") return refusal("APPROVAL_VOID", `the approval was voided (${record.reason ?? "revoked"})`, record);
2316
2423
  return refusal("APPROVAL_REQUIRED", `the approval is ${record.state}`, record);
@@ -2322,8 +2429,11 @@ var ApprovalStore = class {
2322
2429
  * somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
2323
2430
  */
2324
2431
  #requireKind(record, kind) {
2325
- if (approvalKind(record) === kind) return;
2432
+ const actual = approvalKind(record);
2433
+ if (actual === kind) return;
2326
2434
  const id = record.approvalId;
2435
+ if (actual === "download") throw (kind === "change" ? refuseChange : refuse$1)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
2436
+ if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
2327
2437
  throw kind === "send" ? refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, `A person approves it with \`agentcomms approve ${id}\` — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
2328
2438
  }
2329
2439
  /** Issues a new challenge to show a human; only its hash is kept. */
@@ -2545,6 +2655,139 @@ var ApprovalStore = class {
2545
2655
  await this.#markClaimed(result);
2546
2656
  return result;
2547
2657
  }
2658
+ /**
2659
+ * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
2660
+ * caller, as a change's digest is. It stands in for the draft revision too.
2661
+ *
2662
+ * `policy` is the change policy of the mailbox or workspace the files come from, as it stands when the question is
2663
+ * asked: where a stranger's files land on this machine is a change to it, and is answered the way that account's
2664
+ * other changes are approved. A claim holds the question to the stricter of this and the policy then.
2665
+ */
2666
+ async createDownload(input) {
2667
+ const now = this.#now();
2668
+ const download = {
2669
+ summary: input.download.summary,
2670
+ target: { ...input.download.target },
2671
+ operation: input.download.operation,
2672
+ request: JSON.parse(canonicalJson$1(input.download.request)),
2673
+ files: [...input.download.files],
2674
+ names: [...input.download.names],
2675
+ folders: {
2676
+ downloads: input.download.folders.downloads,
2677
+ current: input.download.folders.current
2678
+ },
2679
+ ...input.download.listing === void 0 ? {} : { listing: input.download.listing.map((file) => ({
2680
+ name: file.name,
2681
+ size: file.size,
2682
+ ...file.renamed === void 0 ? {} : { renamed: file.renamed },
2683
+ ...file.flags === void 0 || file.flags.length === 0 ? {} : { flags: [...file.flags] }
2684
+ })) }
2685
+ };
2686
+ const digest = downloadDigest(download);
2687
+ const policy = input.policy === "chat" ? "chat" : "confirm";
2688
+ const record = {
2689
+ approvalId: newApprovalId(),
2690
+ kind: "download",
2691
+ digestVersion: 1,
2692
+ inboxId: download.target.id,
2693
+ draftId: "download",
2694
+ draftMessageId: digest,
2695
+ digest,
2696
+ policy,
2697
+ requiredPolicy: policy,
2698
+ riskFlags: [],
2699
+ expect: {
2700
+ to: [],
2701
+ cc: [],
2702
+ bcc: [],
2703
+ subject: download.summary
2704
+ },
2705
+ challengeAttempts: 0,
2706
+ state: "pending",
2707
+ createdAt: now.toISOString(),
2708
+ expiresAt: new Date(now.getTime() + this.#downloadTtlMs).toISOString(),
2709
+ updatedAt: now.toISOString(),
2710
+ download
2711
+ };
2712
+ await this.#write(record);
2713
+ return record;
2714
+ }
2715
+ /**
2716
+ * Records the person's answer to a download's question, given where an agent cannot give it: at their own terminal,
2717
+ * or in a form a trusted client showed them. The question moves to `approved`, carrying the answer, and waits for
2718
+ * the download to claim it — which it may then do under `confirm`, and which saves where this answer says.
2719
+ *
2720
+ * Only a pending question can be answered, and only once: a second answer is refused, not taken over the first.
2721
+ */
2722
+ async answerDownload(approvalId, via, answer) {
2723
+ const recorded = answer.choice === "other" ? {
2724
+ choice: "other",
2725
+ folder: answer.folder
2726
+ } : { choice: answer.choice };
2727
+ return await this.#transition(approvalId, (current) => {
2728
+ this.#requireKind(current, "download");
2729
+ if (current.state === "approved") throw refuseDownload("APPROVAL_VOID", "the question was answered already, and it is answered once", current);
2730
+ if (current.state !== "pending") throw this.#stateError(current);
2731
+ if (!current.download || downloadDigest(current.download) !== current.digest) throw refuseDownload("APPROVAL_VOID", "the question does not describe the download it is bound to", current);
2732
+ return {
2733
+ ...current,
2734
+ state: "approved",
2735
+ approvedVia: via,
2736
+ approvedDigest: current.digest,
2737
+ download: {
2738
+ ...current.download,
2739
+ answer: recorded
2740
+ }
2741
+ };
2742
+ });
2743
+ }
2744
+ /**
2745
+ * Claims a download's question, once, for the download the caller is about to make — and returns it, with the
2746
+ * folders it offered, so that `downloads` and `current` in the answer mean the paths the person read, and with the
2747
+ * answer the person recorded, when they recorded one.
2748
+ *
2749
+ * `live` is the download as the caller computes it now: the same account, the same request, the same files under
2750
+ * the same names — the person answered for those files and no others. Any other is refused, and the question left
2751
+ * open, as it was: a second call that got an argument wrong is the agent's slip, not the person's, and voiding the
2752
+ * question for it would make the person answer again for nothing. It still expires, and is claimed once.
2753
+ *
2754
+ * `options.policy` is the change policy of that account now, and the stricter of it and the one the question was
2755
+ * asked under decides. Under `chat`, a pending question is claimed with the answer the caller carries: the person
2756
+ * gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
2757
+ * form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
2758
+ * expired is refused as an approval in that state is.
2759
+ */
2760
+ async claimForDownload(approvalId, live, options = {}) {
2761
+ const digest = downloadDigest(live);
2762
+ let failure = null;
2763
+ const result = await this.#transition(approvalId, (current) => {
2764
+ this.#requireKind(current, "download");
2765
+ if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
2766
+ const voidWith = (reason) => {
2767
+ failure = {
2768
+ code: "APPROVAL_VOID",
2769
+ reason
2770
+ };
2771
+ return {
2772
+ ...current,
2773
+ state: "revoked",
2774
+ reason
2775
+ };
2776
+ };
2777
+ if (!current.download || downloadDigest(current.download) !== current.digest) return voidWith("the question does not describe the download it is bound to");
2778
+ if (digest !== current.digest) throw refuseDownload("USAGE", `${downloadDrift(current.download, live)}; the question is still open`, current, "Call again with the same arguments the question was asked with, and its choice id. If the files themselves changed, make the download again without an answer, and show the person the new question.");
2779
+ const refusal = downloadClaimRefusal(current, options.policy ?? "chat", options.pendingHint);
2780
+ if (refusal !== null) throw refusal;
2781
+ return {
2782
+ ...current,
2783
+ state: "used"
2784
+ };
2785
+ });
2786
+ const failed = failure;
2787
+ if (failed) throw refuseDownload(failed.code, failed.reason, result);
2788
+ await this.#markClaimed(result);
2789
+ return result;
2790
+ }
2548
2791
  /** Records the outcome of the one send attempt. */
2549
2792
  async complete(approvalId, outcome) {
2550
2793
  return this.#transition(approvalId, (current) => {
@@ -3425,6 +3668,27 @@ function packageFrom(server) {
3425
3668
  const line = [server.command, ...server.args].join(" ");
3426
3669
  return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
3427
3670
  }
3671
+ /**
3672
+ * The environment a child process is started with: `env` as it is, and on Windows also
3673
+ * `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
3674
+ * program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
3675
+ * when `PATH` says to.
3676
+ */
3677
+ function childEnvironment(env = process.env, platform = process.platform) {
3678
+ if (platform !== "win32") return env;
3679
+ return {
3680
+ ...env,
3681
+ NoDefaultCurrentDirectoryInExePath: "1"
3682
+ };
3683
+ }
3684
+ /**
3685
+ * The directories of a search path that name a folder on their own: absolute, and on Windows on a drive. An empty
3686
+ * entry means the current folder to a Unix shell, and a relative one is read against it; either would make the program
3687
+ * found — and run — depend on where this was started, which is where a download may have saved a stranger's file.
3688
+ */
3689
+ function absoluteSearchPath(entries, platform = process.platform) {
3690
+ return entries.filter((entry) => platform === "win32" ? /^[A-Za-z]:[\\/]/.test(entry) : entry !== "" && path.posix.isAbsolute(entry));
3691
+ }
3428
3692
  /** The install options that pin a server to one account: the generic one, and Gmail's and Slack's own names. */
3429
3693
  const PIN_OPTIONS = Object.freeze([
3430
3694
  "account",
@@ -3480,9 +3744,14 @@ function clientCliSearch(env) {
3480
3744
  /**
3481
3745
  * The first of `directories` that holds an executable `name`. PATH is read by the caller, once: a test that counts
3482
3746
  * how often the installer looks at PATH counts exactly the looks it means.
3747
+ *
3748
+ * Only absolute directories are looked in, whoever the caller. An empty entry of PATH is the current folder to a
3749
+ * shell, and a relative one is read against it: the `node`, `npx` or `claude` found there — and then run, or written
3750
+ * into a client's config to be run — would be whatever is in the folder this was started from, which is where a
3751
+ * download may have saved a stranger's file.
3483
3752
  */
3484
3753
  async function executableIn(name, directories, env) {
3485
- const paths = directories.filter(Boolean);
3754
+ const paths = absoluteSearchPath(directories);
3486
3755
  const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
3487
3756
  for (const directory of paths) for (const extension of extensions) {
3488
3757
  const candidate = join(directory, name + extension.toLowerCase());
@@ -3654,13 +3923,20 @@ async function findNpmCli() {
3654
3923
  } catch {}
3655
3924
  throw new CommsError("CONFIG", "npm could not be found next to this Node installation", { hint: "Install with `--launcher npx` instead, which runs the published package directly." });
3656
3925
  }
3926
+ /**
3927
+ * A command run to its end. Its environment is this process's, and on Windows also tells it never to take a program
3928
+ * from its current folder: `npm` and a client's command both start programs of their own by name.
3929
+ */
3657
3930
  function run(command, args) {
3658
3931
  return new Promise((resolvePromise, reject) => {
3659
- const child = spawn(command, args, { stdio: [
3660
- "ignore",
3661
- "ignore",
3662
- "pipe"
3663
- ] });
3932
+ const child = spawn(command, args, {
3933
+ stdio: [
3934
+ "ignore",
3935
+ "ignore",
3936
+ "pipe"
3937
+ ],
3938
+ env: childEnvironment(process.env)
3939
+ });
3664
3940
  let stderr = "";
3665
3941
  child.stderr?.on("data", (chunk) => {
3666
3942
  stderr += String(chunk);
@@ -3886,14 +4162,20 @@ function installCommand$1(product, options, name, extra = []) {
3886
4162
  words.push(...extra);
3887
4163
  return words.map((word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
3888
4164
  }
3889
- /** A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. */
4165
+ /**
4166
+ * A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
4167
+ * `run` starts one, so that on Windows it takes no program from its current folder either.
4168
+ */
3890
4169
  function capture$1(command, args) {
3891
4170
  return new Promise((resolvePromise, reject) => {
3892
- const child = spawn(command, args, { stdio: [
3893
- "ignore",
3894
- "pipe",
3895
- "pipe"
3896
- ] });
4171
+ const child = spawn(command, args, {
4172
+ stdio: [
4173
+ "ignore",
4174
+ "pipe",
4175
+ "pipe"
4176
+ ],
4177
+ env: childEnvironment(process.env)
4178
+ });
3897
4179
  let stdout = "";
3898
4180
  let stderr = "";
3899
4181
  child.stdout?.on("data", (chunk) => {
@@ -4346,11 +4628,27 @@ async function verifyEntry(entry, product) {
4346
4628
  await client.close().catch(() => void 0);
4347
4629
  }
4348
4630
  }
4349
- /** Every running process's command line, or null when they cannot be listed. */
4350
- async function runningCommandLines() {
4631
+ /** Where `ps` is looked for, in order: the system's own directories, never PATH. */
4632
+ const PS_DIRECTORIES = ["/bin", "/usr/bin"];
4633
+ /**
4634
+ * Every running process's command line, or null when they cannot be listed.
4635
+ *
4636
+ * `ps` is started by its full path, `/bin/ps` or else `/usr/bin/ps`, never by its bare name: a name is looked up, and
4637
+ * a lookup can reach the folder this was started from — where a download may have saved a stranger's `ps`, which would
4638
+ * then run, and whose answer would decide what is deleted. With neither there, the processes cannot be listed.
4639
+ *
4640
+ * `directories` and `list` are for tests: where to look for `ps`, and what runs the one found.
4641
+ */
4642
+ async function runningCommandLines(options = {}) {
4351
4643
  if (process.platform === "win32") return null;
4644
+ const ps = await executableIn("ps", options.directories ?? PS_DIRECTORIES, process.env);
4645
+ if (ps === null) return null;
4646
+ return (options.list ?? commandLinesFrom)(ps);
4647
+ }
4648
+ /** What `ps` at this path says is running: one command line per process, or null when it fails. */
4649
+ function commandLinesFrom(ps) {
4352
4650
  return new Promise((resolvePromise) => {
4353
- execFile("ps", [
4651
+ execFile(ps, [
4354
4652
  "-A",
4355
4653
  "-o",
4356
4654
  "args="
@@ -5193,6 +5491,7 @@ async function claimChange(core, approvalId, expect, options) {
5193
5491
  async function changeRecord(core, approvalId) {
5194
5492
  const record = await core.approvals.get(approvalId);
5195
5493
  if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
5494
+ if (approvalKind(record) === "download") throw new CommsError("USAGE", `${approvalId} is a question about where to save files, not a configuration change`, { hint: DOWNLOAD_ANSWER_HINT });
5196
5495
  if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: `Approve it with the command that prepared it: ${channelApproveCommands({ sending: true })}.` });
5197
5496
  if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
5198
5497
  hint: "Nothing was approved. Prepare the change again.",
@@ -6135,11 +6434,14 @@ async function npmLatestVersion(packageName, options) {
6135
6434
  if (typeof latest !== "string") throw new Error(`${where} names no \`latest\` release`);
6136
6435
  return latest;
6137
6436
  }
6138
- /** A command's exit status and output, given up on after `timeoutMs`. */
6437
+ /**
6438
+ * A command's exit status and output, given up on after `timeoutMs`. npm starts programs of its own by name — scripts,
6439
+ * `node-gyp`, a shell — so on Windows it is told never to take one from its current folder.
6440
+ */
6139
6441
  function capture(args, env, timeoutMs) {
6140
6442
  return new Promise((resolve, reject) => {
6141
6443
  const child = spawn(process.execPath, args, {
6142
- env,
6444
+ env: childEnvironment(env),
6143
6445
  stdio: [
6144
6446
  "ignore",
6145
6447
  "pipe",
@@ -6223,7 +6525,7 @@ async function npmInstallGlobal(env, spec) {
6223
6525
  }
6224
6526
  }
6225
6527
  /** The package version, read from package.json at build time. */
6226
- const VERSION = "0.9.0";
6528
+ const VERSION = "0.11.0";
6227
6529
  /**
6228
6530
  * Registering, pruning and listing the MCP servers of every channel, from the core.
6229
6531
  *
@@ -6478,7 +6780,7 @@ function serverPruneChange(core, env, request, own) {
6478
6780
  };
6479
6781
  const { packageName, version } = ownProduct(request.channel, own) ?? {
6480
6782
  packageName: channelServer(request.channel).packageName,
6481
- version: "0.9.0"
6783
+ version: "0.11.0"
6482
6784
  };
6483
6785
  const product = {
6484
6786
  packageName,
@@ -7577,6 +7879,10 @@ const SEND_LOOKUP = Object.freeze({
7577
7879
  lookup: true
7578
7880
  });
7579
7881
  Object.freeze({ kind: "change" });
7882
+ Object.freeze({
7883
+ kind: "download",
7884
+ argument: "choiceId"
7885
+ });
7580
7886
  /**
7581
7887
  * Whether a call claims an approval the person already gave (§2): an approval this machine's approval store holds —
7582
7888
  * every channel keeps its approvals there — that is still waiting to be used, pending or approved, and of the kind the
@@ -7628,7 +7934,8 @@ function updateToolGate(options) {
7628
7934
  surface: "server"
7629
7935
  });
7630
7936
  if (pending === null) return null;
7631
- if (await claimsApproval(options.core, args.approvalId, options.approvals?.[tool])) return null;
7937
+ const claim = options.approvals?.[tool];
7938
+ if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
7632
7939
  return stoppedCall(pending, {
7633
7940
  server: options.server,
7634
7941
  tool
@@ -7693,9 +8000,10 @@ function commandPathOf(command) {
7693
8000
  }
7694
8001
  /**
7695
8002
  * The approvals a Commander command claims: its `--approval`, the `--mcp-approval` Gmail's `setup` carries beside it,
7696
- * and an argument named `approvalId` — `agent-gmail send cancel <approvalId>`, `agent-resend send execute
7697
- * <approvalId>`, the commands whose tools take it as `approvalId`. What the gate hands `claimsApproval`, as a tool
7698
- * call's `approvalId` is handed it. Commander has read the arguments by the time a `preAction` hook runs.
8003
+ * the `--choice` a download carries its question's id in, and an argument named `approvalId` — `agent-gmail send
8004
+ * cancel <approvalId>`, `agent-resend send execute <approvalId>`, the commands whose tools take it as `approvalId`.
8005
+ * What the gate hands `claimsApproval`, as a tool call's `approvalId` is handed it. Commander has read the arguments
8006
+ * by the time a `preAction` hook runs.
7699
8007
  */
7700
8008
  function approvalsOf(command) {
7701
8009
  const options = command.opts();
@@ -7703,6 +8011,7 @@ function approvalsOf(command) {
7703
8011
  return [
7704
8012
  options.approval,
7705
8013
  options.mcpApproval,
8014
+ options.choice,
7706
8015
  ...named
7707
8016
  ];
7708
8017
  }
@@ -7849,7 +8158,7 @@ async function askLine(streams, question) {
7849
8158
  }
7850
8159
  }
7851
8160
  //#endregion
7852
- //#region ../core/dist/tool-arguments-Cx3Of7xZ.mjs
8161
+ //#region ../core/dist/tool-arguments-5aAzuZnA.mjs
7853
8162
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
7854
8163
  const TARGET = "draft-2020-12";
7855
8164
  /**
@@ -7867,7 +8176,7 @@ const TARGET = "draft-2020-12";
7867
8176
  * in, because what it asks the registry with is each server's own — and WhatsApp's is nothing at all. A call it
7868
8177
  * answers reaches nothing, as a refused one does.
7869
8178
  */
7870
- function strictToolArguments(server, refuse, gate) {
8179
+ function strictToolArguments(server, refuse, gate, retired = {}) {
7871
8180
  const target = server;
7872
8181
  if (typeof target.registerTool !== "function") throw new Error("strictToolArguments needs an MCP server: this has no registerTool");
7873
8182
  if (target[APPLIED]) return;
@@ -7883,7 +8192,7 @@ function strictToolArguments(server, refuse, gate) {
7883
8192
  inputSchema: passThrough(schema)
7884
8193
  }, async (input, context) => {
7885
8194
  const parsed = await schema.safeParseAsync(input ?? {});
7886
- if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues));
8195
+ if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
7887
8196
  const stopped = gate ? await gate(name, parsed.data) : null;
7888
8197
  if (stopped !== null) return stopped;
7889
8198
  return declared ? handler(parsed.data, context) : handler(context);
@@ -7927,12 +8236,22 @@ function passThrough(schema) {
7927
8236
  } };
7928
8237
  }
7929
8238
  /** A USAGE refusal naming what was wrong and what the tool takes. */
7930
- function refusal(tool, published, input, issues) {
8239
+ function refusal(tool, published, input, issues, retired) {
7931
8240
  const properties = published.properties ?? {};
7932
8241
  const takes = Object.keys(properties);
7933
8242
  const required = new Set(published.required ?? []);
7934
8243
  const signature = takes.length === 0 ? `${tool} takes no arguments.` : `${tool} takes ${spoken(takes.map((key) => required.has(key) ? `\`${key}\` (required)` : `\`${key}\``), "and")}.`;
7935
8244
  const unknown = issues.flatMap((issue) => issue.code === "unrecognized_keys" && issue.path.length === 0 ? issue.keys : []);
8245
+ const removed = unknown.filter((key) => Object.hasOwn(retired, key));
8246
+ if (removed.length > 0) return new CommsError("USAGE", `${tool} no longer takes ${spoken(removed.map((key) => `\`${key}\``), "or")}`, {
8247
+ hint: removed.map((key) => retired[key]).join(" "),
8248
+ details: {
8249
+ tool,
8250
+ retired: removed,
8251
+ unknown,
8252
+ takes
8253
+ }
8254
+ });
7936
8255
  if (unknown.length > 0) return new CommsError("USAGE", `${tool} does not take ${spoken(unknown.map((key) => `\`${key}\``), "or")}`, {
7937
8256
  hint: takes.length === 0 ? signature : `${signature} Leave out anything else.`,
7938
8257
  details: {
@@ -17629,7 +17948,7 @@ strictObject({
17629
17948
  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`);
17630
17949
  }
17631
17950
  });
17632
- const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
17951
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
17633
17952
  const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
17634
17953
  /** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
17635
17954
  function truncateBytes(value, maxBytes) {
@@ -17653,7 +17972,7 @@ function safeFilename(name, maxBytes = 255, fallback = "attachment") {
17653
17972
  cleaned = cleaned.replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
17654
17973
  if (cleaned === "") cleaned = fallback;
17655
17974
  const extension = extname(cleaned);
17656
- if (WINDOWS_RESERVED.test(cleaned.split(".")[0] ?? cleaned)) cleaned = `_${cleaned}`;
17975
+ if (WINDOWS_RESERVED.test((cleaned.split(".")[0] ?? cleaned).trimEnd())) cleaned = `_${cleaned}`;
17657
17976
  if (Buffer.byteLength(cleaned, "utf8") <= maxBytes) return cleaned;
17658
17977
  const keptExtension = Buffer.byteLength(extension, "utf8") < maxBytes / 2 ? extension : "";
17659
17978
  return truncateBytes(keptExtension ? cleaned.slice(0, -keptExtension.length) : cleaned, maxBytes - Buffer.byteLength(keptExtension, "utf8")) + keptExtension;
@@ -17708,7 +18027,7 @@ async function createUniqueFile(directory, filename) {
17708
18027
  throw error;
17709
18028
  }
17710
18029
  }
17711
- throw new CommsError("BAD_DATA", `could not find a free file name for ${filename} in ${directory}`);
18030
+ throw new CommsError("BAD_DATA", `could not find a free file name in ${directory}: the name and every numbered one after it are taken`);
17712
18031
  }
17713
18032
  /**
17714
18033
  * Default places attachments may never be read from, whatever the allowed roots say. `~/.*` means every dot-entry
@@ -17749,7 +18068,7 @@ async function checkAttachable(path, policy) {
17749
18068
  throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
17750
18069
  }
17751
18070
  if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
17752
- if (!(await Promise.all(policy.roots.map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Move the file into an allowed folder, or add its folder to defaults.attachRoots with the CLI." });
18071
+ if (!(await Promise.all(policy.roots.map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: "Copy the file under your home folder — not into one of its hidden folders — and name the copy instead." });
17753
18072
  const name = basename(real);
17754
18073
  const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
17755
18074
  for (const entry of policy.deny) {
@@ -1,4 +1,4 @@
1
- import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as checkForUpdates, I as CommsError, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, W as changeToolResult, X as gatedChange, _ as runDoctor, _t as VERSION, b as listAccounts, c as downloadReceived, ct as toCommsError, d as listReceived, dt as updateToolGate, f as listScheduled, g as showSentEmail, h as showReceived, i as prepareSend, j as MODES, k as ResendContext, l as getMetrics, m as listSuppressions, n as executeSend, o as sendStatus, p as listSentEmails, s as cancelScheduledChange, tt as refuseUnclaimedApproval, u as listDomains, w as policyReport } from "./send-D9aAM9kv.mjs";
1
+ import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as checkForUpdates, I as CommsError, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, W as changeToolResult, X as gatedChange, _ as runDoctor, _t as VERSION, b as listAccounts, c as downloadReceived, ct as toCommsError, d as listReceived, dt as updateToolGate, f as listScheduled, g as showSentEmail, h as showReceived, i as prepareSend, j as MODES, k as ResendContext, l as getMetrics, m as listSuppressions, n as executeSend, o as sendStatus, p as listSentEmails, s as cancelScheduledChange, tt as refuseUnclaimedApproval, u as listDomains, w as policyReport } from "./send-CgN2O8kG.mjs";
2
2
  import { S as union, c as boolean, g as object, h as number, s as array, x as string } from "./schemas-DiGKNDNq.mjs";
3
3
  import { t as McpServer } from "./mcp-DXXb3Vv3-Co5BzjYe.mjs";
4
4
  //#region src/mcp/server.ts
@@ -1,4 +1,4 @@
1
- import { t as createResendMcpServer } from "./server-Cr9ZXT1i.mjs";
1
+ import { t as createResendMcpServer } from "./server-DYhcD72L.mjs";
2
2
  //#region src/mcp/stdio-entry.ts
3
3
  /**
4
4
  * Starting the stdio server. On stdio, **stdout is the protocol**: one stray `console.log` corrupts the stream, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcomms/resend",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Resend for coding agents: read domains, sent and received mail, metrics and suppressions, and send mail that nothing sends without a person's approval of exactly what goes out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "@modelcontextprotocol/server": "^2.0.0",
39
39
  "commander": "^15.0.0",
40
40
  "zod": "^4.6.5",
41
- "@agentcomms/core": "0.9.0"
41
+ "@agentcomms/core": "0.11.0"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public"
@@ -94,5 +94,5 @@
94
94
  "typecheck": "tsc -p tsconfig.json",
95
95
  "test": "node --experimental-strip-types --test --test-timeout=600000 \"test/**/*.test.ts\""
96
96
  },
97
- "gitHead": "7db27256fda2e40fcc2a4958c50bf77bff871ef1"
97
+ "gitHead": "bcc1392f8380dbf523a910a87de652a18faead6f"
98
98
  }