@agentcomms/resend 0.12.1 → 0.12.2
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 +3 -3
- package/dist/index.d.mts +20 -1
- package/dist/index.mjs +2 -2
- package/dist/{send-W8SmlF7p.mjs → send-BlDpMIlS.mjs} +459 -152
- package/dist/{server-ElSFoKeD.mjs → server-B-ubuD7Z.mjs} +1 -1
- package/dist/{stdio-entry-h_wAVp9z.mjs → stdio-entry-Cibag7m5.mjs} +1 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as installExitStatus, B as agentMarker, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approvalsOf, I as CommsError, J as commandPathOf, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as gatedChangeAtTerminal, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as approveChangeAtTerminal, V as approvalKind, W as canPrompt, X as exemptFromUpdateGate, Y as escapeForDisplay, _ as runDoctor, a as revokeSendApproval, at as runCommand, b as listAccounts, c as downloadReceived, ct as serverPruneChange, d as listReceived, dt as truncateDisplay, et as openCore, f as listScheduled, ft as updateGateAtTerminal, g as showSentEmail, h as showReceived, i as prepareSend, it as renderPrune, k as ResendContext, l as getMetrics, lt as terminalUpdateHooks, m as listSuppressions, mt as writeResult, n as executeSend, nt as refuseUnclaimedApproval, o as sendStatus, ot as runUpdateCheckChild, p as listSentEmails, q as colorEnabled, r as finishSendApproval, rt as renderInstall, s as cancelScheduledChange, st as serverInstallChange, t as beginSendApproval, tt as paint, u as listDomains, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, yt as VERSION, z as UPDATE_CHECK_CHILD_COMMAND } from "./send-
|
|
2
|
+
import { $ as installExitStatus, B as agentMarker, C as policyChange, D as sendPolicyOf, E as removeAccountChange, F as CHANNEL_SERVERS, H as approvalsOf, I as CommsError, J as commandPathOf, L as EXIT_CODES, N as checkNewName, O as showAccount, Q as gatedChangeAtTerminal, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, U as approveChangeAtTerminal, V as approvalKind, W as canPrompt, X as exemptFromUpdateGate, Y as escapeForDisplay, _ as runDoctor, a as revokeSendApproval, at as runCommand, b as listAccounts, c as downloadReceived, ct as serverPruneChange, d as listReceived, dt as truncateDisplay, et as openCore, f as listScheduled, ft as updateGateAtTerminal, g as showSentEmail, h as showReceived, i as prepareSend, it as renderPrune, k as ResendContext, l as getMetrics, lt as terminalUpdateHooks, m as listSuppressions, mt as writeResult, n as executeSend, nt as refuseUnclaimedApproval, o as sendStatus, ot as runUpdateCheckChild, p as listSentEmails, q as colorEnabled, r as finishSendApproval, rt as renderInstall, s as cancelScheduledChange, st as serverInstallChange, t as beginSendApproval, tt as paint, u as listDomains, v as addAccountChange, w as policyReport, x as modeOf, y as inspectKey, yt as VERSION, z as UPDATE_CHECK_CHILD_COMMAND } from "./send-BlDpMIlS.mjs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
-
import childProcess from "node:child_process";
|
|
6
5
|
import { createInterface } from "node:readline/promises";
|
|
7
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
|
+
import childProcess from "node:child_process";
|
|
8
8
|
import process$1 from "node:process";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
10
|
import { Writable } from "node:stream";
|
|
@@ -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-
|
|
3408
|
+
const { startResendStdioServer } = await import("./stdio-entry-Cibag7m5.mjs");
|
|
3409
3409
|
await startResendStdioServer({
|
|
3410
3410
|
...deps,
|
|
3411
3411
|
env,
|
package/dist/index.d.mts
CHANGED
|
@@ -11509,7 +11509,7 @@ type RegisteredPrompt = {
|
|
|
11509
11509
|
remove(): void;
|
|
11510
11510
|
};
|
|
11511
11511
|
//#endregion
|
|
11512
|
-
//#region ../core/dist/
|
|
11512
|
+
//#region ../core/dist/change-flow-C4tw0Usg.d.mts
|
|
11513
11513
|
type SendPolicy = "chat" | "confirm" | "never";
|
|
11514
11514
|
/**
|
|
11515
11515
|
* How a loosening of the configuration is approved: `chat` — the person says yes in the conversation and the agent
|
|
@@ -12006,6 +12006,17 @@ interface ClaimOptions {
|
|
|
12006
12006
|
* names no product at all rather than the wrong one.
|
|
12007
12007
|
*/
|
|
12008
12008
|
pendingHint?: string | undefined;
|
|
12009
|
+
/**
|
|
12010
|
+
* The caller's cancellation — an MCP request's signal — asked under the record's lock, immediately before the claim
|
|
12011
|
+
* would change the record.
|
|
12012
|
+
*
|
|
12013
|
+
* A claim can wait: for the lock, while another process holds it. A caller that looked at its signal before calling
|
|
12014
|
+
* and found it clear could be cancelled during that wait, and the claim then went through all the same, spending an
|
|
12015
|
+
* approval on a call nobody was waiting for any more — whose outcome its caller then had to record as a failure,
|
|
12016
|
+
* since a claim cannot be put back. Asked here, a cancellation that lands before the record changes leaves it exactly
|
|
12017
|
+
* as it was, for the same call made again; one that lands after is the caller's to handle, as it always was.
|
|
12018
|
+
*/
|
|
12019
|
+
signal?: AbortSignal | undefined;
|
|
12009
12020
|
}
|
|
12010
12021
|
declare class ApprovalStore {
|
|
12011
12022
|
#private;
|
|
@@ -12099,10 +12110,14 @@ declare class ApprovalStore {
|
|
|
12099
12110
|
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
12100
12111
|
* 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
12112
|
* expired is refused as an approval in that state is.
|
|
12113
|
+
*
|
|
12114
|
+
* `options.signal` is the download's cancellation, asked under the lock as a send's is (`ClaimOptions.signal`): a
|
|
12115
|
+
* download cancelled while this waited saves nothing and leaves the question open, the answer still unused.
|
|
12102
12116
|
*/
|
|
12103
12117
|
claimForDownload(approvalId: string, live: DownloadRequest, options?: {
|
|
12104
12118
|
policy?: ChangePolicy | undefined;
|
|
12105
12119
|
pendingHint?: string | undefined;
|
|
12120
|
+
signal?: AbortSignal | undefined;
|
|
12106
12121
|
}): Promise<ApprovalRecord & {
|
|
12107
12122
|
download: DownloadBinding;
|
|
12108
12123
|
}>;
|
|
@@ -12398,6 +12413,10 @@ interface ThrottleOptions {
|
|
|
12398
12413
|
sleep?: (ms: number) => Promise<void>;
|
|
12399
12414
|
/** Tests shorten this; nothing a person or an agent passes can. */
|
|
12400
12415
|
intervalMs?: number;
|
|
12416
|
+
/** How long one holder of the lock may keep everyone else waiting. Tests shorten this too. */
|
|
12417
|
+
lockTimeoutMs?: number;
|
|
12418
|
+
/** How long a request may wait for the lock in all: `LOCK_MAX_WAIT_MS`. Tests shorten this too. */
|
|
12419
|
+
lockMaxWaitMs?: number;
|
|
12401
12420
|
}
|
|
12402
12421
|
//#endregion
|
|
12403
12422
|
//#region src/context.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { _t as RESEND_DOWNLOAD_ORIGIN, gt as RESEND_API_ORIGIN, ht as REFUSED, vt as ROUTES, yt as VERSION } from "./send-
|
|
2
|
-
import { t as createResendMcpServer } from "./server-
|
|
1
|
+
import { _t as RESEND_DOWNLOAD_ORIGIN, gt as RESEND_API_ORIGIN, ht as REFUSED, vt as ROUTES, yt as VERSION } from "./send-BlDpMIlS.mjs";
|
|
2
|
+
import { t as createResendMcpServer } from "./server-B-ubuD7Z.mjs";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* The library entry of `@agentcomms/resend`.
|
|
@@ -6,13 +6,13 @@ import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto
|
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
7
|
import { homedir, platform, userInfo } from "node:os";
|
|
8
8
|
import { constants as constants$1, realpathSync } from "node:fs";
|
|
9
|
-
import { execFile, spawn } from "node:child_process";
|
|
10
9
|
import "node:readline/promises";
|
|
11
10
|
import { styleText } from "node:util";
|
|
11
|
+
import { execFile, spawn } from "node:child_process";
|
|
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.12.
|
|
15
|
+
const VERSION$1 = "0.12.2";
|
|
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-check-
|
|
190
|
+
//#region ../core/dist/update-check-B-WYPZae.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
|
|
@@ -564,9 +564,15 @@ async function takeOverStale(lockPath, staleMs) {
|
|
|
564
564
|
async function withFileLock(lockPath, fn, options = {}) {
|
|
565
565
|
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
566
566
|
const staleMs = options.staleMs ?? 3e4;
|
|
567
|
-
const
|
|
567
|
+
const started = Date.now();
|
|
568
|
+
let deadline = started + timeoutMs;
|
|
569
|
+
/** `maxWaitMs`'s end: fixed when the wait begins, whatever the lock does. */
|
|
570
|
+
const limit = options.maxWaitMs === void 0 ? Number.POSITIVE_INFINITY : started + options.maxWaitMs;
|
|
568
571
|
const token = randomBytes(12).toString("hex");
|
|
569
572
|
let lastCode = "EEXIST";
|
|
573
|
+
/** The token of the holder this caller saw last, and how many times it has seen the lock change hands. */
|
|
574
|
+
let holder;
|
|
575
|
+
let handOvers = 0;
|
|
570
576
|
await ensurePrivateDir(dirname(lockPath));
|
|
571
577
|
for (;;) try {
|
|
572
578
|
const handle = await open(lockPath, "wx", 384);
|
|
@@ -581,10 +587,20 @@ async function withFileLock(lockPath, fn, options = {}) {
|
|
|
581
587
|
const code = error.code;
|
|
582
588
|
if (!CONTENDED.has(code ?? "")) throw error;
|
|
583
589
|
lastCode = code ?? lastCode;
|
|
584
|
-
if (code === "EEXIST"
|
|
585
|
-
await
|
|
586
|
-
|
|
590
|
+
if (code === "EEXIST") {
|
|
591
|
+
const body = await readLock(lockPath);
|
|
592
|
+
if (await isStale(lockPath, body, staleMs)) {
|
|
593
|
+
await takeOverStale(lockPath, staleMs);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const seen = typeof body?.token === "string" ? body.token : void 0;
|
|
597
|
+
if (seen !== void 0 && seen !== holder) {
|
|
598
|
+
if (holder !== void 0) handOvers += 1;
|
|
599
|
+
holder = seen;
|
|
600
|
+
if (options.timeoutPerHolder) deadline = Date.now() + timeoutMs;
|
|
601
|
+
}
|
|
587
602
|
}
|
|
603
|
+
if (Date.now() > limit) throw new CommsError("LOCK_TIMEOUT", `gave up waiting for ${lockPath}: the wait hit its overall limit of ${(limit - started) / 1e3} s`, { hint: (handOvers > 0 ? `It changed hands ${handOvers} time(s) while this waited, never to this process: busy rather than stuck. Retry in a moment.` : "Retry in a moment. If it persists and no other process is running, delete the lock file.") + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
|
|
588
604
|
if (Date.now() > deadline) throw new CommsError("LOCK_TIMEOUT", `another agent-communications process is holding ${lockPath}`, { hint: `Retry in a moment. If it persists and no other process is running, delete the lock file.` + (lastCode === "EEXIST" ? "" : ` (last error: ${lastCode})`) });
|
|
589
605
|
await setTimeout$1(25 + Math.floor(Math.random() * 50));
|
|
590
606
|
}
|
|
@@ -2588,6 +2604,32 @@ function refusalFor(record) {
|
|
|
2588
2604
|
const kind = approvalKind(record);
|
|
2589
2605
|
return kind === "change" ? refuseChange : kind === "download" ? refuseDownload : refuse$1;
|
|
2590
2606
|
}
|
|
2607
|
+
/**
|
|
2608
|
+
* A claim whose caller was cancelled before it changed anything (`ClaimOptions.signal`).
|
|
2609
|
+
*
|
|
2610
|
+
* `USAGE` and `cancelled:`, the words every cancellation in this repository is reported in, with `details.reason`
|
|
2611
|
+
* saying so for a caller that branches on it — and in the record's own kind: a question is not an approval, and
|
|
2612
|
+
* nothing is sent by answering one. The record is as it was, and the hint says so.
|
|
2613
|
+
*/
|
|
2614
|
+
function cancelledClaim(record) {
|
|
2615
|
+
const kind = approvalKind(record);
|
|
2616
|
+
if (kind === "download") return new CommsError("USAGE", "cancelled: nothing was saved", {
|
|
2617
|
+
hint: "The question was not used: the same call, made again with the same answer, can still use it until it expires.",
|
|
2618
|
+
details: {
|
|
2619
|
+
choiceId: record.approvalId,
|
|
2620
|
+
state: record.state,
|
|
2621
|
+
reason: "cancelled"
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
return new CommsError("USAGE", `cancelled: ${kind === "change" ? "nothing was changed" : "nothing was sent"}`, {
|
|
2625
|
+
hint: "The approval was not used: the same call, made again, can still use it until it expires.",
|
|
2626
|
+
details: {
|
|
2627
|
+
approvalId: record.approvalId,
|
|
2628
|
+
state: record.state,
|
|
2629
|
+
reason: "cancelled"
|
|
2630
|
+
}
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2591
2633
|
/** The record as it may be shown to anyone, agents included: never the challenge hash. */
|
|
2592
2634
|
function publicView(record) {
|
|
2593
2635
|
const { challengeHash: _hidden, ...rest } = record;
|
|
@@ -2796,6 +2838,7 @@ var ApprovalStore = class {
|
|
|
2796
2838
|
const result = await this.#transition(approvalId, (current) => {
|
|
2797
2839
|
this.#requireKind(current, "send");
|
|
2798
2840
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2841
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2799
2842
|
const voidWith = (code, reason) => {
|
|
2800
2843
|
failure = {
|
|
2801
2844
|
code,
|
|
@@ -2906,6 +2949,7 @@ var ApprovalStore = class {
|
|
|
2906
2949
|
const result = await this.#transition(approvalId, (current) => {
|
|
2907
2950
|
this.#requireKind(current, "change");
|
|
2908
2951
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
2952
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
2909
2953
|
const voidWith = (reason) => {
|
|
2910
2954
|
failure = {
|
|
2911
2955
|
code: "APPROVAL_VOID",
|
|
@@ -3033,6 +3077,9 @@ var ApprovalStore = class {
|
|
|
3033
3077
|
* gave it in the conversation. Under `confirm`, only a question the person answered at a terminal or in a trusted
|
|
3034
3078
|
* form can be claimed; a pending one is refused, and left as it is, so they can still answer it. One used, voided or
|
|
3035
3079
|
* expired is refused as an approval in that state is.
|
|
3080
|
+
*
|
|
3081
|
+
* `options.signal` is the download's cancellation, asked under the lock as a send's is (`ClaimOptions.signal`): a
|
|
3082
|
+
* download cancelled while this waited saves nothing and leaves the question open, the answer still unused.
|
|
3036
3083
|
*/
|
|
3037
3084
|
async claimForDownload(approvalId, live, options = {}) {
|
|
3038
3085
|
const digest = downloadDigest(live);
|
|
@@ -3040,6 +3087,7 @@ var ApprovalStore = class {
|
|
|
3040
3087
|
const result = await this.#transition(approvalId, (current) => {
|
|
3041
3088
|
this.#requireKind(current, "download");
|
|
3042
3089
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3090
|
+
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
3043
3091
|
const voidWith = (reason) => {
|
|
3044
3092
|
failure = {
|
|
3045
3093
|
code: "APPROVAL_VOID",
|
|
@@ -3308,6 +3356,219 @@ function nameAvailable(config, kind, name, platform) {
|
|
|
3308
3356
|
}
|
|
3309
3357
|
return { ok: true };
|
|
3310
3358
|
}
|
|
3359
|
+
function okEnvelope(data) {
|
|
3360
|
+
return {
|
|
3361
|
+
ok: true,
|
|
3362
|
+
schemaVersion: 1,
|
|
3363
|
+
data
|
|
3364
|
+
};
|
|
3365
|
+
}
|
|
3366
|
+
function errorEnvelope(error) {
|
|
3367
|
+
const body = {
|
|
3368
|
+
code: error.code,
|
|
3369
|
+
message: error.message
|
|
3370
|
+
};
|
|
3371
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
3372
|
+
if (error.details !== void 0) body.details = error.details;
|
|
3373
|
+
return {
|
|
3374
|
+
ok: false,
|
|
3375
|
+
schemaVersion: 1,
|
|
3376
|
+
error: body
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
const defaultStreams = {
|
|
3380
|
+
stdout: process.stdout,
|
|
3381
|
+
stderr: process.stderr,
|
|
3382
|
+
stdin: process.stdin
|
|
3383
|
+
};
|
|
3384
|
+
function colorEnabled(env, stream, flag) {
|
|
3385
|
+
if (flag === false) return false;
|
|
3386
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
3387
|
+
if (env.TERM === "dumb") return false;
|
|
3388
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
3389
|
+
return Boolean(stream.isTTY);
|
|
3390
|
+
}
|
|
3391
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
3392
|
+
function canPrompt(env, streams, options) {
|
|
3393
|
+
if (options.json || options.noInput) return false;
|
|
3394
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
3395
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
3396
|
+
}
|
|
3397
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
3398
|
+
const AGENT_MARKERS = [
|
|
3399
|
+
"CLAUDECODE",
|
|
3400
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
3401
|
+
"CODEX_SANDBOX",
|
|
3402
|
+
"CODEX_HOME",
|
|
3403
|
+
"CURSOR_AGENT",
|
|
3404
|
+
"GEMINI_CLI",
|
|
3405
|
+
"AGENT_COMMS_AGENT"
|
|
3406
|
+
];
|
|
3407
|
+
function agentMarker(env) {
|
|
3408
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
function paint(color, format, text) {
|
|
3412
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
3416
|
+
* Every command this package prints to be run — a change to run again with its approval, a folder to take out, an
|
|
3417
|
+
* entry to register again or remove — is quoted here, so no printer quotes for a shell of its own.
|
|
3418
|
+
*
|
|
3419
|
+
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
3420
|
+
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
3421
|
+
* as quotes at all, so `'C:\Profiles\First Last\outgoing'` reached the command as two words, quote marks and all, and
|
|
3422
|
+
* PowerShell does, but escapes a quote inside them by doubling it rather than as `'\''`. A command printed on Windows
|
|
3423
|
+
* has to be safe in both, because nothing says which one it will be pasted into (CUE-306) — and safe is not enough:
|
|
3424
|
+
* the program has to receive the same words from either. Three readers stand between the line and the program. cmd.exe
|
|
3425
|
+
* hands the program the line as it is, and the program's own parser (the C runtime's, Node's) splits it. PowerShell
|
|
3426
|
+
* reads the line itself, a double-quoted word with its backslashes as plain characters, and then writes a new command
|
|
3427
|
+
* line for the program: Windows PowerShell 5.1 the old way ("Legacy" in about_Parsing, "Passing arguments to native
|
|
3428
|
+
* applications") — a word quoted only when it holds whitespace, as it is, and an empty word dropped — and PowerShell
|
|
3429
|
+
* 7.3 and later its own way ("Standard") — quoted when it must be, with every backslash before a quote doubled. For a
|
|
3430
|
+
* `.cmd` script, which is how npm installs `agentcomms`, `claude` and `codex` on Windows, PowerShell 7 goes back to the
|
|
3431
|
+
* old way, and cmd.exe then reads that new line. So on Windows:
|
|
3432
|
+
*
|
|
3433
|
+
* - A word of letters, digits and `_ + = : . / \`, with `@` and `-` anywhere but first, is left as it is: no reader
|
|
3434
|
+
* does anything with it, and a backslash is an ordinary character to all of them, at the end too. A first `@` is
|
|
3435
|
+
* splatting to PowerShell, and a `,` its array operator — two words — so a word with either is quoted. So is a word
|
|
3436
|
+
* that starts with a digit, which PowerShell may read as a number (`1kb`, `0x10`), and one that starts with `-` but
|
|
3437
|
+
* is not a plain option, which PowerShell may read as a parameter of its own and split (`-name.x`).
|
|
3438
|
+
* - A word that double quotes bring through all three readers whole goes in double quotes. Inside them cmd.exe reads
|
|
3439
|
+
* `& | < > ^ ( )` and spaces as ordinary characters, and PowerShell reads everything as ordinary but `$`, the
|
|
3440
|
+
* backtick and a double quote. What is left special in one or the other is kept out: a double quote, which ends the
|
|
3441
|
+
* quoting in both — and PowerShell takes the curly ones, `“ ” „`, for one too; `$` and the backtick, PowerShell's
|
|
3442
|
+
* expansion and escape; `%`, which cmd.exe expands as `%NAME%` before it looks at quotes at all; `!`, which it
|
|
3443
|
+
* expands as `!NAME!` inside quotes too wherever delayed expansion is on, and which then makes a `^` inside quotes an
|
|
3444
|
+
* escape; and any control or formatting character — a line break ends the command in cmd.exe even inside quotes, a
|
|
3445
|
+
* tab pasted into cmd.exe can complete a file name, and a right-to-left override shows a line other than the one
|
|
3446
|
+
* that runs. Three more cannot come through: an empty word, which Windows PowerShell drops; a word ending in a
|
|
3447
|
+
* backslash, which the C runtime reads with the closing quote as `\"` — and doubled for it, PowerShell 7 passes both
|
|
3448
|
+
* backslashes on where cmd.exe and Windows PowerShell pass one; and a word with `& | < > ^ ( )` but no whitespace,
|
|
3449
|
+
* which PowerShell passes to a `.cmd` script unquoted, for cmd.exe to run `&whoami` from or to split at `|`.
|
|
3450
|
+
* - With any other word in it, no line is printed at all (`line` is null), and the printers show the command's words
|
|
3451
|
+
* as JSON instead, saying it has to be typed. Not a line with the word left out: a placeholder in its place was
|
|
3452
|
+
* still a command that ran — `claude mcp remove NAME` removed whatever entry was called `NAME`, and an install
|
|
3453
|
+
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
3454
|
+
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
3455
|
+
*
|
|
3456
|
+
* Everywhere else there is always a line: single quotes make any word safe.
|
|
3457
|
+
*/
|
|
3458
|
+
function shellCommand(words, platform = process.platform) {
|
|
3459
|
+
if (platform !== "win32") return {
|
|
3460
|
+
words,
|
|
3461
|
+
line: words.map(posixShellWord).join(" "),
|
|
3462
|
+
platform
|
|
3463
|
+
};
|
|
3464
|
+
const printed = words.map(windowsShellWord);
|
|
3465
|
+
return {
|
|
3466
|
+
words,
|
|
3467
|
+
line: printed.includes(null) ? null : printed.join(" "),
|
|
3468
|
+
platform
|
|
3469
|
+
};
|
|
3470
|
+
}
|
|
3471
|
+
/** The same command with more words at its end — the approval it is to be run again with — for the same shell. */
|
|
3472
|
+
function withWords(command, ...more) {
|
|
3473
|
+
return shellCommand([...command.words, ...more], command.platform);
|
|
3474
|
+
}
|
|
3475
|
+
function posixShellWord(word) {
|
|
3476
|
+
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
3480
|
+
* does (see `shellCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
3481
|
+
*/
|
|
3482
|
+
function windowsShellWord(word) {
|
|
3483
|
+
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
3484
|
+
if (word === "" || word.endsWith("\\")) return null;
|
|
3485
|
+
if (/["$`%!\u201C-\u201E]|[\p{C}\p{Zl}\p{Zp}]/u.test(word)) return null;
|
|
3486
|
+
if (/[&|<>^()]/.test(word) && !/\s/.test(word)) return null;
|
|
3487
|
+
return `"${word}"`;
|
|
3488
|
+
}
|
|
3489
|
+
/**
|
|
3490
|
+
* The words of a command as a JSON array, for a command that cannot be printed as a line: something to read, and to
|
|
3491
|
+
* parse, and nothing to run.
|
|
3492
|
+
*
|
|
3493
|
+
* Pasted into either Windows shell by mistake, it runs nothing. PowerShell refuses it before running anything: a `[`
|
|
3494
|
+
* opens a type name, and a quoted string is none. cmd.exe looks for a program called `["agentcomms"` or the like, and
|
|
3495
|
+
* finds none — and nothing in the line is anything else to it: every character it acts on, inside quotes or out (`%`,
|
|
3496
|
+
* `!`), is written as a `\u` escape, and so is every double quote inside a word, so the quotes cmd.exe sees are the
|
|
3497
|
+
* JSON's own, in pairs, and `&`, `|`, `<`, `>`, `^` and the parentheses are only ever inside them, where it reads them
|
|
3498
|
+
* as characters. `$` and the backtick are escaped too, so that PowerShell would expand nothing even if it read on, and
|
|
3499
|
+
* so is everything outside printable ASCII — a line break, a curly quote, a right-to-left override. A backslash is
|
|
3500
|
+
* doubled, as JSON has it.
|
|
3501
|
+
*/
|
|
3502
|
+
function commandAsJson(words) {
|
|
3503
|
+
const word = (text) => {
|
|
3504
|
+
let json = "";
|
|
3505
|
+
for (let index = 0; index < text.length; index++) {
|
|
3506
|
+
const code = text.charCodeAt(index);
|
|
3507
|
+
const char = text[index];
|
|
3508
|
+
if (char === "\\") json += "\\\\";
|
|
3509
|
+
else if (code >= 32 && code <= 126 && !"\"%!$`".includes(char)) json += char;
|
|
3510
|
+
else json += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
3511
|
+
}
|
|
3512
|
+
return `"${json}"`;
|
|
3513
|
+
};
|
|
3514
|
+
return `[${words.map(word).join(",")}]`;
|
|
3515
|
+
}
|
|
3516
|
+
/** What is said beside a command shown as JSON. */
|
|
3517
|
+
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
3518
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
3519
|
+
function inlineCommand(command) {
|
|
3520
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${TO_TYPE})` : `\`${command.line}\``;
|
|
3521
|
+
}
|
|
3522
|
+
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
3523
|
+
function commandText(command) {
|
|
3524
|
+
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
3525
|
+
}
|
|
3526
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
3527
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
3528
|
+
if (options.json) {
|
|
3529
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
const text = human(data);
|
|
3533
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
3534
|
+
}
|
|
3535
|
+
/** Writes an error and returns the exit code to use. */
|
|
3536
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
3537
|
+
const commsError = toCommsError(error);
|
|
3538
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
3539
|
+
else {
|
|
3540
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
3541
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
3542
|
+
}
|
|
3543
|
+
return commsError.exitCode;
|
|
3544
|
+
}
|
|
3545
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
3546
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
3547
|
+
try {
|
|
3548
|
+
await body();
|
|
3549
|
+
return EXIT_CODES.OK;
|
|
3550
|
+
} catch (error) {
|
|
3551
|
+
return writeError(error, options, streams);
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
3556
|
+
* whose code is not its own to make up.
|
|
3557
|
+
*
|
|
3558
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
3559
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
3560
|
+
*/
|
|
3561
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
3562
|
+
const marker = agentMarker(env);
|
|
3563
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
3564
|
+
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
3565
|
+
details: { marker }
|
|
3566
|
+
});
|
|
3567
|
+
const prompting = {};
|
|
3568
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
3569
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
3570
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
3571
|
+
}
|
|
3311
3572
|
/**
|
|
3312
3573
|
* Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
|
|
3313
3574
|
*
|
|
@@ -4374,13 +4635,13 @@ function snippetFor(client, name, entry) {
|
|
|
4374
4635
|
* Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
|
|
4375
4636
|
* replaced.
|
|
4376
4637
|
*/
|
|
4377
|
-
function claimName(product, options, name, existing) {
|
|
4638
|
+
function claimName(product, options, name, existing, platform) {
|
|
4378
4639
|
const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
|
|
4379
4640
|
const foreign = taken.find((server) => !isProductServer(server, product));
|
|
4380
4641
|
if (foreign) {
|
|
4381
4642
|
const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
|
|
4382
4643
|
const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
|
|
4383
|
-
throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name:
|
|
4644
|
+
throw new CommsError("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, { hint: `Register this one under another name: ${inlineCommand(installCommand$1(product, options, other, [], platform))}. --force does not replace a server this did not install.` });
|
|
4384
4645
|
}
|
|
4385
4646
|
if (taken.length > 0 && !options.force) {
|
|
4386
4647
|
for (const pin of PIN_OPTIONS) {
|
|
@@ -4388,14 +4649,14 @@ function claimName(product, options, name, existing) {
|
|
|
4388
4649
|
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
4389
4650
|
if (!wanted || !served || served === wanted) continue;
|
|
4390
4651
|
const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
|
|
4391
|
-
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name:
|
|
4652
|
+
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${inlineCommand(installCommand$1(product, options, second === name ? `${name}-2` : second, [], platform))}.` });
|
|
4392
4653
|
}
|
|
4393
4654
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
4394
4655
|
const again = {
|
|
4395
4656
|
...keepNarrowing(product, options, taken).options,
|
|
4396
4657
|
launcher: options.launcher ?? (npx ? "npx" : void 0)
|
|
4397
4658
|
};
|
|
4398
|
-
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `Pass --force to replace it — that is how an upgrade reaches a client:
|
|
4659
|
+
throw new CommsError("CONFIG", `${options.client} already has this server registered as "${name}"`, { hint: `Pass --force to replace it — that is how an upgrade reaches a client: ${inlineCommand(installCommand$1(product, again, name, ["--force"], platform))}.` });
|
|
4399
4660
|
}
|
|
4400
4661
|
return taken;
|
|
4401
4662
|
}
|
|
@@ -4438,8 +4699,11 @@ function keepNarrowing(product, options, previous) {
|
|
|
4438
4699
|
* workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
|
|
4439
4700
|
* rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
|
|
4440
4701
|
* the pin and the narrowing, so they are repeated as they are.
|
|
4702
|
+
*
|
|
4703
|
+
* A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
|
|
4704
|
+
* command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
|
|
4441
4705
|
*/
|
|
4442
|
-
function installCommand$1(product, options, name, extra
|
|
4706
|
+
function installCommand$1(product, options, name, extra, platform) {
|
|
4443
4707
|
const words = [
|
|
4444
4708
|
product.binary,
|
|
4445
4709
|
"mcp",
|
|
@@ -4451,7 +4715,7 @@ function installCommand$1(product, options, name, extra = []) {
|
|
|
4451
4715
|
words.push(...product.serverArgs(options));
|
|
4452
4716
|
if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
|
|
4453
4717
|
words.push(...extra);
|
|
4454
|
-
return words
|
|
4718
|
+
return shellCommand(words, platform);
|
|
4455
4719
|
}
|
|
4456
4720
|
/**
|
|
4457
4721
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
@@ -4494,8 +4758,13 @@ function capture$1(env, command, args) {
|
|
|
4494
4758
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4495
4759
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4496
4760
|
*/
|
|
4497
|
-
async function codexRegistration(env, binary, name, path) {
|
|
4498
|
-
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with
|
|
4761
|
+
async function codexRegistration(env, binary, name, path, platform) {
|
|
4762
|
+
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${inlineCommand(shellCommand([
|
|
4763
|
+
"codex",
|
|
4764
|
+
"mcp",
|
|
4765
|
+
"get",
|
|
4766
|
+
name
|
|
4767
|
+
], platform))}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4499
4768
|
let answer;
|
|
4500
4769
|
try {
|
|
4501
4770
|
answer = await capture$1(env, binary, [
|
|
@@ -4598,10 +4867,10 @@ async function preflightInstall(context, product, options) {
|
|
|
4598
4867
|
const blind = scan.unreadable.find((file) => file.path === own);
|
|
4599
4868
|
if (blind) throw new CommsError("CONFIG", `${blind.path} could not be read (${blind.reason}), so what is already registered there cannot be checked; nothing was written`, { hint: "Fix the file, or add the entry by hand: `--print` shows it without writing anything." });
|
|
4600
4869
|
}
|
|
4601
|
-
let previous = writes ? claimName(product, options, name, existing) : [];
|
|
4870
|
+
let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
|
|
4602
4871
|
if (writes && binary && options.client === "codex") {
|
|
4603
|
-
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex");
|
|
4604
|
-
if (reported) previous = claimName(product, options, name, [reported]);
|
|
4872
|
+
const reported = await codexRegistration(context.env, binary, name, configPath ?? "codex", context.platform);
|
|
4873
|
+
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
4605
4874
|
}
|
|
4606
4875
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
4607
4876
|
const node = await resolveNode(context.env);
|
|
@@ -4728,7 +4997,19 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4728
4997
|
const { cliName, binary, own, configPath, writes } = target;
|
|
4729
4998
|
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client)) ?? []).filter(Boolean);
|
|
4730
4999
|
if (kept.length > 0) {
|
|
4731
|
-
const removal = options.client === "claude-code" ?
|
|
5000
|
+
const removal = options.client === "claude-code" ? inlineCommand(shellCommand([
|
|
5001
|
+
"claude",
|
|
5002
|
+
"mcp",
|
|
5003
|
+
"remove",
|
|
5004
|
+
name,
|
|
5005
|
+
"--scope",
|
|
5006
|
+
"user"
|
|
5007
|
+
], context.platform)) : options.client === "codex" ? inlineCommand(shellCommand([
|
|
5008
|
+
"codex",
|
|
5009
|
+
"mcp",
|
|
5010
|
+
"remove",
|
|
5011
|
+
name
|
|
5012
|
+
], context.platform)) : `delete "${name}" from ${configPath}`;
|
|
4732
5013
|
warnings.push(`Kept ${kept.join(" ")} from the "${name}" entry this replaced, because this install did not say otherwise. To register it wider on purpose, remove that entry first (${removal}), then install without them.`);
|
|
4733
5014
|
}
|
|
4734
5015
|
if (!writes) {
|
|
@@ -4823,7 +5104,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4823
5104
|
restored = false;
|
|
4824
5105
|
}
|
|
4825
5106
|
throw new CommsError("CONFIG", restored ? `could not register "${name}"; the previous entry was put back` : `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
4826
|
-
hint: restored ? "Check the client is not running, then try again." : `Re-register it with
|
|
5107
|
+
hint: restored ? "Check the client is not running, then try again." : `Re-register it with ${inlineCommand(installCommand$1(product, effective, name, [], context.platform))}. The old entry is in ${backupPath}.`,
|
|
4827
5108
|
cause: error
|
|
4828
5109
|
});
|
|
4829
5110
|
}
|
|
@@ -4832,7 +5113,17 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
4832
5113
|
} catch (error) {
|
|
4833
5114
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
4834
5115
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
4835
|
-
hint: `Look at it with
|
|
5116
|
+
hint: `Look at it with ${inlineCommand(shellCommand([
|
|
5117
|
+
cliName,
|
|
5118
|
+
"mcp",
|
|
5119
|
+
"get",
|
|
5120
|
+
name
|
|
5121
|
+
], context.platform))}. If it is an older ${product.binary}, remove it with ${inlineCommand(shellCommand([
|
|
5122
|
+
cliName,
|
|
5123
|
+
"mcp",
|
|
5124
|
+
"remove",
|
|
5125
|
+
name
|
|
5126
|
+
], context.platform))} and run this again; if not, choose another --name.`,
|
|
4836
5127
|
cause: error
|
|
4837
5128
|
});
|
|
4838
5129
|
throw error;
|
|
@@ -5168,19 +5459,32 @@ function rivalPatterns(packages) {
|
|
|
5168
5459
|
function escapeRegExp$2(text) {
|
|
5169
5460
|
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
5170
5461
|
}
|
|
5171
|
-
|
|
5462
|
+
/**
|
|
5463
|
+
* The command that removes a registered server, in its client's own terms — the name as the client's file has it,
|
|
5464
|
+
* which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
|
|
5465
|
+
* (`shellCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
|
|
5466
|
+
*/
|
|
5467
|
+
function removalCommand(client, name, platform) {
|
|
5468
|
+
return commandText(shellCommand([
|
|
5469
|
+
client,
|
|
5470
|
+
"mcp",
|
|
5471
|
+
"remove",
|
|
5472
|
+
name
|
|
5473
|
+
], platform));
|
|
5474
|
+
}
|
|
5475
|
+
function gmailRemoval({ client, name: server, path, scope }, platform) {
|
|
5172
5476
|
if (scope === "project") return `remove "${server}" from the project entry in ${path} by hand`;
|
|
5173
5477
|
switch (client) {
|
|
5174
|
-
case "claude-code": return
|
|
5175
|
-
case "codex": return
|
|
5478
|
+
case "claude-code": return removalCommand("claude", server, platform);
|
|
5479
|
+
case "codex": return removalCommand("codex", server, platform);
|
|
5176
5480
|
default: return `remove the "${server}" entry from ${path}, then restart ${client}`;
|
|
5177
5481
|
}
|
|
5178
5482
|
}
|
|
5179
5483
|
/**
|
|
5180
5484
|
* Registered servers that are one of `packages` — another server for the same service whose send tools no approval
|
|
5181
|
-
* step gates — each with why it matters and how to remove it.
|
|
5485
|
+
* step gates — each with why it matters and how to remove it. `platform` is the shell the removal is quoted for.
|
|
5182
5486
|
*/
|
|
5183
|
-
function findRivalPackageServers(servers, packages) {
|
|
5487
|
+
function findRivalPackageServers(servers, packages, platform = process.platform) {
|
|
5184
5488
|
const patterns = rivalPatterns(packages);
|
|
5185
5489
|
const findings = [];
|
|
5186
5490
|
for (const server of servers) {
|
|
@@ -5191,7 +5495,7 @@ function findRivalPackageServers(servers, packages) {
|
|
|
5191
5495
|
...server,
|
|
5192
5496
|
packageName: known.name,
|
|
5193
5497
|
reason: `${known.name} exposes send tools that no approval step gates`,
|
|
5194
|
-
removal: gmailRemoval(server)
|
|
5498
|
+
removal: gmailRemoval(server, platform)
|
|
5195
5499
|
});
|
|
5196
5500
|
}
|
|
5197
5501
|
return findings;
|
|
@@ -5317,107 +5621,6 @@ function requireChannelManifest(channel) {
|
|
|
5317
5621
|
if (manifest === void 0) throw notAChannel(channel);
|
|
5318
5622
|
return manifest;
|
|
5319
5623
|
}
|
|
5320
|
-
function okEnvelope(data) {
|
|
5321
|
-
return {
|
|
5322
|
-
ok: true,
|
|
5323
|
-
schemaVersion: 1,
|
|
5324
|
-
data
|
|
5325
|
-
};
|
|
5326
|
-
}
|
|
5327
|
-
function errorEnvelope(error) {
|
|
5328
|
-
const body = {
|
|
5329
|
-
code: error.code,
|
|
5330
|
-
message: error.message
|
|
5331
|
-
};
|
|
5332
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
5333
|
-
if (error.details !== void 0) body.details = error.details;
|
|
5334
|
-
return {
|
|
5335
|
-
ok: false,
|
|
5336
|
-
schemaVersion: 1,
|
|
5337
|
-
error: body
|
|
5338
|
-
};
|
|
5339
|
-
}
|
|
5340
|
-
const defaultStreams = {
|
|
5341
|
-
stdout: process.stdout,
|
|
5342
|
-
stderr: process.stderr,
|
|
5343
|
-
stdin: process.stdin
|
|
5344
|
-
};
|
|
5345
|
-
function colorEnabled(env, stream, flag) {
|
|
5346
|
-
if (flag === false) return false;
|
|
5347
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
5348
|
-
if (env.TERM === "dumb") return false;
|
|
5349
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
5350
|
-
return Boolean(stream.isTTY);
|
|
5351
|
-
}
|
|
5352
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
5353
|
-
function canPrompt(env, streams, options) {
|
|
5354
|
-
if (options.json || options.noInput) return false;
|
|
5355
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
5356
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
5357
|
-
}
|
|
5358
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
5359
|
-
const AGENT_MARKERS = [
|
|
5360
|
-
"CLAUDECODE",
|
|
5361
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
5362
|
-
"CODEX_SANDBOX",
|
|
5363
|
-
"CODEX_HOME",
|
|
5364
|
-
"CURSOR_AGENT",
|
|
5365
|
-
"GEMINI_CLI",
|
|
5366
|
-
"AGENT_COMMS_AGENT"
|
|
5367
|
-
];
|
|
5368
|
-
function agentMarker(env) {
|
|
5369
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
5370
|
-
return null;
|
|
5371
|
-
}
|
|
5372
|
-
function paint(color, format, text) {
|
|
5373
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
5374
|
-
}
|
|
5375
|
-
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
5376
|
-
function writeResult(data, options, human, streams = defaultStreams) {
|
|
5377
|
-
if (options.json) {
|
|
5378
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
5379
|
-
return;
|
|
5380
|
-
}
|
|
5381
|
-
const text = human(data);
|
|
5382
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
5383
|
-
}
|
|
5384
|
-
/** Writes an error and returns the exit code to use. */
|
|
5385
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
5386
|
-
const commsError = toCommsError(error);
|
|
5387
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
5388
|
-
else {
|
|
5389
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
5390
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
5391
|
-
}
|
|
5392
|
-
return commsError.exitCode;
|
|
5393
|
-
}
|
|
5394
|
-
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
5395
|
-
async function runCommand(options, body, streams = defaultStreams) {
|
|
5396
|
-
try {
|
|
5397
|
-
await body();
|
|
5398
|
-
return EXIT_CODES.OK;
|
|
5399
|
-
} catch (error) {
|
|
5400
|
-
return writeError(error, options, streams);
|
|
5401
|
-
}
|
|
5402
|
-
}
|
|
5403
|
-
/**
|
|
5404
|
-
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
5405
|
-
* whose code is not its own to make up.
|
|
5406
|
-
*
|
|
5407
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
5408
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
5409
|
-
*/
|
|
5410
|
-
function refuseUnlessPerson(env, streams, gate) {
|
|
5411
|
-
const marker = agentMarker(env);
|
|
5412
|
-
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
5413
|
-
hint: `Ask the user to run \`${gate.command}\` in their own terminal.`,
|
|
5414
|
-
details: { marker }
|
|
5415
|
-
});
|
|
5416
|
-
const prompting = {};
|
|
5417
|
-
if (gate.json !== void 0) prompting.json = gate.json;
|
|
5418
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
5419
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
5420
|
-
}
|
|
5421
5624
|
/**
|
|
5422
5625
|
* One renderer for every surface a send preview is shown on — the chat, an elicitation form, a terminal. Text the
|
|
5423
5626
|
* draft's author controls (body, subject, display names, file names, link text) must not be able to change how the
|
|
@@ -5978,21 +6181,25 @@ function renderChangePreview(record) {
|
|
|
5978
6181
|
* it on the second call, with its id.
|
|
5979
6182
|
*
|
|
5980
6183
|
* A change that loosens no setting and does nothing irreversible is applied directly: asking a person to agree to
|
|
5981
|
-
* something that needs no agreement teaches them to agree without reading. It refuses an approval id
|
|
5982
|
-
*
|
|
5983
|
-
* call claiming an approval through (design 2026-09-28 §2),
|
|
5984
|
-
* approval the store holds — one an agent had just had prepared for
|
|
5985
|
-
* or a cancellation made where the call itself was stopped.
|
|
6184
|
+
* something that needs no agreement teaches them to agree without reading. It refuses an approval id — in the change's
|
|
6185
|
+
* own words when it has them (`refuseApproval`) — as a report refuses one (`refuseApprovalWithoutChange`), rather than
|
|
6186
|
+
* applying and dropping it: the update check's stop lets a call claiming an approval through (design 2026-09-28 §2),
|
|
6187
|
+
* and one that is then never claimed would carry any approval the store holds — one an agent had just had prepared for
|
|
6188
|
+
* "not now", say — past the stop, to a tightening or a cancellation made where the call itself was stopped.
|
|
5986
6189
|
*/
|
|
5987
6190
|
async function gatedChange(core, change, options) {
|
|
5988
6191
|
const request = await change.plan(await core.config.load());
|
|
5989
6192
|
const loosens = classifyChange(request.before, request.after).loosened.length > 0;
|
|
5990
6193
|
const acts = (request.effects ?? []).length > 0;
|
|
5991
6194
|
if (!loosens && !acts) {
|
|
5992
|
-
if (options.approvalId)
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
6195
|
+
if (options.approvalId) {
|
|
6196
|
+
const refusal = await change.refuseApproval?.(options.approvalId);
|
|
6197
|
+
if (refusal !== void 0) throw refusal;
|
|
6198
|
+
throw new CommsError("USAGE", "nothing was changed: as things stand this change needs no approval, so it takes none", {
|
|
6199
|
+
hint: "Make the same call again without the approval: it applies at once and asks nobody. An approval is spent only on the change it was prepared for.",
|
|
6200
|
+
details: { approvalId: options.approvalId }
|
|
6201
|
+
});
|
|
6202
|
+
}
|
|
5996
6203
|
return {
|
|
5997
6204
|
status: "applied",
|
|
5998
6205
|
result: await change.apply(void 0, request)
|
|
@@ -6075,8 +6282,10 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6075
6282
|
const { prepared } = first;
|
|
6076
6283
|
if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
|
|
6077
6284
|
if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
|
|
6285
|
+
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
6286
|
+
const { command } = options;
|
|
6078
6287
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
6079
|
-
hint: approvalHint(prepared, `${
|
|
6288
|
+
hint: approvalHint(prepared, typeof command === "string" ? `${command} ${carrying.join(" ")}` : withWords(command, ...carrying), approveCommand),
|
|
6080
6289
|
details: {
|
|
6081
6290
|
approvalId: prepared.approvalId,
|
|
6082
6291
|
policy: prepared.policy,
|
|
@@ -6110,7 +6319,8 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6110
6319
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
6111
6320
|
*/
|
|
6112
6321
|
function approvalHint(prepared, rerun, approveCommand) {
|
|
6113
|
-
|
|
6322
|
+
const run = typeof rerun === "string" ? `\`${rerun}\`` : inlineCommand(rerun);
|
|
6323
|
+
return prepared.policy === "confirm" ? `Show the person the preview. They run \`${approveCommandOf({ approveCommand })} ${prepared.approvalId}\`; then run ${run}.` : `Show the person the preview. Once they say yes, run ${run}.`;
|
|
6114
6324
|
}
|
|
6115
6325
|
/**
|
|
6116
6326
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -6792,7 +7002,7 @@ async function npmInstallGlobal(env, spec) {
|
|
|
6792
7002
|
}
|
|
6793
7003
|
}
|
|
6794
7004
|
/** The package version, read from package.json at build time. */
|
|
6795
|
-
const VERSION = "0.12.
|
|
7005
|
+
const VERSION = "0.12.2";
|
|
6796
7006
|
/**
|
|
6797
7007
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
6798
7008
|
*
|
|
@@ -7050,7 +7260,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
7050
7260
|
};
|
|
7051
7261
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
7052
7262
|
packageName: channelServer(request.channel).packageName,
|
|
7053
|
-
version: "0.12.
|
|
7263
|
+
version: "0.12.2"
|
|
7054
7264
|
};
|
|
7055
7265
|
const product = {
|
|
7056
7266
|
packageName,
|
|
@@ -7396,8 +7606,13 @@ async function latestReleases(names, latestVersion) {
|
|
|
7396
7606
|
if (first?.problem) throw new CommsError("PROVIDER_UNAVAILABLE", `${first.problem}${problems.length > 1 ? ` (and ${problems.length - 1} more)` : ""}`, { hint: "Nothing was checked or changed. Try again once the registry can be reached; `npm view <package> version` shows what npm itself reads." });
|
|
7397
7607
|
return Object.fromEntries(answers.map((answer) => [answer.name, answer.version ?? ""]));
|
|
7398
7608
|
}
|
|
7399
|
-
/**
|
|
7400
|
-
|
|
7609
|
+
/**
|
|
7610
|
+
* How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach.
|
|
7611
|
+
*
|
|
7612
|
+
* The entry's name and pins are read from the client's file, which may hold anything, so on Windows the command may
|
|
7613
|
+
* have no line to paste: the reasons below give it with `inlineCommand`, which shows it as words then.
|
|
7614
|
+
*/
|
|
7615
|
+
function installCommand(item, platform) {
|
|
7401
7616
|
const facts = channelServer(item.channel);
|
|
7402
7617
|
const words = [
|
|
7403
7618
|
facts.binary,
|
|
@@ -7408,7 +7623,7 @@ function installCommand(item) {
|
|
|
7408
7623
|
];
|
|
7409
7624
|
if (item.name !== facts.defaultServerName) words.push("--name", item.name);
|
|
7410
7625
|
words.push(...item.narrowing, "--force");
|
|
7411
|
-
return words
|
|
7626
|
+
return shellCommand(words, platform);
|
|
7412
7627
|
}
|
|
7413
7628
|
/**
|
|
7414
7629
|
* Why a registration that is behind cannot be registered again from here, or undefined when it can.
|
|
@@ -7419,13 +7634,13 @@ function installCommand(item) {
|
|
|
7419
7634
|
* been renamed or removed: registering it again would register a server that is refused when it starts.
|
|
7420
7635
|
*/
|
|
7421
7636
|
async function whyNotUpdatable(context, config, item, narrowing) {
|
|
7422
|
-
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with
|
|
7637
|
+
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with ${inlineCommand(installCommand(item, context.platform))}`;
|
|
7423
7638
|
if (item.scope !== "user") return `it is registered for one project (in ${item.path}), and \`mcp install\` registers at user scope only; register it again from that project with ${item.client}'s own command`;
|
|
7424
7639
|
const target = await installTarget(context, {
|
|
7425
7640
|
client: item.client,
|
|
7426
7641
|
apply: true
|
|
7427
7642
|
});
|
|
7428
|
-
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run
|
|
7643
|
+
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; run ${inlineCommand(installCommand(item, context.platform))} where it is` : `this environment names no ${item.client} configuration to write to`;
|
|
7429
7644
|
const own = pinOption(item.channel);
|
|
7430
7645
|
const pin = own === void 0 || own === "readOnly" ? void 0 : narrowing[own];
|
|
7431
7646
|
if (pin !== void 0) {
|
|
@@ -7469,7 +7684,8 @@ async function runtimesNeeded(dataDir, latest, managed) {
|
|
|
7469
7684
|
async function inspect(core, env, deps) {
|
|
7470
7685
|
const context = {
|
|
7471
7686
|
env,
|
|
7472
|
-
core
|
|
7687
|
+
core,
|
|
7688
|
+
platform: deps.platform
|
|
7473
7689
|
};
|
|
7474
7690
|
const scan = await scanRegisteredServers(env);
|
|
7475
7691
|
const unreadable = [...scan.unreadable];
|
|
@@ -7778,7 +7994,7 @@ async function planRegistrations(context, candidates, request) {
|
|
|
7778
7994
|
}
|
|
7779
7995
|
const keeps = narrowingArgs(item.channel, preflight.effective);
|
|
7780
7996
|
if (JSON.stringify(keeps) !== JSON.stringify(item.narrowing)) {
|
|
7781
|
-
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have:
|
|
7997
|
+
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have: ${inlineCommand(installCommand(item, context.platform))}`);
|
|
7782
7998
|
continue;
|
|
7783
7999
|
}
|
|
7784
8000
|
steps.push({
|
|
@@ -7803,7 +8019,8 @@ async function planRegistrations(context, candidates, request) {
|
|
|
7803
8019
|
function updateChange(core, env, request = {}, deps = {}) {
|
|
7804
8020
|
const context = {
|
|
7805
8021
|
env,
|
|
7806
|
-
core
|
|
8022
|
+
core,
|
|
8023
|
+
platform: deps.platform
|
|
7807
8024
|
};
|
|
7808
8025
|
let planned = null;
|
|
7809
8026
|
return {
|
|
@@ -7837,9 +8054,69 @@ function updateChange(core, env, request = {}, deps = {}) {
|
|
|
7837
8054
|
const result = await applyUpdate(context, planned, deps);
|
|
7838
8055
|
await recordFound(core, env, planned.report, deps, result);
|
|
7839
8056
|
return result;
|
|
8057
|
+
},
|
|
8058
|
+
refuseApproval: (approvalId) => {
|
|
8059
|
+
if (planned === null) throw new CommsError("UNEXPECTED", "the update was refused before it was planned");
|
|
8060
|
+
return nothingToApply(core, approvalId, planned);
|
|
7840
8061
|
}
|
|
7841
8062
|
};
|
|
7842
8063
|
}
|
|
8064
|
+
/**
|
|
8065
|
+
* The refusal for an approval handed to an update with no step left in it (CUE-303).
|
|
8066
|
+
*
|
|
8067
|
+
* An update needs an approval only for its steps, so one with none takes no approval, and the general refusal said to
|
|
8068
|
+
* call again without it, as "it applies at once". An approval is prepared for an update while something is behind,
|
|
8069
|
+
* and it is usually claimed minutes later; meanwhile another session, or the person at a terminal, may have applied
|
|
8070
|
+
* the update. Then nothing is behind, calling again applies nothing, and "applies at once" read as an update about to
|
|
8071
|
+
* happen. So this says what is so: there is nothing to apply — nothing behind at all, or nothing that can be updated
|
|
8072
|
+
* from here — and the approval was not used, with what had become of it.
|
|
8073
|
+
*
|
|
8074
|
+
* The store is asked as it is. Its `get` answers null for an approval it does not have, and throws for anything else:
|
|
8075
|
+
* an id that is not one, a record it cannot read or parse. Every one of those was caught and said as "not used" with
|
|
8076
|
+
* nothing more, so a corrupt approval file or a permissions problem read as an approval that was simply not there.
|
|
8077
|
+
* Now a record that cannot be read goes up as itself, as it would from any other call that reads an approval, and an
|
|
8078
|
+
* id that is not one is said to be that.
|
|
8079
|
+
*/
|
|
8080
|
+
async function nothingToApply(core, approvalId, planned) {
|
|
8081
|
+
if (!APPROVAL_ID_PATTERN.test(approvalId)) return new CommsError("USAGE", `nothing was changed: "${approvalId}" is not an approval id`, {
|
|
8082
|
+
hint: "An approval id is the `approvalId` a call that needs approval returns, and nothing here needs one: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows what is behind.",
|
|
8083
|
+
details: { approvalId }
|
|
8084
|
+
});
|
|
8085
|
+
const record = await core.approvals.get(approvalId);
|
|
8086
|
+
const unused = `so there is nothing to apply; the approval ${approvalId} was not used (${whatBecameOf(record)})`;
|
|
8087
|
+
const details = {
|
|
8088
|
+
approvalId,
|
|
8089
|
+
...record ? { state: record.state } : {}
|
|
8090
|
+
};
|
|
8091
|
+
if (planned.nothingBehind) return new CommsError("USAGE", `nothing was changed: nothing is behind the latest release, ${unused}`, {
|
|
8092
|
+
hint: "Nothing needs doing: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — shows everything here up to date. A release published later is another update, with a preview and an approval of its own.",
|
|
8093
|
+
details
|
|
8094
|
+
});
|
|
8095
|
+
return new CommsError("USAGE", `nothing was changed: what is behind cannot be updated from here, ${unused}`, {
|
|
8096
|
+
hint: "Each is left for a person: a check — comms_update with `check`, or `agentcomms update --check` at a terminal — lists them, with why and what to run. The same call without the approval reports them too, and changes nothing.",
|
|
8097
|
+
details
|
|
8098
|
+
});
|
|
8099
|
+
}
|
|
8100
|
+
/**
|
|
8101
|
+
* What had become of an approval, for `nothingToApply`: none at all, or each state an approval can be in, by name.
|
|
8102
|
+
*
|
|
8103
|
+
* A record, so that a state added to the store is a type error here rather than a refusal that says nothing. The
|
|
8104
|
+
* send states are here too: any approval's id can be handed to an update, a send's among them, and this reads the
|
|
8105
|
+
* record before anything looks at its kind.
|
|
8106
|
+
*/
|
|
8107
|
+
function whatBecameOf(record) {
|
|
8108
|
+
if (record === null) return "there is no approval by that id";
|
|
8109
|
+
return {
|
|
8110
|
+
pending: `it was still waiting to be approved, and lapses at ${record.expiresAt}`,
|
|
8111
|
+
approved: `it had been approved, and lapses unspent at ${record.expiresAt}`,
|
|
8112
|
+
sending: "it is an approval to send, and that send is under way",
|
|
8113
|
+
used: "it had been spent already",
|
|
8114
|
+
failed: "it had been spent on a send that failed",
|
|
8115
|
+
unknown: "it had been spent on a send whose outcome was never recorded",
|
|
8116
|
+
expired: `it had expired at ${record.expiresAt}`,
|
|
8117
|
+
revoked: "it had been revoked"
|
|
8118
|
+
}[record.state] ?? `it is ${JSON.stringify(record.state)}, a state this version does not know`;
|
|
8119
|
+
}
|
|
7843
8120
|
/** The product a runtime of `packageName` is installed as: the channel whose package it is. */
|
|
7844
8121
|
function runtimeProduct(packageName, version) {
|
|
7845
8122
|
const channel = CHANNELS.find((each) => channelServer(each).packageName === packageName);
|
|
@@ -8660,7 +8937,7 @@ function terminalUpdateHooks(core, env, options) {
|
|
|
8660
8937
|
};
|
|
8661
8938
|
}
|
|
8662
8939
|
//#endregion
|
|
8663
|
-
//#region ../core/dist/tool-arguments-
|
|
8940
|
+
//#region ../core/dist/tool-arguments-BMmJnuY4.mjs
|
|
8664
8941
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
8665
8942
|
const TARGET = "draft-2020-12";
|
|
8666
8943
|
/**
|
|
@@ -19982,12 +20259,38 @@ var Throttle = class {
|
|
|
19982
20259
|
#now;
|
|
19983
20260
|
#sleep;
|
|
19984
20261
|
#interval;
|
|
20262
|
+
/**
|
|
20263
|
+
* Five seconds **for each holder** of the lock, not for the whole line of callers ahead.
|
|
20264
|
+
*
|
|
20265
|
+
* Every Resend request on this machine takes this one lock, and an agent asking six accounts at once puts six
|
|
20266
|
+
* callers on it at the same moment. With the timeout counted from when each began to wait, the sixth had to see
|
|
20267
|
+
* five holders through inside one five-second budget; on a heavily loaded machine (a load of 97 on ten cores, in
|
|
20268
|
+
* the pre-push run that showed it) the read and the fsynced write under the lock slow down with everything else,
|
|
20269
|
+
* and it gave up with "another process is holding" while the lock was being handed on exactly as it should be. A
|
|
20270
|
+
* person with several accounts and a busy machine would have had requests refused for no reason but the queue.
|
|
20271
|
+
* Counted per holder, a caller gives up only once one holder has kept the lock for five seconds: the stuck lock
|
|
20272
|
+
* the timeout is there for.
|
|
20273
|
+
*
|
|
20274
|
+
* Each request takes the lock once, to reserve its slot, and does its waiting outside it, so the line is only the
|
|
20275
|
+
* requests in flight. But the lock is polled, not queued: whoever looks first after a release takes it, and a
|
|
20276
|
+
* request that keeps looking at the wrong moment watches later ones go ahead of it, for as long as later ones keep
|
|
20277
|
+
* arriving — and several servers each paging through sent mail keep them arriving. So the wait also has an end
|
|
20278
|
+
* that no hand-over moves, `LOCK_MAX_WAIT_MS`, and past it the request is refused as a busy lock rather than left
|
|
20279
|
+
* hanging. `after` waits the same way, because a stop it gave up on recording is a 429 that every other account
|
|
20280
|
+
* carries on into.
|
|
20281
|
+
*/
|
|
20282
|
+
#lock;
|
|
19985
20283
|
/** Takes no account on purpose: see above. Every throttle on a state directory is the same one. */
|
|
19986
20284
|
constructor(stateDir, options = {}) {
|
|
19987
20285
|
this.path = join(stateDir, "resend", "throttle.json");
|
|
19988
20286
|
this.#now = options.now ?? Date.now;
|
|
19989
20287
|
this.#sleep = options.sleep ?? ((ms) => setTimeout$1(ms).then(() => void 0));
|
|
19990
20288
|
this.#interval = options.intervalMs ?? 500;
|
|
20289
|
+
this.#lock = {
|
|
20290
|
+
timeoutPerHolder: true,
|
|
20291
|
+
maxWaitMs: options.lockMaxWaitMs ?? 3e4,
|
|
20292
|
+
...options.lockTimeoutMs === void 0 ? {} : { timeoutMs: options.lockTimeoutMs }
|
|
20293
|
+
};
|
|
19991
20294
|
}
|
|
19992
20295
|
async #read() {
|
|
19993
20296
|
try {
|
|
@@ -20004,6 +20307,10 @@ var Throttle = class {
|
|
|
20004
20307
|
throw error;
|
|
20005
20308
|
}
|
|
20006
20309
|
}
|
|
20310
|
+
/** Runs `fn` holding the machine's one throttle lock, waiting for it as `#lock` says. */
|
|
20311
|
+
#locked(fn) {
|
|
20312
|
+
return withFileLock(`${this.path}.lock`, fn, this.#lock);
|
|
20313
|
+
}
|
|
20007
20314
|
/** When Resend will next be asked anything from this machine, or null when it is not holding off. */
|
|
20008
20315
|
async blockedUntil() {
|
|
20009
20316
|
const state = await this.#read();
|
|
@@ -20031,7 +20338,7 @@ var Throttle = class {
|
|
|
20031
20338
|
* later.
|
|
20032
20339
|
*/
|
|
20033
20340
|
async before() {
|
|
20034
|
-
const wait = await
|
|
20341
|
+
const wait = await this.#locked(async () => {
|
|
20035
20342
|
const state = await this.#read();
|
|
20036
20343
|
this.#refuseIfHeld(state);
|
|
20037
20344
|
const now = this.#now();
|
|
@@ -20062,7 +20369,7 @@ var Throttle = class {
|
|
|
20062
20369
|
else if (seconds("ratelimit-remaining") === 0) stopFor = seconds("ratelimit-reset") ?? 1;
|
|
20063
20370
|
if (stopFor === null) return null;
|
|
20064
20371
|
const until = this.#now() + Math.max(1, stopFor) * 1e3;
|
|
20065
|
-
await
|
|
20372
|
+
await this.#locked(async () => {
|
|
20066
20373
|
const state = await this.#read();
|
|
20067
20374
|
await writeFileAtomic(this.path, JSON.stringify({
|
|
20068
20375
|
...state,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as changeToolResult, I as CommsError, K as checkForUpdates, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, Z as gatedChange, _ as runDoctor, b as listAccounts, c as downloadReceived, d as listReceived, 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, nt as refuseUnclaimedApproval, o as sendStatus, p as listSentEmails, pt as updateToolGate, s as cancelScheduledChange, u as listDomains, ut as toCommsError, w as policyReport, yt as VERSION } from "./send-
|
|
1
|
+
import { A as CHANGE_POLICIES, C as policyChange, E as removeAccountChange, G as changeToolResult, I as CommsError, K as checkForUpdates, M as SEND_POLICIES, O as showAccount, P as strictToolArguments, R as SEND_LOOKUP, S as policyApprovalRefusal, T as policyWanted, Z as gatedChange, _ as runDoctor, b as listAccounts, c as downloadReceived, d as listReceived, 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, nt as refuseUnclaimedApproval, o as sendStatus, p as listSentEmails, pt as updateToolGate, s as cancelScheduledChange, u as listDomains, ut as toCommsError, w as policyReport, yt as VERSION } from "./send-BlDpMIlS.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-
|
|
1
|
+
import { t as createResendMcpServer } from "./server-B-ubuD7Z.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.12.
|
|
3
|
+
"version": "0.12.2",
|
|
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.12.
|
|
41
|
+
"@agentcomms/core": "0.12.2"
|
|
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": "
|
|
97
|
+
"gitHead": "224763228ecc63ce403f0d12988865cfaa981f2a"
|
|
98
98
|
}
|