@agentrq/acp-gateway 0.2.3 → 0.2.5

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/index.js CHANGED
@@ -7,22 +7,49 @@
7
7
  */
8
8
  import { spawn } from "node:child_process";
9
9
  import { Writable, Readable } from "node:stream";
10
- import { readFileSync } from "node:fs";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import * as path from "node:path";
11
12
  import * as acp from "@agentclientprotocol/sdk";
12
13
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
13
- import { loadMcpConfig, pickAgentrqServer } from "./config.js";
14
+ import { loadMcpConfig, pickAgentrqServer, } from "./config.js";
14
15
  import { MCPBridge } from "./mcpClient.js";
15
- export function mapMcpServers(configs) {
16
- return configs.map((cfg) => {
17
- if (cfg.type === "http") {
18
- return {
19
- type: "http",
20
- name: cfg.name,
21
- url: cfg.url,
22
- headers: Object.entries(cfg.headers || {}).map(([name, value]) => ({ name, value })),
23
- };
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;
24
44
  }
25
- 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") {
26
53
  return {
27
54
  name: cfg.name,
28
55
  command: cfg.command,
@@ -30,45 +57,78 @@ export function mapMcpServers(configs) {
30
57
  env: Object.entries(cfg.env || {}).map(([name, value]) => ({ name, value })),
31
58
  };
32
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
+ };
33
66
  });
34
67
  }
35
- import { AgentRQACPClient } from "./acpClient.js";
68
+ import { AgentRQACPClient, DEFAULT_PERMISSION_TIMEOUT_MS } from "./acpClient.js";
69
+ import { describeAuthMethods, isAuthRequiredError, login, logout, supportsLogout, } from "./auth.js";
70
+ import { resolveAgentLaunch } from "./agentInstall.js";
71
+ import { describeAgentInfo } from "./agentInfo.js";
72
+ import { describeAgents, fetchRegistry, hostPlatformTarget, } from "./registry.js";
36
73
  import { extractTaskIdFromMeta, extractTaskIdFromText, } from "./taskIdentity.js";
37
74
  const lastTaskContent = new Map();
38
75
  export const activeSessions = new Map();
39
- export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge) {
40
- const key = taskId || "default";
41
- const existing = activeSessions.get(key);
42
- if (existing) {
43
- return existing;
44
- }
76
+ /** Login preferences taken from the CLI, consulted whenever an agent demands auth. */
77
+ export const authConfig = {};
78
+ /** How long tool calls wait for a human, taken from the CLI at startup. */
79
+ export const permissionConfig = {};
80
+ /**
81
+ * Whether a human is sitting in front of this process.
82
+ *
83
+ * Terminal logins hand the agent our own stdio, and the "which login method?"
84
+ * prompt needs someone to answer it — neither works when the gateway runs
85
+ * unattended under a supervisor.
86
+ */
87
+ export function isInteractiveTerminal() {
88
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY);
89
+ }
90
+ /**
91
+ * Spawns an ACP agent, wires the JSON-RPC streams to it and completes the
92
+ * `initialize` handshake, returning the connection plus what the agent said
93
+ * about itself — including the login methods it advertises.
94
+ */
95
+ export async function openAgentConnection({ acpCmdArgs, mcpBridge, env, label, taskId, onExit, }) {
45
96
  const [cmd, ...cmdArgs] = acpCmdArgs;
46
- console.error(`[acp] Spawning agent for task ${key}: ${cmd} ${cmdArgs.join(" ")}`);
97
+ console.error(`[acp] Spawning agent for ${label}: ${cmd} ${cmdArgs.join(" ")}`);
47
98
  const agentProcess = spawn(cmd, cmdArgs, {
48
99
  stdio: ["pipe", "pipe", "inherit"],
49
- env: { ...process.env, ...agentrqConfig.env },
100
+ env: { ...process.env, ...env },
101
+ });
102
+ const acpClient = new AgentRQACPClient(mcpBridge, () => taskId, {
103
+ permissionTimeoutMs: permissionConfig.timeoutMs,
50
104
  });
51
105
  // Guard against unhandled child-process failures. Without these listeners a
52
106
  // crashed agent (e.g. on network loss) leaves a broken stdin pipe; the next
53
107
  // write raises EPIPE as an uncaught error and takes the gateway down with it.
54
108
  agentProcess.on("error", (err) => {
55
- console.error(`[acp] Agent process error for task ${key}:`, err.message);
56
- activeSessions.delete(key);
109
+ console.error(`[acp] Agent process error for ${label}:`, err.message);
110
+ acpClient.cancelPendingPermissions(`agent process for ${label} failed`);
111
+ onExit?.();
57
112
  });
58
113
  agentProcess.on("exit", (code, signal) => {
59
- console.error(`[acp] Agent process for task ${key} exited (code=${code}, signal=${signal})`);
60
- activeSessions.delete(key);
114
+ console.error(`[acp] Agent process for ${label} exited (code=${code}, signal=${signal})`);
115
+ // Nothing will act on these answers now, but the tool calls waiting on them
116
+ // are holding task-queue slots that would never be given back.
117
+ acpClient.cancelPendingPermissions(`agent process for ${label} exited`);
118
+ onExit?.();
61
119
  });
62
120
  // stdin can emit EPIPE when the child dies mid-write; swallow it so it
63
121
  // doesn't surface as an uncaught exception.
64
122
  agentProcess.stdin?.on("error", (err) => {
65
- console.error(`[acp] Agent stdin error for task ${key}:`, err.message);
123
+ console.error(`[acp] Agent stdin error for ${label}:`, err.message);
66
124
  });
67
125
  const input = Writable.toWeb(agentProcess.stdin);
68
126
  const output = Readable.toWeb(agentProcess.stdout);
69
- const acpClient = new AgentRQACPClient(mcpBridge, () => taskId);
70
127
  const stream = acp.ndJsonStream(input, output);
71
128
  const connection = new acp.ClientSideConnection((_agent) => acpClient, stream);
129
+ // Stopping a turn is only possible once the connection exists, and the
130
+ // connection is built around the client — so it is handed over afterwards.
131
+ acpClient.setSessionCanceller((sessionId) => connection.cancel({ sessionId }));
72
132
  const initResult = await connection.initialize({
73
133
  protocolVersion: acp.PROTOCOL_VERSION,
74
134
  clientCapabilities: {
@@ -80,21 +140,73 @@ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqCon
80
140
  form: {},
81
141
  url: {},
82
142
  },
143
+ // Only claim terminal logins when we can actually hand the agent a
144
+ // terminal; otherwise the agent may offer a method we cannot run.
145
+ auth: {
146
+ terminal: isInteractiveTerminal(),
147
+ },
83
148
  },
84
149
  });
85
- console.error(`[acp] Connected to agent for task ${key} (protocol v${initResult.protocolVersion})`);
150
+ console.error(`[acp] Connected to agent for ${label} (protocol v${initResult.protocolVersion})`);
151
+ if (initResult.authMethods?.length) {
152
+ console.error(`[auth] Agent offers these login methods:\n${describeAuthMethods(initResult.authMethods)}`);
153
+ }
154
+ return { process: agentProcess, connection, acpClient, initResult };
155
+ }
156
+ /**
157
+ * Starts a session, logging in first if the agent refuses without one.
158
+ *
159
+ * Agents only report `auth_required` when the session is requested, so this is
160
+ * where a first-run login belongs: authenticate once, then retry.
161
+ */
162
+ export async function createSessionWithAuth(connection, params, auth) {
163
+ try {
164
+ return await connection.newSession(params);
165
+ }
166
+ catch (err) {
167
+ if (!isAuthRequiredError(err))
168
+ throw err;
169
+ console.error("[auth] Agent requires authentication before a session can start.");
170
+ await login({ ...auth, connection: connection });
171
+ return await connection.newSession(params);
172
+ }
173
+ }
174
+ export async function getOrCreateSession(taskId, acpCmdArgs, configs, agentrqConfig, mcpBridge) {
175
+ const key = taskId || "default";
176
+ const existing = activeSessions.get(key);
177
+ if (existing) {
178
+ return existing;
179
+ }
180
+ const [cmd, ...cmdArgs] = acpCmdArgs;
181
+ const { process: agentProcess, connection, acpClient, initResult } = await openAgentConnection({
182
+ acpCmdArgs,
183
+ mcpBridge,
184
+ env: agentrqConfig.env,
185
+ label: `task ${key}`,
186
+ taskId,
187
+ onExit: () => activeSessions.delete(key),
188
+ });
86
189
  const newSessionParams = {
87
190
  cwd: process.cwd(),
88
- mcpServers: mapMcpServers(configs),
191
+ mcpServers: mapMcpServers(configs, initResult.agentCapabilities),
89
192
  };
90
- const sessionResult = await connection.newSession(newSessionParams);
193
+ const sessionResult = await createSessionWithAuth(connection, newSessionParams, {
194
+ methods: initResult.authMethods,
195
+ launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
196
+ preferredId: authConfig.methodId,
197
+ interactive: isInteractiveTerminal(),
198
+ });
91
199
  console.error(`[acp] Created session ${sessionResult.sessionId} for task ${key}`);
92
200
  await enforceHumanApprovalMode(connection, sessionResult);
201
+ // The mode is pinned once here, but agents may move themselves back out of
202
+ // it, so keep watching for the rest of the session's life.
203
+ acpClient.setModeChangeHandler((changedSessionId, modeId) => handleAgentModeChange(connection, changedSessionId, modeId, sessionResult.modes));
93
204
  const sessionInfo = {
94
205
  process: agentProcess,
95
206
  connection,
96
207
  acpClient,
97
208
  sessionId: sessionResult.sessionId,
209
+ initResult,
98
210
  };
99
211
  activeSessions.set(key, sessionInfo);
100
212
  return sessionInfo;
@@ -155,6 +267,50 @@ export async function enforceHumanApprovalMode(connection, sessionResult) {
155
267
  console.error(`[acp] ⚠️ Failed to set session mode to "${modeId}" — tool calls may execute without agentrq approval:`, err);
156
268
  }
157
269
  }
270
+ /**
271
+ * How many times the gateway will drag one session back into a mode that asks
272
+ * the human. An agent that keeps switching back is not going to stop, and an
273
+ * unbounded fight with it would be an endless stream of setSessionMode calls.
274
+ */
275
+ const MAX_MODE_REENFORCEMENTS = 3;
276
+ /** sessionId → how many times its mode has already been put back. */
277
+ const modeReenforcements = new Map();
278
+ /**
279
+ * Puts a session back into a mode that asks the human, after the agent moved
280
+ * itself out of one.
281
+ *
282
+ * Agents may change modes on their own. If one moves into a mode that approves
283
+ * tool calls on the user's behalf, every later tool call — including
284
+ * destructive ones — executes without ever reaching agentrq, and nothing
285
+ * anywhere says so.
286
+ */
287
+ export async function handleAgentModeChange(connection, sessionId, currentModeId, modes) {
288
+ const available = modes?.availableModes;
289
+ if (!available?.length)
290
+ return;
291
+ const mode = available.find((m) => m.id === currentModeId);
292
+ // A mode the agent never advertised cannot be vouched for either, so it is
293
+ // treated the same as one that approves on our behalf.
294
+ if (mode && !AUTO_APPROVING_MODE.test(describeMode(mode))) {
295
+ modeReenforcements.delete(sessionId);
296
+ return;
297
+ }
298
+ const attempts = modeReenforcements.get(sessionId) ?? 0;
299
+ if (attempts >= MAX_MODE_REENFORCEMENTS) {
300
+ console.error(`[acp] ⚠️ Agent keeps returning session ${sessionId} to mode "${currentModeId}", ` +
301
+ `which approves tool calls without asking. Giving up after ` +
302
+ `${MAX_MODE_REENFORCEMENTS} attempts — tool calls may now execute without ` +
303
+ `agentrq approval.`);
304
+ return;
305
+ }
306
+ modeReenforcements.set(sessionId, attempts + 1);
307
+ console.error(`[acp] ⚠️ Agent moved session ${sessionId} into "${currentModeId}", which approves ` +
308
+ `tool calls without asking. Putting it back.`);
309
+ await enforceHumanApprovalMode(connection, {
310
+ sessionId,
311
+ modes: { availableModes: available, currentModeId },
312
+ });
313
+ }
158
314
  export function createAcpSessionSwitcher(connection, params, initialSessionId) {
159
315
  let currentSessionId = initialSessionId;
160
316
  const taskSessionMap = new Map();
@@ -237,33 +393,337 @@ export class TaskQueue {
237
393
  return this.queue.length;
238
394
  }
239
395
  }
396
+ /**
397
+ * Parses the gateway's own flags — everything before the `--` that introduces
398
+ * the agent command.
399
+ */
400
+ export function parseGatewayArgs(args) {
401
+ const options = {
402
+ maxConcurrency: 2,
403
+ permissionTimeoutMs: DEFAULT_PERMISSION_TIMEOUT_MS,
404
+ command: "run",
405
+ allowUnverifiedAgent: false,
406
+ rest: [],
407
+ };
408
+ for (let i = 0; i < args.length; i++) {
409
+ // A following token is this flag's value only when it isn't a flag itself,
410
+ // so `--login` can stand alone or take a method id.
411
+ const next = args[i + 1];
412
+ const value = next !== undefined && !next.startsWith("-") ? next : undefined;
413
+ switch (args[i]) {
414
+ case "--max-concurrency":
415
+ case "--maxConcurrency": {
416
+ const parsed = parseInt(value ?? "", 10);
417
+ if (!isNaN(parsed)) {
418
+ options.maxConcurrency = parsed;
419
+ i++;
420
+ }
421
+ break;
422
+ }
423
+ case "--permission-timeout": {
424
+ const minutes = parseInt(value ?? "", 10);
425
+ if (!isNaN(minutes) && minutes >= 0) {
426
+ options.permissionTimeoutMs = minutes * 60_000;
427
+ i++;
428
+ }
429
+ break;
430
+ }
431
+ case "--auth-method":
432
+ if (value) {
433
+ options.authMethodId = value;
434
+ i++;
435
+ }
436
+ break;
437
+ case "--login":
438
+ options.command = "login";
439
+ if (value) {
440
+ options.authMethodId = value;
441
+ i++;
442
+ }
443
+ break;
444
+ case "--logout":
445
+ options.command = "logout";
446
+ break;
447
+ case "--list-auth-methods":
448
+ options.command = "list-auth-methods";
449
+ break;
450
+ case "--agent":
451
+ if (value) {
452
+ options.agentId = value;
453
+ i++;
454
+ }
455
+ break;
456
+ case "--list-agents":
457
+ options.command = "list-agents";
458
+ break;
459
+ case "--agent-info":
460
+ options.command = "agent-info";
461
+ break;
462
+ case "--allow-unverified-agent":
463
+ options.allowUnverifiedAgent = true;
464
+ break;
465
+ case "--registry-url":
466
+ if (value) {
467
+ options.registryUrl = value;
468
+ i++;
469
+ }
470
+ break;
471
+ case "--help":
472
+ case "-h":
473
+ options.command = "help";
474
+ break;
475
+ default:
476
+ // Anything unrecognised belongs to the agent command, which may be
477
+ // given without a `--` separator.
478
+ options.rest.push(args[i]);
479
+ }
480
+ }
481
+ return options;
482
+ }
483
+ /**
484
+ * Prints every agent the registry publishes, and how each one can be run here.
485
+ */
486
+ export async function runListAgents(registryUrl, fetchImpl = fetch) {
487
+ const registry = await fetchRegistry(registryUrl, fetchImpl);
488
+ const target = hostPlatformTarget();
489
+ console.log(`ACP registry v${registry.version} — ${registry.agents.length} agents ` +
490
+ `(this machine: ${target ?? `${process.platform}/${process.arch}, unsupported`})\n`);
491
+ console.log(describeAgents(registry, target));
492
+ console.log(`\nRun one with: acp-gateway --agent <id>`);
493
+ }
494
+ /**
495
+ * Works out which command actually starts the agent.
496
+ *
497
+ * `--agent <id>` resolves through the registry — installing the agent when the
498
+ * only distribution is a binary — and otherwise the command given after `--`
499
+ * is used as-is.
500
+ */
501
+ export async function resolveAgentCommand(options, explicitCommand, fetchImpl = fetch) {
502
+ if (!options.agentId)
503
+ return { command: explicitCommand };
504
+ const registry = await fetchRegistry(options.registryUrl, fetchImpl);
505
+ const spec = await resolveAgentLaunch({
506
+ id: options.agentId,
507
+ registry,
508
+ platformTarget: hostPlatformTarget(),
509
+ allowUnverified: options.allowUnverifiedAgent,
510
+ fetchImpl,
511
+ });
512
+ console.error(`[registry] Running "${options.agentId}" via ${spec.kind}: ${spec.command} ${spec.args.join(" ")}`);
513
+ return { command: [spec.command, ...spec.args], env: spec.env };
514
+ }
515
+ /**
516
+ * Whether a command can actually be run.
517
+ *
518
+ * A path is checked directly; a bare name is looked for along PATH, honouring
519
+ * PATHEXT on Windows where an executable is rarely named without a suffix.
520
+ */
521
+ export function isRunnable(command, env = process.env, platform = process.platform) {
522
+ if (command.includes("/") || (platform === "win32" && command.includes("\\"))) {
523
+ return existsSync(command);
524
+ }
525
+ const extensions = platform === "win32" ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
526
+ const separator = platform === "win32" ? ";" : ":";
527
+ return (env.PATH ?? "")
528
+ .split(separator)
529
+ .filter(Boolean)
530
+ .some((dir) => extensions.some((ext) => existsSync(path.join(dir, command + ext))));
531
+ }
532
+ /**
533
+ * Refuses to start with an agent that cannot be run.
534
+ *
535
+ * The agent is not spawned until the first task arrives, so without this a
536
+ * mistyped command — or a registry id passed as if it were one — starts a
537
+ * gateway that looks healthy and only fails much later, out of sight.
538
+ */
539
+ export function assertAgentRunnable(command, usedRegistryId) {
540
+ if (isRunnable(command))
541
+ return;
542
+ const hint = usedRegistryId
543
+ ? `The registry says to run it as "${command}", which is not installed.`
544
+ : `If "${command}" is an ACP registry agent id, run it with --agent ${command} ` +
545
+ `(--list-agents shows what is published).`;
546
+ throw new Error(`Agent command "${command}" was not found. ${hint}`);
547
+ }
548
+ /**
549
+ * Runs a one-shot command against the agent and shuts it down again.
550
+ *
551
+ * These commands exist so a login — or a look at what the agent supports — can
552
+ * be done deliberately, before any task arrives, rather than only when a
553
+ * session is refused.
554
+ */
555
+ export async function runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId) {
556
+ const [cmd, ...cmdArgs] = acpCmdArgs;
557
+ const agent = await openAgentConnection({
558
+ acpCmdArgs,
559
+ mcpBridge,
560
+ env: agentrqConfig.env,
561
+ label: command,
562
+ });
563
+ try {
564
+ const connection = agent.connection;
565
+ const { authMethods, agentCapabilities } = agent.initResult;
566
+ if (command === "agent-info") {
567
+ console.log(describeAgentInfo(agent.initResult, acpCmdArgs.join(" ")));
568
+ return;
569
+ }
570
+ if (command === "list-auth-methods") {
571
+ console.log(`Authentication methods for "${acpCmdArgs.join(" ")}":\n${describeAuthMethods(authMethods)}`);
572
+ if (supportsLogout(agentCapabilities)) {
573
+ console.log("\nThe agent also supports --logout.");
574
+ }
575
+ return;
576
+ }
577
+ if (command === "logout") {
578
+ await logout(connection, agentCapabilities);
579
+ return;
580
+ }
581
+ await login({
582
+ connection,
583
+ methods: authMethods,
584
+ launch: { command: cmd, args: cmdArgs, env: agentrqConfig.env },
585
+ preferredId: authMethodId,
586
+ interactive: isInteractiveTerminal(),
587
+ });
588
+ }
589
+ finally {
590
+ agent.process.kill();
591
+ }
592
+ }
593
+ /**
594
+ * The full help text.
595
+ *
596
+ * Shown for `--help`, and when the gateway is run with nothing to do — at
597
+ * which point the reason someone is looking at the terminal is that they do
598
+ * not yet know what to type.
599
+ */
600
+ export function helpText(version = pkg.version) {
601
+ return `acp-gateway ${version} — bridges an ACP agent to an agentrq workspace.
602
+
603
+ USAGE
604
+ acp-gateway [options] -- <agent-command> [agent-args...]
605
+ acp-gateway [options] --agent <registry-id>
606
+
607
+ The agent is either a command you supply after \`--\`, or an id from the ACP
608
+ registry. Everything after \`--\` is passed to the agent untouched.
609
+
610
+ AGENT
611
+ --agent <registry-id> Run an agent from the ACP registry, installing it
612
+ if needed, instead of a command you supply.
613
+ --list-agents List every agent in the registry, and how each one
614
+ can run on this machine. Exits.
615
+ --agent-info What the agent says it supports — session
616
+ lifecycle, prompt content, MCP transports and
617
+ logins. Only a live handshake can tell you. Exits.
618
+ --allow-unverified-agent Install a registry binary that publishes no
619
+ checksum. Off by default: without a checksum there
620
+ is no way to tell what was downloaded.
621
+ --registry-url <url> Read a different registry index, for pinning it or
622
+ for testing.
623
+
624
+ AUTHENTICATION
625
+ --list-auth-methods List the login methods the agent offers. Exits.
626
+ --login [method-id] Log in to the agent. With no id, and a terminal to
627
+ ask in, you are asked which method to use. Exits.
628
+ --logout Log out of the agent, where it supports it. Exits.
629
+ --auth-method <id> The method to use when the agent demands a login
630
+ mid-run. Defaults to choosing one automatically.
631
+
632
+ BRIDGE
633
+ --max-concurrency <number> How many tasks may prompt the agent at once.
634
+ Defaults to 2.
635
+ --permission-timeout <min> How long a tool call waits for someone to approve
636
+ it before the turn is cancelled. Defaults to 30.
637
+ 0 waits indefinitely, which is what a wedged
638
+ gateway looks like — use it knowingly.
639
+
640
+ OTHER
641
+ --help, -h Show this help. Exits.
642
+
643
+ EXAMPLES
644
+ acp-gateway --agent gemini Run Gemini from the registry
645
+ acp-gateway -- gemini --acp Run an agent you installed
646
+ acp-gateway --list-agents See what the registry offers
647
+ acp-gateway --agent-info --agent gemini See what that agent supports
648
+ acp-gateway --login -- gemini --acp Log in before running anything
649
+ acp-gateway --max-concurrency 4 -- gemini --acp
650
+
651
+ The workspace comes from .mcp.json, searched for in the current directory and up
652
+ to three directories above it.`;
653
+ }
654
+ export function printHelp() {
655
+ console.log(helpText());
656
+ }
240
657
  async function main() {
241
- console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
242
658
  const args = process.argv.slice(2);
243
- // Find where the command starts (after -- if provided, or just all args)
659
+ // Everything after `--` is the agent command. Without a separator the
660
+ // gateway's own options are still recognised and whatever is left over is
661
+ // the command, so `acp-gateway --agent gemini` needs no trailing `--`.
244
662
  const cmdStartIndex = args.indexOf("--");
245
- const acpCmdArgs = cmdStartIndex !== -1 ? args.slice(cmdStartIndex + 1) : args;
246
- if (acpCmdArgs.length === 0) {
247
- console.log("Usage: acp-gateway [--max-concurrency <number>] -- <acp-server-command> [args...]");
248
- console.log("Example: acp-gateway --max-concurrency 4 -- gemini --acp");
663
+ const gatewayArgs = cmdStartIndex !== -1 ? args.slice(0, cmdStartIndex) : args;
664
+ const options = parseGatewayArgs(gatewayArgs);
665
+ const explicitCommand = cmdStartIndex !== -1 ? args.slice(cmdStartIndex + 1) : options.rest;
666
+ const { maxConcurrency, command, authMethodId } = options;
667
+ if (command === "help") {
668
+ printHelp();
669
+ process.exit(0);
670
+ }
671
+ // Listing the registry needs neither a workspace nor an agent.
672
+ if (command === "list-agents") {
673
+ await runListAgents(options.registryUrl);
674
+ process.exit(0);
675
+ }
676
+ // Nothing to run: the reason someone is looking at the terminal now is that
677
+ // they do not yet know what to type, so show the help rather than an error
678
+ // about a workspace they have not got to yet.
679
+ if (!options.agentId && explicitCommand.length === 0) {
680
+ printHelp();
249
681
  process.exit(1);
250
682
  }
683
+ console.log(`Starting [acp-gateway] ${pkg.name} v${pkg.version}`);
251
684
  // 1. Load MCP Config
252
685
  const configs = loadMcpConfig();
253
686
  const agentrqConfig = pickAgentrqServer(configs);
254
- // Parse max concurrency from CLI args, falling back to default of 2
255
- let maxConcurrency = 2;
256
- const gatewayArgs = cmdStartIndex !== -1 ? args.slice(0, cmdStartIndex) : [];
257
- const maxConcurrencyIdx = gatewayArgs.findIndex((arg) => arg === "--max-concurrency" || arg === "--maxConcurrency");
258
- if (maxConcurrencyIdx !== -1 && maxConcurrencyIdx + 1 < gatewayArgs.length) {
259
- const val = parseInt(gatewayArgs[maxConcurrencyIdx + 1], 10);
260
- if (!isNaN(val)) {
261
- maxConcurrency = val;
262
- }
687
+ // 2. Work out what actually starts the agent a registry id, or the command
688
+ // the user gave.
689
+ // These failures are all things the user can act on — an unknown registry
690
+ // id, no build for this platform, an unverifiable download, a mistyped
691
+ // command so they get a sentence rather than a stack trace.
692
+ const fail = (err) => {
693
+ console.error(`[acp-gateway] ${err instanceof Error ? err.message : err}`);
694
+ return process.exit(1);
695
+ };
696
+ const resolved = await resolveAgentCommand(options, explicitCommand).catch(fail);
697
+ const acpCmdArgs = resolved.command;
698
+ try {
699
+ assertAgentRunnable(acpCmdArgs[0], Boolean(options.agentId));
700
+ }
701
+ catch (err) {
702
+ fail(err);
703
+ }
704
+ if (resolved.env) {
705
+ // The registry entry's env is part of how that agent must be launched, so
706
+ // it travels with the command into every session spawned from it.
707
+ agentrqConfig.env = { ...agentrqConfig.env, ...resolved.env };
263
708
  }
709
+ authConfig.methodId = authMethodId;
710
+ permissionConfig.timeoutMs = options.permissionTimeoutMs;
264
711
  const taskQueue = new TaskQueue(maxConcurrency);
265
- // 2. Initialize MCP Bridge
712
+ // 3. Initialize MCP Bridge
266
713
  const mcpBridge = new MCPBridge(agentrqConfig);
714
+ // Auth commands talk to the agent and exit; they never start bridging tasks.
715
+ // They run before the bridge connects, so a first-time login still works when
716
+ // the workspace is unreachable — `callTool` connects on demand if the login
717
+ // actually needs to reach agentrq.
718
+ if (command !== "run") {
719
+ try {
720
+ await runAgentCommand(command, acpCmdArgs, agentrqConfig, mcpBridge, authMethodId);
721
+ }
722
+ finally {
723
+ await mcpBridge.close();
724
+ }
725
+ process.exit(0);
726
+ }
267
727
  await mcpBridge.connect();
268
728
  try {
269
729
  // Bridge: MCP -> ACP
@@ -287,6 +747,7 @@ async function main() {
287
747
  prompt: [{ type: "text", text: content }],
288
748
  });
289
749
  await sessionInfo.acpClient.flushReply(sessionInfo.sessionId);
750
+ await sessionInfo.acpClient.reportStopReason(sessionInfo.sessionId, result.stopReason);
290
751
  console.error(`\n[acp] Agent completed task. Reason: ${result.stopReason}`);
291
752
  }
292
753
  catch (err) {
@@ -374,6 +835,7 @@ export async function checkForNextTask(mcpBridge, acpCmdArgsOrConnection, config
374
835
  prompt: [{ type: "text", text }],
375
836
  });
376
837
  await acpClientToUse.flushReply(sessionIdToUse);
838
+ await acpClientToUse.reportStopReason(sessionIdToUse, promptResult.stopReason);
377
839
  console.error(`\n[acp] Agent completed with: ${promptResult.stopReason}`);
378
840
  };
379
841
  if (taskQueue) {