@agentrq/acp-gateway 0.2.4 → 0.2.6

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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentInfo.js","sourceRoot":"","sources":["../src/agentInfo.ts"],"names":[],"mappings":"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"}
package/dist/config.js CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
+ /** The MCP transports ACP defines that this gateway knows how to hand over. */
10
+ export const MCP_TRANSPORTS = ["http", "sse", "stdio"];
9
11
  /**
10
12
  * Find and parse the .mcp.json config file.
11
13
  * Searches CWD and then up to 3 parent directories.
@@ -18,29 +20,61 @@ export function loadMcpConfig(startDir = process.cwd()) {
18
20
  resolve(startDir, "..", "..", "..", ".mcp.json"),
19
21
  ];
20
22
  for (const candidate of candidates) {
23
+ // Only reading and parsing may fall through to the next candidate. What the
24
+ // file *says* is a separate matter: once a .mcp.json has been found, a
25
+ // mistake inside it is reported rather than hidden behind "no config found".
26
+ let parsed;
21
27
  try {
22
- const raw = readFileSync(candidate, "utf-8");
23
- const parsed = JSON.parse(raw);
24
- const servers = Object.entries(parsed.mcpServers ?? {}).map(([name, cfg]) => ({
25
- name,
26
- type: cfg.type ?? (cfg.url ? "http" : "stdio"),
27
- url: cfg.url,
28
- command: cfg.command,
29
- args: cfg.args,
30
- env: cfg.env,
31
- headers: cfg.headers,
32
- }));
33
- if (servers.length > 0) {
34
- console.error(`[config] Loaded .mcp.json from ${candidate}`);
35
- return servers;
36
- }
28
+ parsed = JSON.parse(readFileSync(candidate, "utf-8"));
37
29
  }
38
30
  catch {
39
- // Not found here, try next
31
+ continue;
32
+ }
33
+ const servers = Object.entries(parsed.mcpServers ?? {}).map(([name, cfg]) => ({
34
+ name,
35
+ type: readTransport(name, cfg.type, cfg.url),
36
+ url: cfg.url,
37
+ command: cfg.command,
38
+ args: cfg.args,
39
+ env: cfg.env,
40
+ headers: cfg.headers,
41
+ }));
42
+ servers.forEach(assertUsable);
43
+ if (servers.length > 0) {
44
+ console.error(`[config] Loaded .mcp.json from ${candidate}`);
45
+ return servers;
40
46
  }
41
47
  }
42
48
  throw new Error("Could not find .mcp.json — run acp-gateway from your workspace root");
43
49
  }
50
+ /**
51
+ * Reads an entry's transport, defaulting the way MCP clients conventionally do.
52
+ *
53
+ * An unknown transport is refused here rather than quietly treated as stdio,
54
+ * which produced a server with no command and an agent that could not say why.
55
+ */
56
+ function readTransport(name, type, url) {
57
+ if (type === undefined)
58
+ return url ? "http" : "stdio";
59
+ if (MCP_TRANSPORTS.includes(type))
60
+ return type;
61
+ throw new Error(`MCP server "${name}" in .mcp.json has transport "${type}", which this gateway ` +
62
+ `cannot hand to an agent. Use one of: ${MCP_TRANSPORTS.join(", ")}.`);
63
+ }
64
+ /**
65
+ * Refuses an entry the agent could never connect to.
66
+ *
67
+ * The gateway passes these straight through to the agent, so an entry missing
68
+ * the one field its transport needs fails inside the agent, long after the
69
+ * mistake was made and with nothing to point at.
70
+ */
71
+ function assertUsable(server) {
72
+ const missing = server.type === "stdio" ? !server.command : !server.url;
73
+ if (!missing)
74
+ return;
75
+ const field = server.type === "stdio" ? "command" : "url";
76
+ throw new Error(`MCP server "${server.name}" in .mcp.json is ${server.type} but has no ${field}.`);
77
+ }
44
78
  /**
45
79
  * Pick the primary agentrq MCP server from the list.
46
80
  * Prefers servers with "agentrq" in the name; falls back to the first HTTP server.
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA0BpC;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,QAAQ,GAAW,OAAO,CAAC,GAAG,EAAE;IAC5D,MAAM,UAAU,GAAG;QACjB,OAAO,CAAC,QAAQ,EAAE,WAAW,CAAC;QAC9B,OAAO,CAAC,QAAQ,EAAE,IAAI,EAAE,WAAW,CAAC;QACpC,OAAO,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,CAAC;QAC1C,OAAO,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,CAAC;KACjD,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;QACnC,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;YAC7C,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACxC,MAAM,OAAO,GAAsB,MAAM,CAAC,OAAO,CAC/C,MAAM,CAAC,UAAU,IAAI,EAAE,CACxB,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;gBACtB,IAAI;gBACJ,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;gBAC9C,GAAG,EAAE,GAAG,CAAC,GAAG;gBACZ,OAAO,EAAE,GAAG,CAAC,OAAO;gBACpB,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,GAAG,EAAE,GAAG,CAAC,GAAG;gBACZ,OAAO,EAAE,GAAG,CAAC,OAAO;aACrB,CAAC,CAAC,CAAC;YAEJ,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,KAAK,CAAC,kCAAkC,SAAS,EAAE,CAAC,CAAC;gBAC7D,OAAO,OAAO,CAAC;YACjB,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,2BAA2B;QAC7B,CAAC;IACH,CAAC;IAED,MAAM,IAAI,KAAK,CACb,qEAAqE,CACtE,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAA0B;IAE1B,8BAA8B;IAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CACxB,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,CAAC,GAAG,CAC9E,CAAC;IACF,IAAI,KAAK;QAAE,OAAO,KAAK,CAAC;IAExB,iCAAiC;IACjC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,IAAI,KAAK,CACb,4FAA4F,CAC7F,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -11,19 +11,45 @@ import { existsSync, readFileSync } from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import * as acp from "@agentclientprotocol/sdk";
13
13
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
14
- import { loadMcpConfig, pickAgentrqServer } from "./config.js";
14
+ import { loadMcpConfig, pickAgentrqServer, } from "./config.js";
15
15
  import { MCPBridge } from "./mcpClient.js";
16
- export function mapMcpServers(configs) {
17
- return configs.map((cfg) => {
18
- if (cfg.type === "http") {
19
- return {
20
- type: "http",
21
- name: cfg.name,
22
- url: cfg.url,
23
- headers: Object.entries(cfg.headers || {}).map(([name, value]) => ({ name, value })),
24
- };
16
+ /**
17
+ * What the agent said about a transport, if anything.
18
+ *
19
+ * stdio is the one transport every agent must support. For the others the
20
+ * answer is only trustworthy when the agent actually stated it: an agent that
21
+ * advertises no MCP capabilities at all is far more likely to be terse than to
22
+ * be unable to reach an HTTP server, and dropping its servers on that reading
23
+ * would take the workspace's own MCP server away from it.
24
+ */
25
+ function transportSupport(transport, agentCapabilities) {
26
+ if (transport === "stdio")
27
+ return "required";
28
+ const declared = agentCapabilities?.mcpCapabilities?.[transport];
29
+ if (declared === true)
30
+ return "declared";
31
+ if (declared === false)
32
+ return "refused";
33
+ return "unstated";
34
+ }
35
+ export function mapMcpServers(configs, agentCapabilities) {
36
+ return configs
37
+ .filter((cfg) => {
38
+ const support = transportSupport(cfg.type, agentCapabilities);
39
+ if (support === "refused") {
40
+ console.error(`[acp] ⚠️ Not passing MCP server "${cfg.name}" to the agent: it is ${cfg.type}, ` +
41
+ `and the agent says it does not support that transport. Passing it anyway ` +
42
+ `risks the agent refusing the whole session.`);
43
+ return false;
25
44
  }
26
- else {
45
+ if (support === "unstated") {
46
+ console.error(`[acp] MCP server "${cfg.name}" is ${cfg.type}, which the agent does not ` +
47
+ `advertise either way — passing it and letting the agent decide.`);
48
+ }
49
+ return true;
50
+ })
51
+ .map((cfg) => {
52
+ if (cfg.type === "stdio") {
27
53
  return {
28
54
  name: cfg.name,
29
55
  command: cfg.command,
@@ -31,17 +57,61 @@ export function mapMcpServers(configs) {
31
57
  env: Object.entries(cfg.env || {}).map(([name, value]) => ({ name, value })),
32
58
  };
33
59
  }
60
+ return {
61
+ type: cfg.type,
62
+ name: cfg.name,
63
+ url: cfg.url,
64
+ headers: Object.entries(cfg.headers || {}).map(([name, value]) => ({ name, value })),
65
+ };
34
66
  });
35
67
  }
36
- import { AgentRQACPClient } from "./acpClient.js";
68
+ import { AgentRQACPClient, DEFAULT_PERMISSION_TIMEOUT_MS } from "./acpClient.js";
37
69
  import { describeAuthMethods, isAuthRequiredError, login, logout, supportsLogout, } from "./auth.js";
38
70
  import { resolveAgentLaunch } from "./agentInstall.js";
71
+ import { describeAgentInfo } from "./agentInfo.js";
39
72
  import { describeAgents, fetchRegistry, hostPlatformTarget, } from "./registry.js";
40
73
  import { extractTaskIdFromMeta, extractTaskIdFromText, } from "./taskIdentity.js";
41
74
  const lastTaskContent = new Map();
75
+ // Cancellations are remembered by *where they fall in the stream of events*
76
+ // rather than as a one-shot flag. agentrq reuses a chat's id as the task id, so
77
+ // a flag that outlived the cancellation it describes would swallow the user's
78
+ // next message on that chat; ordered this way, a task is skipped only when the
79
+ // cancel arrived after it was queued. Wall-clock time is too coarse to order
80
+ // two events in the same millisecond, so this is a plain counter.
81
+ let taskSeq = 0;
82
+ export const nextTaskSeq = () => ++taskSeq;
83
+ export const cancelledTaskSeq = new Map();
84
+ // A cancel for a task that never runs (a stale id, a task that already
85
+ // finished) leaves an entry behind, so keep the map from growing without bound.
86
+ const MAX_REMEMBERED_CANCELLATIONS = 200;
87
+ /** Records that `taskId` has just been cancelled. */
88
+ export function markTaskCancelled(taskId) {
89
+ // Re-inserting moves the id to the end, so eviction stays oldest-first.
90
+ cancelledTaskSeq.delete(taskId);
91
+ cancelledTaskSeq.set(taskId, nextTaskSeq());
92
+ while (cancelledTaskSeq.size > MAX_REMEMBERED_CANCELLATIONS) {
93
+ const oldest = cancelledTaskSeq.keys().next().value;
94
+ cancelledTaskSeq.delete(oldest);
95
+ }
96
+ }
97
+ /**
98
+ * Whether a task queued at `queuedSeq` has since been cancelled.
99
+ *
100
+ * Two notifications for the same task id can be queued at once, so the check is
101
+ * against the point each was queued: cancelling the older one must not stop the
102
+ * newer one from running.
103
+ */
104
+ export function isTaskCancelled(taskId, queuedSeq) {
105
+ if (!taskId)
106
+ return false;
107
+ const seq = cancelledTaskSeq.get(taskId);
108
+ return seq !== undefined && seq > queuedSeq;
109
+ }
42
110
  export const activeSessions = new Map();
43
111
  /** Login preferences taken from the CLI, consulted whenever an agent demands auth. */
44
112
  export const authConfig = {};
113
+ /** How long tool calls wait for a human, taken from the CLI at startup. */
114
+ export const permissionConfig = {};
45
115
  /**
46
116
  * Whether a human is sitting in front of this process.
47
117
  *
@@ -64,15 +134,22 @@ export async function openAgentConnection({ acpCmdArgs, mcpBridge, env, label, t
64
134
  stdio: ["pipe", "pipe", "inherit"],
65
135
  env: { ...process.env, ...env },
66
136
  });
137
+ const acpClient = new AgentRQACPClient(mcpBridge, () => taskId, {
138
+ permissionTimeoutMs: permissionConfig.timeoutMs,
139
+ });
67
140
  // Guard against unhandled child-process failures. Without these listeners a
68
141
  // crashed agent (e.g. on network loss) leaves a broken stdin pipe; the next
69
142
  // write raises EPIPE as an uncaught error and takes the gateway down with it.
70
143
  agentProcess.on("error", (err) => {
71
144
  console.error(`[acp] Agent process error for ${label}:`, err.message);
145
+ acpClient.cancelPendingPermissions(`agent process for ${label} failed`);
72
146
  onExit?.();
73
147
  });
74
148
  agentProcess.on("exit", (code, signal) => {
75
149
  console.error(`[acp] Agent process for ${label} exited (code=${code}, signal=${signal})`);
150
+ // Nothing will act on these answers now, but the tool calls waiting on them
151
+ // are holding task-queue slots that would never be given back.
152
+ acpClient.cancelPendingPermissions(`agent process for ${label} exited`);
76
153
  onExit?.();
77
154
  });
78
155
  // stdin can emit EPIPE when the child dies mid-write; swallow it so it
@@ -82,9 +159,11 @@ export async function openAgentConnection({ acpCmdArgs, mcpBridge, env, label, t
82
159
  });
83
160
  const input = Writable.toWeb(agentProcess.stdin);
84
161
  const output = Readable.toWeb(agentProcess.stdout);
85
- const acpClient = new AgentRQACPClient(mcpBridge, () => taskId);
86
162
  const stream = acp.ndJsonStream(input, output);
87
163
  const connection = new acp.ClientSideConnection((_agent) => acpClient, stream);
164
+ // Stopping a turn is only possible once the connection exists, and the
165
+ // connection is built around the client — so it is handed over afterwards.
166
+ acpClient.setSessionCanceller((sessionId) => connection.cancel({ sessionId }));
88
167
  const initResult = await connection.initialize({
89
168
  protocolVersion: acp.PROTOCOL_VERSION,
90
169
  clientCapabilities: {
@@ -144,7 +223,7 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
144
223
  });
145
224
  const newSessionParams = {
146
225
  cwd: process.cwd(),
147
- mcpServers: mapMcpServers(configs),
226
+ mcpServers: mapMcpServers(configs, initResult.agentCapabilities),
148
227
  };
149
228
  const sessionResult = await createSessionWithAuth(connection, newSessionParams, {
150
229
  methods: initResult.authMethods,
@@ -154,6 +233,9 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
154
233
  });
155
234
  console.error(`[acp] Created session ${sessionResult.sessionId} for task ${key}`);
156
235
  await enforceHumanApprovalMode(connection, sessionResult);
236
+ // The mode is pinned once here, but agents may move themselves back out of
237
+ // it, so keep watching for the rest of the session's life.
238
+ acpClient.setModeChangeHandler((changedSessionId, modeId) => handleAgentModeChange(connection, changedSessionId, modeId, sessionResult.modes));
157
239
  const sessionInfo = {
158
240
  process: agentProcess,
159
241
  connection,
@@ -164,6 +246,61 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
164
246
  activeSessions.set(key, sessionInfo);
165
247
  return sessionInfo;
166
248
  }
249
+ /**
250
+ * Finds the active agent session for a given task ID (or returns the single active session if none specified).
251
+ */
252
+ export function findActiveSession(taskId) {
253
+ if (taskId) {
254
+ const direct = activeSessions.get(taskId);
255
+ if (direct)
256
+ return direct;
257
+ for (const session of activeSessions.values()) {
258
+ if (session.sessionId === taskId)
259
+ return session;
260
+ }
261
+ // A session opened from a notification that carried no task id is keyed
262
+ // "default"; when it is the only one running, an id-carrying cancel can
263
+ // only have meant it. Never fall back to a session keyed under a
264
+ // *different* task id — that would abort an unrelated task.
265
+ const untracked = activeSessions.get("default");
266
+ if (untracked && activeSessions.size === 1) {
267
+ return untracked;
268
+ }
269
+ }
270
+ else if (activeSessions.size === 1) {
271
+ return activeSessions.values().next().value;
272
+ }
273
+ return undefined;
274
+ }
275
+ /**
276
+ * Handles task cancellation events from the MCP server.
277
+ * Cancels the ACP session/turn and immediately cancels any pending permissions.
278
+ */
279
+ export async function handleTaskCancellation(taskId, reason) {
280
+ if (taskId) {
281
+ markTaskCancelled(taskId);
282
+ const session = findActiveSession(taskId);
283
+ if (!session) {
284
+ console.error(`[bridge] Received cancellation for task ${taskId}, but no active session found (queued tasks will be skipped)`);
285
+ return;
286
+ }
287
+ console.error(`[bridge] Cancelling session ${session.sessionId} for task ${taskId}${reason ? ` (${reason})` : ""}`);
288
+ await session.acpClient.cancelTurn(session.sessionId);
289
+ }
290
+ else {
291
+ const session = findActiveSession(undefined);
292
+ if (session) {
293
+ console.error(`[bridge] Received cancellation with no taskId${reason ? ` (${reason})` : ""}. Cancelling active session ${session.sessionId}...`);
294
+ await session.acpClient.cancelTurn(session.sessionId);
295
+ }
296
+ else {
297
+ console.error(`[bridge] Received cancellation with no taskId${reason ? ` (${reason})` : ""}` +
298
+ (activeSessions.size === 0
299
+ ? ", and no sessions are active"
300
+ : `, but ${activeSessions.size} sessions are active (skipping to avoid aborting unrelated tasks)`));
301
+ }
302
+ }
303
+ }
167
304
  // Modes that approve tool calls without asking the user. codex-acp, for
168
305
  // example, defaults to an "agent" mode whose reviewer is "auto_review" — an
169
306
  // automated Guardian Review that approves on the human's behalf — and also
@@ -220,6 +357,50 @@ export async function enforceHumanApprovalMode(connection, sessionResult) {
220
357
  console.error(`[acp] ⚠️ Failed to set session mode to "${modeId}" — tool calls may execute without agentrq approval:`, err);
221
358
  }
222
359
  }
360
+ /**
361
+ * How many times the gateway will drag one session back into a mode that asks
362
+ * the human. An agent that keeps switching back is not going to stop, and an
363
+ * unbounded fight with it would be an endless stream of setSessionMode calls.
364
+ */
365
+ const MAX_MODE_REENFORCEMENTS = 3;
366
+ /** sessionId → how many times its mode has already been put back. */
367
+ const modeReenforcements = new Map();
368
+ /**
369
+ * Puts a session back into a mode that asks the human, after the agent moved
370
+ * itself out of one.
371
+ *
372
+ * Agents may change modes on their own. If one moves into a mode that approves
373
+ * tool calls on the user's behalf, every later tool call — including
374
+ * destructive ones — executes without ever reaching agentrq, and nothing
375
+ * anywhere says so.
376
+ */
377
+ export async function handleAgentModeChange(connection, sessionId, currentModeId, modes) {
378
+ const available = modes?.availableModes;
379
+ if (!available?.length)
380
+ return;
381
+ const mode = available.find((m) => m.id === currentModeId);
382
+ // A mode the agent never advertised cannot be vouched for either, so it is
383
+ // treated the same as one that approves on our behalf.
384
+ if (mode && !AUTO_APPROVING_MODE.test(describeMode(mode))) {
385
+ modeReenforcements.delete(sessionId);
386
+ return;
387
+ }
388
+ const attempts = modeReenforcements.get(sessionId) ?? 0;
389
+ if (attempts >= MAX_MODE_REENFORCEMENTS) {
390
+ console.error(`[acp] ⚠️ Agent keeps returning session ${sessionId} to mode "${currentModeId}", ` +
391
+ `which approves tool calls without asking. Giving up after ` +
392
+ `${MAX_MODE_REENFORCEMENTS} attempts — tool calls may now execute without ` +
393
+ `agentrq approval.`);
394
+ return;
395
+ }
396
+ modeReenforcements.set(sessionId, attempts + 1);
397
+ console.error(`[acp] ⚠️ Agent moved session ${sessionId} into "${currentModeId}", which approves ` +
398
+ `tool calls without asking. Putting it back.`);
399
+ await enforceHumanApprovalMode(connection, {
400
+ sessionId,
401
+ modes: { availableModes: available, currentModeId },
402
+ });
403
+ }
223
404
  export function createAcpSessionSwitcher(connection, params, initialSessionId) {
224
405
  let currentSessionId = initialSessionId;
225
406
  const taskSessionMap = new Map();
@@ -309,6 +490,7 @@ export class TaskQueue {
309
490
  export function parseGatewayArgs(args) {
310
491
  const options = {
311
492
  maxConcurrency: 2,
493
+ permissionTimeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
312
494
  command: "run",
313
495
  allowUnverifiedAgent: false,
314
496
  rest: [],
@@ -328,6 +510,14 @@ export function parseGatewayArgs(args) {
328
510
  }
329
511
  break;
330
512
  }
513
+ case "--permission-timeout": {
514
+ const minutes = parseInt(value ?? "", 10);
515
+ if (!isNaN(minutes) && minutes >= 0) {
516
+ options.permissionTimeoutMs = minutes * 60_000;
517
+ i++;
518
+ }
519
+ break;
520
+ }
331
521
  case "--auth-method":
332
522
  if (value) {
333
523
  options.authMethodId = value;
@@ -356,6 +546,9 @@ export function parseGatewayArgs(args) {
356
546
  case "--list-agents":
357
547
  options.command = "list-agents";
358
548
  break;
549
+ case "--agent-info":
550
+ options.command = "agent-info";
551
+ break;
359
552
  case "--allow-unverified-agent":
360
553
  options.allowUnverifiedAgent = true;
361
554
  break;
@@ -443,12 +636,13 @@ export function assertAgentRunnable(command, usedRegistryId) {
443
636
  throw new Error(`Agent command "${command}" was not found. ${hint}`);
444
637
  }
445
638
  /**
446
- * Runs a one-shot auth command against the agent and shuts it down again.
639
+ * Runs a one-shot command against the agent and shuts it down again.
447
640
  *
448
- * These commands exist so a login can be done deliberately before any task
449
- * arrives rather than only when a session is refused.
641
+ * These commands exist so a login or a look at what the agent supports — can
642
+ * be done deliberately, before any task arrives, rather than only when a
643
+ * session is refused.
450
644
  */
451
- export async function runAuthCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId) {
645
+ export async function runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId) {
452
646
  const [cmd, ...cmdArgs] = acpCmdArgs;
453
647
  const agent = await openAgentConnection({
454
648
  acpCmdArgs,
@@ -459,6 +653,10 @@ export async function runAuthCommand(command, acpCmdArgs, agentrqConfig, mcpBrid
459
653
  try {
460
654
  const connection = agent.connection;
461
655
  const { authMethods, agentCapabilities } = agent.initResult;
656
+ if (command === "agent-info") {
657
+ console.log(describeAgentInfo(agent.initResult, acpCmdArgs.join(" ")));
658
+ return;
659
+ }
462
660
  if (command === "list-auth-methods") {
463
661
  console.log(`Authentication methods for "${acpCmdArgs.join(" ")}":\n${describeAuthMethods(authMethods)}`);
464
662
  if (supportsLogout(agentCapabilities)) {
@@ -504,6 +702,9 @@ AGENT
504
702
  if needed, instead of a command you supply.
505
703
  --list-agents List every agent in the registry, and how each one
506
704
  can run on this machine. Exits.
705
+ --agent-info What the agent says it supports — session
706
+ lifecycle, prompt content, MCP transports and
707
+ logins. Only a live handshake can tell you. Exits.
507
708
  --allow-unverified-agent Install a registry binary that publishes no
508
709
  checksum. Off by default: without a checksum there
509
710
  is no way to tell what was downloaded.
@@ -521,6 +722,10 @@ AUTHENTICATION
521
722
  BRIDGE
522
723
  --max-concurrency <number> How many tasks may prompt the agent at once.
523
724
  Defaults to 2.
725
+ --permission-timeout <min> How long a tool call waits for someone to approve
726
+ it before the turn is cancelled. Defaults to 30.
727
+ 0 waits indefinitely, which is what a wedged
728
+ gateway looks like — use it knowingly.
524
729
 
525
730
  OTHER
526
731
  --help, -h Show this help. Exits.
@@ -529,6 +734,7 @@ EXAMPLES
529
734
  acp-gateway --agent gemini Run Gemini from the registry
530
735
  acp-gateway -- gemini --acp Run an agent you installed
531
736
  acp-gateway --list-agents See what the registry offers
737
+ acp-gateway --agent-info --agent gemini See what that agent supports
532
738
  acp-gateway --login -- gemini --acp Log in before running anything
533
739
  acp-gateway --max-concurrency 4 -- gemini --acp
534
740
 
@@ -591,6 +797,7 @@ async function main() {
591
797
  agentrqConfig.env = { ...agentrqConfig.env, ...resolved.env };
592
798
  }
593
799
  authConfig.methodId = authMethodId;
800
+ permissionConfig.timeoutMs = options.permissionTimeoutMs;
594
801
  const taskQueue = new TaskQueue(maxConcurrency);
595
802
  // 3. Initialize MCP Bridge
596
803
  const mcpBridge = new MCPBridge(agentrqConfig);
@@ -600,7 +807,7 @@ async function main() {
600
807
  // actually needs to reach agentrq.
601
808
  if (command !== "run") {
602
809
  try {
603
- await runAuthCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId);
810
+ await runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId);
604
811
  }
605
812
  finally {
606
813
  await mcpBridge.close();
@@ -622,14 +829,25 @@ async function main() {
622
829
  lastTaskContent.set(taskId, content);
623
830
  }
624
831
  console.error("\n[bridge] Incoming task from MCP server. Forwarding to ACP agent...");
832
+ const queuedSeq = nextTaskSeq();
625
833
  taskQueue.run(async () => {
834
+ if (isTaskCancelled(taskId, queuedSeq)) {
835
+ console.error(`[bridge] Task ${taskId} was cancelled before execution started, skipping`);
836
+ return;
837
+ }
626
838
  try {
627
839
  const sessionInfo = await getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge);
840
+ if (isTaskCancelled(taskId, queuedSeq)) {
841
+ console.error(`[bridge] Task ${taskId} was cancelled during session setup, cancelling session`);
842
+ await sessionInfo.acpClient.cancelTurn(sessionInfo.sessionId);
843
+ return;
844
+ }
628
845
  const result = await sessionInfo.connection.prompt({
629
846
  sessionId: sessionInfo.sessionId,
630
847
  prompt: [{ type: "text", text: content }],
631
848
  });
632
849
  await sessionInfo.acpClient.flushReply(sessionInfo.sessionId);
850
+ await sessionInfo.acpClient.reportStopReason(sessionInfo.sessionId, result.stopReason);
633
851
  console.error(`\n[acp] Agent completed task. Reason: ${result.stopReason}`);
634
852
  }
635
853
  catch (err) {
@@ -639,6 +857,12 @@ async function main() {
639
857
  console.error("[bridge] Error queuing task:", err);
640
858
  });
641
859
  });
860
+ // Listen for task cancellation events from the MCP bridge
861
+ mcpBridge.on("cancel", ({ taskId, reason }) => {
862
+ handleTaskCancellation(taskId, reason).catch((err) => {
863
+ console.error("[bridge] Error handling task cancellation:", err);
864
+ });
865
+ });
642
866
  // Initial check for a pending task
643
867
  await checkForNextTask(mcpBridge, acpCmdArgs, configs, agentrqConfig, taskQueue);
644
868
  // Keep the process alive
@@ -662,6 +886,9 @@ async function main() {
662
886
  */
663
887
  export async function checkForNextTask(mcpBridge, acpCmdArgsOrConnection, configsOrSessionSwitcher, agentrqConfigOrAcpClient, taskQueue, acpCmdArgs, configs, agentrqConfig) {
664
888
  console.error("[bridge] Checking for next task via MCP server...");
889
+ // Stamped before the fetch, so a cancel that arrives while `getTask` is in
890
+ // flight (or while the session is being opened) still stops the task.
891
+ const queuedSeq = nextTaskSeq();
665
892
  try {
666
893
  const result = await mcpBridge.callTool("getTask");
667
894
  if (result.isError) {
@@ -684,6 +911,10 @@ export async function checkForNextTask(mcpBridge, acpCmdArgsOrConnection, config
684
911
  }
685
912
  console.error(`[bridge] Found task: "${text.slice(0, 50).replace(/\n/g, " ")}..."`);
686
913
  const runFn = async () => {
914
+ if (isTaskCancelled(taskId, queuedSeq)) {
915
+ console.error(`[bridge] Task ${taskId} was cancelled before execution started, skipping`);
916
+ return;
917
+ }
687
918
  let connectionToUse;
688
919
  let acpClientToUse;
689
920
  let sessionIdToUse;
@@ -712,11 +943,20 @@ export async function checkForNextTask(mcpBridge, acpCmdArgsOrConnection, config
712
943
  sessionIdToUse = sessionInfo.sessionId;
713
944
  acpClientToUse = sessionInfo.acpClient;
714
945
  }
946
+ // Spawning the agent and opening the session takes seconds; a cancel
947
+ // that lands in that window has no turn to stop yet, so check again
948
+ // before handing the agent the work.
949
+ if (isTaskCancelled(taskId, queuedSeq)) {
950
+ console.error(`[bridge] Task ${taskId} was cancelled during session setup, cancelling session`);
951
+ await acpClientToUse?.cancelTurn(sessionIdToUse);
952
+ return;
953
+ }
715
954
  const promptResult = await connectionToUse.prompt({
716
955
  sessionId: sessionIdToUse,
717
956
  prompt: [{ type: "text", text }],
718
957
  });
719
958
  await acpClientToUse.flushReply(sessionIdToUse);
959
+ await acpClientToUse.reportStopReason(sessionIdToUse, promptResult.stopReason);
720
960
  console.error(`\n[acp] Agent completed with: ${promptResult.stopReason}`);
721
961
  };
722
962
  if (taskQueue) {