@wrongstack/acp 1.0.3 → 1.0.4

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.
@@ -1,4 +1,4 @@
1
- export type { ClientCapabilities, RunTurn, RunTurnApi, RunTurnInput, RunTurnPermissionRequest, RunTurnResult, SessionPersistence, } from './protocol-handler.js';
1
+ export type { ClientCapabilities, McpServer, RunTurn, RunTurnApi, RunTurnInput, RunTurnPermissionRequest, RunTurnResult, SessionPersistence, } from './protocol-handler.js';
2
2
  export { ACPProtocolHandler } from './protocol-handler.js';
3
3
  export type { ACPServerAgentTurnOptions } from './server-agent-turn.js';
4
4
  export { makeACPServerAgentTurn } from './server-agent-turn.js';
@@ -3,9 +3,9 @@
3
3
  * Keeping these declarations separate leaves the handler focused on request dispatch.
4
4
  */
5
5
  import type { ACPMessage } from '../types/acp-messages.js';
6
- import { ACP_PROTOCOL_VERSION, type ContentBlock, type PermissionOption, type PlanEntry, type RequestPermissionOutcome, type StopReason, type ToolKind, type UsageCost } from '../types/acp-v1.js';
6
+ import { ACP_PROTOCOL_VERSION, type ContentBlock, type McpServer, type PermissionOption, type PlanEntry, type RequestPermissionOutcome, type StopReason, type ToolKind, type UsageCost } from '../types/acp-v1.js';
7
7
  import type { AgentServerTransport } from './stdio-transport.js';
8
- export type { ACPMessage, ContentBlock, RequestPermissionOutcome };
8
+ export type { ACPMessage, ContentBlock, McpServer, RequestPermissionOutcome };
9
9
  export { ACP_PROTOCOL_VERSION };
10
10
  type WireMessage = {
11
11
  jsonrpc?: '2.0';
@@ -33,6 +33,19 @@ export interface RunTurnInput {
33
33
  prompt: readonly ContentBlock[];
34
34
  /** Cancelled when the client sends `session/cancel` for this session. */
35
35
  signal: AbortSignal;
36
+ /** Session working directory from `session/new` (absolute). */
37
+ cwd?: string | undefined;
38
+ /**
39
+ * MCP servers the CLIENT asked this session to connect to, as supplied in
40
+ * `session/new` / `session/load` / `session/fork`. Validated at the wire
41
+ * boundary (see `parseMcpServers`), so entries here are well-formed.
42
+ *
43
+ * The runTurn implementation owns the connection: it is what holds the tool
44
+ * registry these servers' tools must land in. Nothing here is optional
45
+ * decoration — an editor that passes a server and gets no tools has been
46
+ * told a successful `session/new` about work that never happened.
47
+ */
48
+ mcpServers?: readonly McpServer[] | undefined;
36
49
  }
37
50
  export interface RunTurnResult {
38
51
  stopReason: StopReason;
@@ -127,6 +140,13 @@ export interface SessionState {
127
140
  updatedAt: string;
128
141
  /** Optional human title. */
129
142
  title?: string;
143
+ /**
144
+ * The client's MCP servers for this session, captured at `session/new`
145
+ * (or `load`/`fork`) and replayed into every turn. Held on the session
146
+ * because the connection is session-scoped: two ACP sessions may ask for
147
+ * different servers, and closing one must not tear down the other's.
148
+ */
149
+ mcpServers?: readonly McpServer[] | undefined;
130
150
  }
131
151
  /** MCP-style session mode advertised in current_mode_update. */
132
152
  export interface SessionMode {
@@ -1,6 +1,6 @@
1
1
  import { type ProtocolHandlerOptions, WRONGSTACK_VERSION } from './protocol-contract.js';
2
2
  import { errorToJsonRpc } from './protocol-session-ops.js';
3
- export type { AgentCapabilities, ClientCapabilities, PromptCapabilities, ProtocolHandlerOptions, RunTurn, RunTurnApi, RunTurnInput, RunTurnPermissionRequest, RunTurnResult, SessionConfigOption, SessionMode, SessionPersistence, SessionState, } from './protocol-contract.js';
3
+ export type { AgentCapabilities, ClientCapabilities, McpServer, PromptCapabilities, ProtocolHandlerOptions, RunTurn, RunTurnApi, RunTurnInput, RunTurnPermissionRequest, RunTurnResult, SessionConfigOption, SessionMode, SessionPersistence, SessionState, } from './protocol-contract.js';
4
4
  export { WRONGSTACK_VERSION };
5
5
  export declare class ACPProtocolHandler {
6
6
  private readonly transport;
@@ -1,8 +1,19 @@
1
- import { type ClientCapabilities, type RunTurnApi, type SessionConfigOption, type SessionMode } from './protocol-contract.js';
1
+ import { type ClientCapabilities, type McpServer, type RunTurnApi, type SessionConfigOption, type SessionMode } from './protocol-contract.js';
2
2
  /** Single global mode id, sufficient for v1. */
3
3
  export declare const DEFAULT_MODE_ID = "code";
4
4
  export declare const DEFAULT_MAX_SESSIONS = 64;
5
5
  export declare const DEFAULT_MODES: readonly SessionMode[];
6
+ /**
7
+ * Validate the `mcpServers` array a client sends with `session/new`,
8
+ * `session/load` or `session/fork`.
9
+ *
10
+ * Malformed entries are dropped rather than rejecting the whole request: an
11
+ * editor that ships one bad server config should still get a working session.
12
+ * A dropped entry is reported through `onSkipped` so the caller can tell the
13
+ * client instead of silently discarding what it asked for — silent discard is
14
+ * exactly how this array came to be ignored in the first place.
15
+ */
16
+ export declare function parseMcpServers(raw: unknown, onSkipped?: (reason: string) => void): McpServer[];
6
17
  export declare function resolveSessionCwd(requested: string): Promise<string | null>;
7
18
  export declare function errorToJsonRpc(err: unknown): {
8
19
  code: number;
@@ -39,7 +39,7 @@
39
39
  * `{stopReason: 'cancelled'}`.
40
40
  */
41
41
  import type { Agent, AgentInput } from '@wrongstack/core/agent';
42
- import type { ContentBlock, PlanEntry, StopReason, ToolKind, UsageCost } from '../types/acp-v1.js';
42
+ import type { ContentBlock, McpServer, PlanEntry, StopReason, ToolKind, UsageCost } from '../types/acp-v1.js';
43
43
  import type { RunTurn, RunTurnApi, RunTurnResult } from './protocol-handler.js';
44
44
  export interface ACPServerAgentTurnOptions {
45
45
  /**
@@ -53,8 +53,12 @@ export interface ACPServerAgentTurnOptions {
53
53
  * advertises those capabilities. A factory that wires it builds a
54
54
  * client-backed permission policy and ACP-backed fs/terminal tools
55
55
  * instead of silently auto-approving against the local disk.
56
+ *
57
+ * `mcpServers` is the client's per-session MCP server list from
58
+ * `session/new`. The factory owns the session's tool registry, so it is the
59
+ * only place those servers can be connected and their tools registered.
56
60
  */
57
- agentFor: (sessionId: string, cwd: string, api?: RunTurnApi) => Promise<Agent> | Agent;
61
+ agentFor: (sessionId: string, cwd: string, api?: RunTurnApi, mcpServers?: readonly McpServer[]) => Promise<Agent> | Agent;
58
62
  /**
59
63
  * Hard wall-clock cap for one turn. The agent's own provider
60
64
  * timeout is layered under this; this cap is a safety belt.
package/dist/agent.js CHANGED
@@ -46,6 +46,64 @@ var DEFAULT_MODES = [
46
46
  description: "Default agent mode for code-generation tasks."
47
47
  }
48
48
  ];
49
+ function parseMcpServers(raw, onSkipped) {
50
+ if (!Array.isArray(raw)) return [];
51
+ const out = [];
52
+ for (const entry of raw) {
53
+ if (typeof entry !== "object" || entry === null) {
54
+ onSkipped?.("entry is not an object");
55
+ continue;
56
+ }
57
+ const e = entry;
58
+ const name = typeof e.name === "string" ? e.name.trim() : "";
59
+ if (name === "") {
60
+ onSkipped?.("entry has no name");
61
+ continue;
62
+ }
63
+ const type = typeof e.type === "string" ? e.type : "stdio";
64
+ if (type === "http" || type === "sse") {
65
+ if (typeof e.url !== "string" || e.url === "") {
66
+ onSkipped?.(`"${name}": ${type} server has no url`);
67
+ continue;
68
+ }
69
+ const headers = parseNameValuePairs(e.headers);
70
+ const url = e.url;
71
+ out.push(
72
+ type === "http" ? { type: "http", name, url, ...headers ? { headers } : {} } : { type: "sse", name, url, ...headers ? { headers } : {} }
73
+ );
74
+ continue;
75
+ }
76
+ if (type !== "stdio") {
77
+ onSkipped?.(`"${name}": unknown transport "${type}"`);
78
+ continue;
79
+ }
80
+ if (typeof e.command !== "string" || e.command === "") {
81
+ onSkipped?.(`"${name}": stdio server has no command`);
82
+ continue;
83
+ }
84
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : void 0;
85
+ const env = parseNameValuePairs(e.env);
86
+ out.push({
87
+ name,
88
+ command: e.command,
89
+ ...args && args.length > 0 ? { args } : {},
90
+ ...env ? { env } : {}
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+ function parseNameValuePairs(raw) {
96
+ if (!Array.isArray(raw)) return void 0;
97
+ const out = [];
98
+ for (const pair of raw) {
99
+ if (typeof pair !== "object" || pair === null) continue;
100
+ const p = pair;
101
+ if (typeof p.name === "string" && p.name !== "" && typeof p.value === "string") {
102
+ out.push({ name: p.name, value: p.value });
103
+ }
104
+ }
105
+ return out.length > 0 ? out : void 0;
106
+ }
49
107
  async function resolveSessionCwd(requested) {
50
108
  if (!path.isAbsolute(requested)) return null;
51
109
  const resolved = path.resolve(requested);
@@ -128,9 +186,16 @@ function buildInitializeResult(agentName, modes, configOptions) {
128
186
  audio: false,
129
187
  embeddedContext: true
130
188
  },
189
+ // All three ACP transports are supported. stdio is mandatory per spec
190
+ // and cannot be declined; http and sse are declared here because the
191
+ // agent now actually connects them (see `parseMcpServers` above and the
192
+ // per-session MCP registry in `buildAcpServerAgentFactory`). Before that
193
+ // wiring existed the array was destructured and thrown away at every
194
+ // entry point, so a client got a successful `session/new` and no tools —
195
+ // flip these back to false if that connection path is ever removed.
131
196
  mcpCapabilities: {
132
- http: false,
133
- sse: false
197
+ http: true,
198
+ sse: true
134
199
  },
135
200
  sessionCapabilities: {
136
201
  close: {},
@@ -170,6 +235,8 @@ async function handleSessionNewOp(ctx, id, params) {
170
235
  }
171
236
  cwd = resolved;
172
237
  }
238
+ const skipped = [];
239
+ const mcpServers = parseMcpServers(p.mcpServers, (reason) => skipped.push(reason));
173
240
  const sessionId = `sess_${ctx.allocId()}`;
174
241
  const now = (/* @__PURE__ */ new Date()).toISOString();
175
242
  const state = {
@@ -178,7 +245,8 @@ async function handleSessionNewOp(ctx, id, params) {
178
245
  abort: new AbortController(),
179
246
  modeId: DEFAULT_MODE_ID,
180
247
  createdAt: now,
181
- updatedAt: now
248
+ updatedAt: now,
249
+ ...mcpServers.length > 0 ? { mcpServers } : {}
182
250
  };
183
251
  ctx.sessions.set(sessionId, state);
184
252
  ctx.onSessionNew(state);
@@ -199,6 +267,7 @@ async function handleSessionNewOp(ctx, id, params) {
199
267
  }
200
268
  });
201
269
  }
270
+ await reportSkippedMcpServers(ctx, sessionId, skipped);
202
271
  await ctx.sendResult(id, {
203
272
  sessionId,
204
273
  modes: ctx.modes,
@@ -210,6 +279,8 @@ async function handleSessionLoadOp(ctx, id, params) {
210
279
  const p = params ?? {};
211
280
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
212
281
  const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
282
+ const loadSkipped = [];
283
+ const loadMcpServers = parseMcpServers(p.mcpServers, (reason) => loadSkipped.push(reason));
213
284
  const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
214
285
  if (!existing && sessionId && ctx.store) {
215
286
  const persisted = await ctx.store.load(sessionId);
@@ -231,7 +302,8 @@ async function handleSessionLoadOp(ctx, id, params) {
231
302
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
232
303
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
233
304
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
234
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
305
+ ...persisted.title !== void 0 ? { title: persisted.title } : {},
306
+ ...loadMcpServers.length > 0 ? { mcpServers: loadMcpServers } : {}
235
307
  };
236
308
  ctx.sessions.set(sessionId, restored);
237
309
  ctx.seedFor?.(sessionId, persisted.history ?? []);
@@ -242,6 +314,7 @@ async function handleSessionLoadOp(ctx, id, params) {
242
314
  sessionId,
243
315
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
244
316
  });
317
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
245
318
  await ctx.sendResult(id, {
246
319
  initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
247
320
  });
@@ -250,6 +323,9 @@ async function handleSessionLoadOp(ctx, id, params) {
250
323
  }
251
324
  if (existing) {
252
325
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
326
+ if (loadMcpServers.length > 0) {
327
+ existing.mcpServers = loadMcpServers;
328
+ }
253
329
  const replay = ctx.replayFor?.(sessionId);
254
330
  if (replay) {
255
331
  for (const update of replay) {
@@ -270,6 +346,7 @@ async function handleSessionLoadOp(ctx, id, params) {
270
346
  modeId: existing.modeId
271
347
  }
272
348
  });
349
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
273
350
  await ctx.sendResult(id, {
274
351
  initialMode: {
275
352
  currentModeId: existing.modeId,
@@ -302,6 +379,9 @@ async function handleSessionForkOp(ctx, id, params) {
302
379
  }
303
380
  forkCwd = resolved;
304
381
  }
382
+ const forkSkipped = [];
383
+ const forkRequested = parseMcpServers(p.mcpServers, (reason) => forkSkipped.push(reason));
384
+ const forkMcpServers = forkRequested.length > 0 ? forkRequested : source.mcpServers;
305
385
  const now = (/* @__PURE__ */ new Date()).toISOString();
306
386
  const sessionId = `sess_${ctx.allocId()}`;
307
387
  const forked = {
@@ -311,7 +391,8 @@ async function handleSessionForkOp(ctx, id, params) {
311
391
  modeId: source.modeId,
312
392
  createdAt: now,
313
393
  updatedAt: now,
314
- ...source.title !== void 0 ? { title: source.title } : {}
394
+ ...source.title !== void 0 ? { title: source.title } : {},
395
+ ...forkMcpServers && forkMcpServers.length > 0 ? { mcpServers: forkMcpServers } : {}
315
396
  };
316
397
  const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
317
398
  sessionUpdate: update.sessionUpdate,
@@ -325,6 +406,7 @@ async function handleSessionForkOp(ctx, id, params) {
325
406
  sessionId,
326
407
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
327
408
  });
409
+ await reportSkippedMcpServers(ctx, sessionId, forkSkipped);
328
410
  await ctx.sendResult(id, {
329
411
  sessionId,
330
412
  modes: ctx.modes,
@@ -364,7 +446,13 @@ async function handleSessionPromptOp(ctx, id, params) {
364
446
  };
365
447
  try {
366
448
  result = await ctx.runTurn(
367
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
449
+ {
450
+ sessionId,
451
+ prompt: p.prompt,
452
+ signal: turnSignal.signal,
453
+ cwd: session.cwd,
454
+ ...session.mcpServers ? { mcpServers: session.mcpServers } : {}
455
+ },
368
456
  emit,
369
457
  api
370
458
  );
@@ -422,6 +510,22 @@ async function handleSetConfigOptionOp(ctx, id, params) {
422
510
  await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
423
511
  return false;
424
512
  }
513
+ async function reportSkippedMcpServers(ctx, sessionId, skipped) {
514
+ if (skipped.length === 0) return;
515
+ try {
516
+ await ctx.sendNotification({
517
+ sessionId,
518
+ update: {
519
+ sessionUpdate: "agent_message_chunk",
520
+ content: {
521
+ type: "text",
522
+ text: `Ignored ${skipped.length} malformed mcpServers entr${skipped.length === 1 ? "y" : "ies"}: ${skipped.join("; ")}`
523
+ }
524
+ }
525
+ });
526
+ } catch {
527
+ }
528
+ }
425
529
 
426
530
  // src/agent/protocol-handler.ts
427
531
  var ACPProtocolHandler = class {
@@ -620,7 +724,7 @@ var ACPProtocolHandler = class {
620
724
  return false;
621
725
  }
622
726
  async handleAuthenticate(id, _params) {
623
- await this.sendResult(id, { outcome: "unauthenticated" });
727
+ await this.sendResult(id, {});
624
728
  return false;
625
729
  }
626
730
  async handleLogout(id, _params) {
@@ -633,6 +737,10 @@ var ACPProtocolHandler = class {
633
737
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
634
738
  if (existing) {
635
739
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
740
+ const resumeServers = parseMcpServers(p.mcpServers);
741
+ if (resumeServers.length > 0) {
742
+ existing.mcpServers = resumeServers;
743
+ }
636
744
  await this.sendResult(id, {
637
745
  initialMode: {
638
746
  currentModeId: existing.modeId,
@@ -775,7 +883,12 @@ function makeACPServerAgentTurn(opts) {
775
883
  const turn = async (input, emit, api) => {
776
884
  let agent = agents.get(input.sessionId);
777
885
  if (!agent) {
778
- agent = await opts.agentFor(input.sessionId, process.cwd(), api);
886
+ agent = await opts.agentFor(
887
+ input.sessionId,
888
+ input.cwd ?? process.cwd(),
889
+ api,
890
+ input.mcpServers
891
+ );
779
892
  agents.set(input.sessionId, agent);
780
893
  if (pendingSeed.has(input.sessionId)) {
781
894
  pendingSeed.delete(input.sessionId);
@@ -10,5 +10,11 @@ interface JsonRpcError {
10
10
  data?: unknown;
11
11
  }
12
12
  export declare function isJsonRpcError(v: unknown): v is JsonRpcError;
13
+ /**
14
+ * True when an agent refused `session/new` (or similar) because the user
15
+ * must authenticate first. Official registry agents often return this
16
+ * (~19/31 in the 2026-09 protocol matrix) instead of creating a session.
17
+ */
18
+ export declare function isAuthRequiredError(err: unknown): boolean;
13
19
  export {};
14
20
  //# sourceMappingURL=acp-session-errors.d.ts.map
@@ -107,6 +107,18 @@ export declare class ACPSession {
107
107
  close(): Promise<void>;
108
108
  private allocId;
109
109
  private sendRequest;
110
+ /**
111
+ * `session/new`, then one authenticate+retry if the agent demands login.
112
+ * Logged-in CLIs succeed on the first call even when they advertise
113
+ * `authMethods`; we do not pop OAuth on every spawn.
114
+ */
115
+ private createSessionWithAuth;
116
+ /**
117
+ * Pick a non-terminal auth method and run `authenticate`. Terminal-only
118
+ * agents need an out-of-band login CLI (registry AUTHENTICATION.md) —
119
+ * we refuse rather than hang a TUI inside the JSON-RPC child.
120
+ */
121
+ private ensureAuthenticated;
110
122
  private sendResult;
111
123
  private sendErrorResponse;
112
124
  private responseSender;
package/dist/client.js CHANGED
@@ -254,6 +254,21 @@ function verbatimOptions(invocation) {
254
254
  // src/types/acp-v1.ts
255
255
  var ACP_PROTOCOL_VERSION = 1;
256
256
 
257
+ // src/version.ts
258
+ import { createRequire } from "node:module";
259
+ var require2 = createRequire(import.meta.url);
260
+ function readPackageVersion(load = () => require2("../package.json")) {
261
+ try {
262
+ const packageJson = load();
263
+ if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
264
+ return packageJson.version;
265
+ }
266
+ } catch {
267
+ }
268
+ return "dev";
269
+ }
270
+ var ACP_PACKAGE_VERSION = readPackageVersion();
271
+
257
272
  // src/client/acp-message-routing.ts
258
273
  function isBestEffortAckMethod(method) {
259
274
  return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
@@ -728,6 +743,20 @@ var ACPSessionError = class extends Error {
728
743
  function isJsonRpcError(v) {
729
744
  return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
730
745
  }
746
+ function isAuthRequiredError(err) {
747
+ const message = err instanceof Error ? err.message : String(err);
748
+ if (/auth(_|-)?required|authentication required/i.test(message)) return true;
749
+ const cause = err instanceof ACPSessionError ? err.cause : err && typeof err === "object" && "cause" in err ? err.cause : err;
750
+ if (!cause || typeof cause !== "object") return false;
751
+ const data = cause.data;
752
+ if (data === "auth_required" || data === "AUTH_REQUIRED") return true;
753
+ if (data && typeof data === "object") {
754
+ const d = data;
755
+ if (d.authRequired === true) return true;
756
+ if (d.code === "auth_required" || d.code === "AUTH_REQUIRED") return true;
757
+ }
758
+ return false;
759
+ }
731
760
 
732
761
  // src/client/acp-session-ops.ts
733
762
  function filterMcpServers(agentCapabilities, servers) {
@@ -1772,7 +1801,7 @@ var ACPSession = class _ACPSession {
1772
1801
  fs: { readTextFile: true, writeTextFile: true },
1773
1802
  terminal: true
1774
1803
  },
1775
- clientInfo: { name: "wrongstack", title: "WrongStack", version: "0.287.0" }
1804
+ clientInfo: { name: "wrongstack", title: "WrongStack", version: ACP_PACKAGE_VERSION }
1776
1805
  });
1777
1806
  if (isJsonRpcError(result)) {
1778
1807
  throw new ACPSessionError("init_failed", `initialize failed: ${result.message}`, result);
@@ -1804,12 +1833,13 @@ var ACPSession = class _ACPSession {
1804
1833
  if (this.state === "closed") {
1805
1834
  throw new ACPSessionError("closed", "session is closed");
1806
1835
  }
1807
- if (this.state !== "ready") {
1836
+ if (this.state !== "ready" && this.state !== "authenticated") {
1808
1837
  throw new ACPSessionError(
1809
1838
  "protocol_error",
1810
1839
  `authenticate called in state=${this.state} (expected 'ready')`
1811
1840
  );
1812
1841
  }
1842
+ if (this.state === "authenticated") return;
1813
1843
  if (!this.authMethods.some((m) => m.id === methodId)) {
1814
1844
  throw new ACPSessionError(
1815
1845
  "auth_failed",
@@ -1912,7 +1942,7 @@ var ACPSession = class _ACPSession {
1912
1942
  return emptyRunResult("cancelled");
1913
1943
  }
1914
1944
  if (!this.sessionId) {
1915
- this.sessionId = await executeCreateSession(this.opContext());
1945
+ this.sessionId = await this.createSessionWithAuth();
1916
1946
  }
1917
1947
  if (signal.aborted) {
1918
1948
  return emptyRunResult("cancelled");
@@ -2048,6 +2078,54 @@ var ACPSession = class _ACPSession {
2048
2078
  });
2049
2079
  });
2050
2080
  }
2081
+ /**
2082
+ * `session/new`, then one authenticate+retry if the agent demands login.
2083
+ * Logged-in CLIs succeed on the first call even when they advertise
2084
+ * `authMethods`; we do not pop OAuth on every spawn.
2085
+ */
2086
+ async createSessionWithAuth() {
2087
+ try {
2088
+ return await executeCreateSession(this.opContext());
2089
+ } catch (err) {
2090
+ if (this.state === "authenticated" || !isAuthRequiredError(err)) {
2091
+ throw err instanceof ACPSessionError ? err : new ACPSessionError(
2092
+ "session_create_failed",
2093
+ err instanceof Error ? err.message : String(err),
2094
+ err
2095
+ );
2096
+ }
2097
+ await this.ensureAuthenticated();
2098
+ return executeCreateSession(this.opContext());
2099
+ }
2100
+ }
2101
+ /**
2102
+ * Pick a non-terminal auth method and run `authenticate`. Terminal-only
2103
+ * agents need an out-of-band login CLI (registry AUTHENTICATION.md) —
2104
+ * we refuse rather than hang a TUI inside the JSON-RPC child.
2105
+ */
2106
+ async ensureAuthenticated() {
2107
+ if (this.state === "authenticated") return;
2108
+ if (this.authMethods.length === 0) {
2109
+ throw new ACPSessionError(
2110
+ "auth_failed",
2111
+ "This agent requires authentication before a session can start, but advertised no authMethods. Log into the CLI, then retry."
2112
+ );
2113
+ }
2114
+ const inProcess = this.authMethods.find(
2115
+ (m) => m.type === void 0 || m.type === "agent" || m.type === "oauth" || m.type === "http"
2116
+ );
2117
+ if (inProcess) {
2118
+ await this.authenticate(inProcess.id);
2119
+ return;
2120
+ }
2121
+ const terminal = this.authMethods.find((m) => m.type === "terminal");
2122
+ const setupArgs = terminal?.args?.length ? terminal.args.join(" ") : void 0;
2123
+ const setup = setupArgs !== void 0 ? `${this.opts.command} ${setupArgs}` : `${this.opts.command}${this.opts.args?.length ? ` ${this.opts.args.join(" ")}` : ""}`;
2124
+ throw new ACPSessionError(
2125
+ "auth_failed",
2126
+ `This agent requires a terminal login before ACP can start. Run \`${setup}\` (or the CLI's /login), then retry.`
2127
+ );
2128
+ }
2051
2129
  sendResult(id, result) {
2052
2130
  return this.transport.send({ jsonrpc: "2.0", id, result });
2053
2131
  }
@@ -2076,7 +2154,11 @@ var ACPSession = class _ACPSession {
2076
2154
  clearTimeout(pending.timeoutHandle);
2077
2155
  this.pending.delete(msg.id);
2078
2156
  if (msg.error !== void 0) {
2079
- pending.reject(new Error(msg.error.message ?? "unknown JSON-RPC error"));
2157
+ const method = pending.method;
2158
+ const kind = method === "session/new" ? "session_create_failed" : method === "authenticate" ? "auth_failed" : "protocol_error";
2159
+ pending.reject(
2160
+ new ACPSessionError(kind, msg.error.message ?? "unknown JSON-RPC error", msg.error)
2161
+ );
2080
2162
  } else {
2081
2163
  pending.resolve(msg.result);
2082
2164
  }
@@ -2198,6 +2280,10 @@ async function makeACPSubagentRunnerWithStop(options) {
2198
2280
  } catch {
2199
2281
  }
2200
2282
  options.onProgress?.(event);
2283
+ try {
2284
+ options.publishLive?.(ctx, task, event);
2285
+ } catch {
2286
+ }
2201
2287
  };
2202
2288
  try {
2203
2289
  const result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
package/dist/index.js CHANGED
@@ -49,6 +49,64 @@ var DEFAULT_MODES = [
49
49
  description: "Default agent mode for code-generation tasks."
50
50
  }
51
51
  ];
52
+ function parseMcpServers(raw, onSkipped) {
53
+ if (!Array.isArray(raw)) return [];
54
+ const out = [];
55
+ for (const entry of raw) {
56
+ if (typeof entry !== "object" || entry === null) {
57
+ onSkipped?.("entry is not an object");
58
+ continue;
59
+ }
60
+ const e = entry;
61
+ const name = typeof e.name === "string" ? e.name.trim() : "";
62
+ if (name === "") {
63
+ onSkipped?.("entry has no name");
64
+ continue;
65
+ }
66
+ const type = typeof e.type === "string" ? e.type : "stdio";
67
+ if (type === "http" || type === "sse") {
68
+ if (typeof e.url !== "string" || e.url === "") {
69
+ onSkipped?.(`"${name}": ${type} server has no url`);
70
+ continue;
71
+ }
72
+ const headers = parseNameValuePairs(e.headers);
73
+ const url = e.url;
74
+ out.push(
75
+ type === "http" ? { type: "http", name, url, ...headers ? { headers } : {} } : { type: "sse", name, url, ...headers ? { headers } : {} }
76
+ );
77
+ continue;
78
+ }
79
+ if (type !== "stdio") {
80
+ onSkipped?.(`"${name}": unknown transport "${type}"`);
81
+ continue;
82
+ }
83
+ if (typeof e.command !== "string" || e.command === "") {
84
+ onSkipped?.(`"${name}": stdio server has no command`);
85
+ continue;
86
+ }
87
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : void 0;
88
+ const env = parseNameValuePairs(e.env);
89
+ out.push({
90
+ name,
91
+ command: e.command,
92
+ ...args && args.length > 0 ? { args } : {},
93
+ ...env ? { env } : {}
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+ function parseNameValuePairs(raw) {
99
+ if (!Array.isArray(raw)) return void 0;
100
+ const out = [];
101
+ for (const pair of raw) {
102
+ if (typeof pair !== "object" || pair === null) continue;
103
+ const p = pair;
104
+ if (typeof p.name === "string" && p.name !== "" && typeof p.value === "string") {
105
+ out.push({ name: p.name, value: p.value });
106
+ }
107
+ }
108
+ return out.length > 0 ? out : void 0;
109
+ }
52
110
  async function resolveSessionCwd(requested) {
53
111
  if (!path.isAbsolute(requested)) return null;
54
112
  const resolved = path.resolve(requested);
@@ -131,9 +189,16 @@ function buildInitializeResult(agentName, modes, configOptions) {
131
189
  audio: false,
132
190
  embeddedContext: true
133
191
  },
192
+ // All three ACP transports are supported. stdio is mandatory per spec
193
+ // and cannot be declined; http and sse are declared here because the
194
+ // agent now actually connects them (see `parseMcpServers` above and the
195
+ // per-session MCP registry in `buildAcpServerAgentFactory`). Before that
196
+ // wiring existed the array was destructured and thrown away at every
197
+ // entry point, so a client got a successful `session/new` and no tools —
198
+ // flip these back to false if that connection path is ever removed.
134
199
  mcpCapabilities: {
135
- http: false,
136
- sse: false
200
+ http: true,
201
+ sse: true
137
202
  },
138
203
  sessionCapabilities: {
139
204
  close: {},
@@ -173,6 +238,8 @@ async function handleSessionNewOp(ctx, id, params) {
173
238
  }
174
239
  cwd = resolved;
175
240
  }
241
+ const skipped = [];
242
+ const mcpServers = parseMcpServers(p.mcpServers, (reason) => skipped.push(reason));
176
243
  const sessionId = `sess_${ctx.allocId()}`;
177
244
  const now = (/* @__PURE__ */ new Date()).toISOString();
178
245
  const state = {
@@ -181,7 +248,8 @@ async function handleSessionNewOp(ctx, id, params) {
181
248
  abort: new AbortController(),
182
249
  modeId: DEFAULT_MODE_ID,
183
250
  createdAt: now,
184
- updatedAt: now
251
+ updatedAt: now,
252
+ ...mcpServers.length > 0 ? { mcpServers } : {}
185
253
  };
186
254
  ctx.sessions.set(sessionId, state);
187
255
  ctx.onSessionNew(state);
@@ -202,6 +270,7 @@ async function handleSessionNewOp(ctx, id, params) {
202
270
  }
203
271
  });
204
272
  }
273
+ await reportSkippedMcpServers(ctx, sessionId, skipped);
205
274
  await ctx.sendResult(id, {
206
275
  sessionId,
207
276
  modes: ctx.modes,
@@ -213,6 +282,8 @@ async function handleSessionLoadOp(ctx, id, params) {
213
282
  const p = params ?? {};
214
283
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
215
284
  const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
285
+ const loadSkipped = [];
286
+ const loadMcpServers = parseMcpServers(p.mcpServers, (reason) => loadSkipped.push(reason));
216
287
  const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
217
288
  if (!existing && sessionId && ctx.store) {
218
289
  const persisted = await ctx.store.load(sessionId);
@@ -234,7 +305,8 @@ async function handleSessionLoadOp(ctx, id, params) {
234
305
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
235
306
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
236
307
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
237
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
308
+ ...persisted.title !== void 0 ? { title: persisted.title } : {},
309
+ ...loadMcpServers.length > 0 ? { mcpServers: loadMcpServers } : {}
238
310
  };
239
311
  ctx.sessions.set(sessionId, restored);
240
312
  ctx.seedFor?.(sessionId, persisted.history ?? []);
@@ -245,6 +317,7 @@ async function handleSessionLoadOp(ctx, id, params) {
245
317
  sessionId,
246
318
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
247
319
  });
320
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
248
321
  await ctx.sendResult(id, {
249
322
  initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
250
323
  });
@@ -253,6 +326,9 @@ async function handleSessionLoadOp(ctx, id, params) {
253
326
  }
254
327
  if (existing) {
255
328
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
329
+ if (loadMcpServers.length > 0) {
330
+ existing.mcpServers = loadMcpServers;
331
+ }
256
332
  const replay = ctx.replayFor?.(sessionId);
257
333
  if (replay) {
258
334
  for (const update of replay) {
@@ -273,6 +349,7 @@ async function handleSessionLoadOp(ctx, id, params) {
273
349
  modeId: existing.modeId
274
350
  }
275
351
  });
352
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
276
353
  await ctx.sendResult(id, {
277
354
  initialMode: {
278
355
  currentModeId: existing.modeId,
@@ -305,6 +382,9 @@ async function handleSessionForkOp(ctx, id, params) {
305
382
  }
306
383
  forkCwd = resolved;
307
384
  }
385
+ const forkSkipped = [];
386
+ const forkRequested = parseMcpServers(p.mcpServers, (reason) => forkSkipped.push(reason));
387
+ const forkMcpServers = forkRequested.length > 0 ? forkRequested : source.mcpServers;
308
388
  const now = (/* @__PURE__ */ new Date()).toISOString();
309
389
  const sessionId = `sess_${ctx.allocId()}`;
310
390
  const forked = {
@@ -314,7 +394,8 @@ async function handleSessionForkOp(ctx, id, params) {
314
394
  modeId: source.modeId,
315
395
  createdAt: now,
316
396
  updatedAt: now,
317
- ...source.title !== void 0 ? { title: source.title } : {}
397
+ ...source.title !== void 0 ? { title: source.title } : {},
398
+ ...forkMcpServers && forkMcpServers.length > 0 ? { mcpServers: forkMcpServers } : {}
318
399
  };
319
400
  const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
320
401
  sessionUpdate: update.sessionUpdate,
@@ -328,6 +409,7 @@ async function handleSessionForkOp(ctx, id, params) {
328
409
  sessionId,
329
410
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
330
411
  });
412
+ await reportSkippedMcpServers(ctx, sessionId, forkSkipped);
331
413
  await ctx.sendResult(id, {
332
414
  sessionId,
333
415
  modes: ctx.modes,
@@ -367,7 +449,13 @@ async function handleSessionPromptOp(ctx, id, params) {
367
449
  };
368
450
  try {
369
451
  result = await ctx.runTurn(
370
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
452
+ {
453
+ sessionId,
454
+ prompt: p.prompt,
455
+ signal: turnSignal.signal,
456
+ cwd: session.cwd,
457
+ ...session.mcpServers ? { mcpServers: session.mcpServers } : {}
458
+ },
371
459
  emit,
372
460
  api
373
461
  );
@@ -425,6 +513,22 @@ async function handleSetConfigOptionOp(ctx, id, params) {
425
513
  await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
426
514
  return false;
427
515
  }
516
+ async function reportSkippedMcpServers(ctx, sessionId, skipped) {
517
+ if (skipped.length === 0) return;
518
+ try {
519
+ await ctx.sendNotification({
520
+ sessionId,
521
+ update: {
522
+ sessionUpdate: "agent_message_chunk",
523
+ content: {
524
+ type: "text",
525
+ text: `Ignored ${skipped.length} malformed mcpServers entr${skipped.length === 1 ? "y" : "ies"}: ${skipped.join("; ")}`
526
+ }
527
+ }
528
+ });
529
+ } catch {
530
+ }
531
+ }
428
532
 
429
533
  // src/agent/protocol-handler.ts
430
534
  var ACPProtocolHandler = class {
@@ -623,7 +727,7 @@ var ACPProtocolHandler = class {
623
727
  return false;
624
728
  }
625
729
  async handleAuthenticate(id, _params) {
626
- await this.sendResult(id, { outcome: "unauthenticated" });
730
+ await this.sendResult(id, {});
627
731
  return false;
628
732
  }
629
733
  async handleLogout(id, _params) {
@@ -636,6 +740,10 @@ var ACPProtocolHandler = class {
636
740
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
637
741
  if (existing) {
638
742
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
743
+ const resumeServers = parseMcpServers(p.mcpServers);
744
+ if (resumeServers.length > 0) {
745
+ existing.mcpServers = resumeServers;
746
+ }
639
747
  await this.sendResult(id, {
640
748
  initialMode: {
641
749
  currentModeId: existing.modeId,
@@ -2045,6 +2153,20 @@ var ACPSessionError = class extends Error {
2045
2153
  function isJsonRpcError(v) {
2046
2154
  return typeof v === "object" && v !== null && typeof v.code === "number" && typeof v.message === "string";
2047
2155
  }
2156
+ function isAuthRequiredError(err) {
2157
+ const message = err instanceof Error ? err.message : String(err);
2158
+ if (/auth(_|-)?required|authentication required/i.test(message)) return true;
2159
+ const cause = err instanceof ACPSessionError ? err.cause : err && typeof err === "object" && "cause" in err ? err.cause : err;
2160
+ if (!cause || typeof cause !== "object") return false;
2161
+ const data = cause.data;
2162
+ if (data === "auth_required" || data === "AUTH_REQUIRED") return true;
2163
+ if (data && typeof data === "object") {
2164
+ const d = data;
2165
+ if (d.authRequired === true) return true;
2166
+ if (d.code === "auth_required" || d.code === "AUTH_REQUIRED") return true;
2167
+ }
2168
+ return false;
2169
+ }
2048
2170
 
2049
2171
  // src/client/acp-session-ops.ts
2050
2172
  function filterMcpServers(agentCapabilities, servers) {
@@ -3089,7 +3211,7 @@ var ACPSession = class _ACPSession {
3089
3211
  fs: { readTextFile: true, writeTextFile: true },
3090
3212
  terminal: true
3091
3213
  },
3092
- clientInfo: { name: "wrongstack", title: "WrongStack", version: "0.287.0" }
3214
+ clientInfo: { name: "wrongstack", title: "WrongStack", version: ACP_PACKAGE_VERSION }
3093
3215
  });
3094
3216
  if (isJsonRpcError(result)) {
3095
3217
  throw new ACPSessionError("init_failed", `initialize failed: ${result.message}`, result);
@@ -3121,12 +3243,13 @@ var ACPSession = class _ACPSession {
3121
3243
  if (this.state === "closed") {
3122
3244
  throw new ACPSessionError("closed", "session is closed");
3123
3245
  }
3124
- if (this.state !== "ready") {
3246
+ if (this.state !== "ready" && this.state !== "authenticated") {
3125
3247
  throw new ACPSessionError(
3126
3248
  "protocol_error",
3127
3249
  `authenticate called in state=${this.state} (expected 'ready')`
3128
3250
  );
3129
3251
  }
3252
+ if (this.state === "authenticated") return;
3130
3253
  if (!this.authMethods.some((m) => m.id === methodId)) {
3131
3254
  throw new ACPSessionError(
3132
3255
  "auth_failed",
@@ -3229,7 +3352,7 @@ var ACPSession = class _ACPSession {
3229
3352
  return emptyRunResult("cancelled");
3230
3353
  }
3231
3354
  if (!this.sessionId) {
3232
- this.sessionId = await executeCreateSession(this.opContext());
3355
+ this.sessionId = await this.createSessionWithAuth();
3233
3356
  }
3234
3357
  if (signal.aborted) {
3235
3358
  return emptyRunResult("cancelled");
@@ -3365,6 +3488,54 @@ var ACPSession = class _ACPSession {
3365
3488
  });
3366
3489
  });
3367
3490
  }
3491
+ /**
3492
+ * `session/new`, then one authenticate+retry if the agent demands login.
3493
+ * Logged-in CLIs succeed on the first call even when they advertise
3494
+ * `authMethods`; we do not pop OAuth on every spawn.
3495
+ */
3496
+ async createSessionWithAuth() {
3497
+ try {
3498
+ return await executeCreateSession(this.opContext());
3499
+ } catch (err) {
3500
+ if (this.state === "authenticated" || !isAuthRequiredError(err)) {
3501
+ throw err instanceof ACPSessionError ? err : new ACPSessionError(
3502
+ "session_create_failed",
3503
+ err instanceof Error ? err.message : String(err),
3504
+ err
3505
+ );
3506
+ }
3507
+ await this.ensureAuthenticated();
3508
+ return executeCreateSession(this.opContext());
3509
+ }
3510
+ }
3511
+ /**
3512
+ * Pick a non-terminal auth method and run `authenticate`. Terminal-only
3513
+ * agents need an out-of-band login CLI (registry AUTHENTICATION.md) —
3514
+ * we refuse rather than hang a TUI inside the JSON-RPC child.
3515
+ */
3516
+ async ensureAuthenticated() {
3517
+ if (this.state === "authenticated") return;
3518
+ if (this.authMethods.length === 0) {
3519
+ throw new ACPSessionError(
3520
+ "auth_failed",
3521
+ "This agent requires authentication before a session can start, but advertised no authMethods. Log into the CLI, then retry."
3522
+ );
3523
+ }
3524
+ const inProcess = this.authMethods.find(
3525
+ (m) => m.type === void 0 || m.type === "agent" || m.type === "oauth" || m.type === "http"
3526
+ );
3527
+ if (inProcess) {
3528
+ await this.authenticate(inProcess.id);
3529
+ return;
3530
+ }
3531
+ const terminal = this.authMethods.find((m) => m.type === "terminal");
3532
+ const setupArgs = terminal?.args?.length ? terminal.args.join(" ") : void 0;
3533
+ const setup = setupArgs !== void 0 ? `${this.opts.command} ${setupArgs}` : `${this.opts.command}${this.opts.args?.length ? ` ${this.opts.args.join(" ")}` : ""}`;
3534
+ throw new ACPSessionError(
3535
+ "auth_failed",
3536
+ `This agent requires a terminal login before ACP can start. Run \`${setup}\` (or the CLI's /login), then retry.`
3537
+ );
3538
+ }
3368
3539
  sendResult(id, result) {
3369
3540
  return this.transport.send({ jsonrpc: "2.0", id, result });
3370
3541
  }
@@ -3393,7 +3564,11 @@ var ACPSession = class _ACPSession {
3393
3564
  clearTimeout(pending.timeoutHandle);
3394
3565
  this.pending.delete(msg.id);
3395
3566
  if (msg.error !== void 0) {
3396
- pending.reject(new Error(msg.error.message ?? "unknown JSON-RPC error"));
3567
+ const method = pending.method;
3568
+ const kind = method === "session/new" ? "session_create_failed" : method === "authenticate" ? "auth_failed" : "protocol_error";
3569
+ pending.reject(
3570
+ new ACPSessionError(kind, msg.error.message ?? "unknown JSON-RPC error", msg.error)
3571
+ );
3397
3572
  } else {
3398
3573
  pending.resolve(msg.result);
3399
3574
  }
@@ -3833,7 +4008,7 @@ var AGENTS_CATALOG = [
3833
4008
  id: "cline",
3834
4009
  displayName: "Cline",
3835
4010
  vendor: "community",
3836
- probe: { command: "npx", args: ["--version"] },
4011
+ probe: { command: "cline", args: ["--version"] },
3837
4012
  // Registry id `cline`: the `cline` npm package speaks ACP behind `--acp`.
3838
4013
  acp: {
3839
4014
  command: "npx",
@@ -3876,7 +4051,8 @@ var AGENTS_CATALOG = [
3876
4051
  fs: true
3877
4052
  },
3878
4053
  integration: "experimental",
3879
- // Canonical repo URL — the org renamed; All-Hands-AI/OpenHands 301-redirects here.
4054
+ // Not in the official agentclientprotocol/registry (2026-09). Keep as a
4055
+ // local-PATH fallback; probe/spawn may hang if the binary has no ACP entry.
3880
4056
  docs: "https://github.com/OpenHands/OpenHands"
3881
4057
  },
3882
4058
  // ── Vendor CLIs (native binaries) ───────────────────────────────────
@@ -3909,6 +4085,7 @@ var AGENTS_CATALOG = [
3909
4085
  fs: true
3910
4086
  },
3911
4087
  integration: "experimental",
4088
+ // Not in the official agentclientprotocol/registry (2026-09).
3912
4089
  docs: "https://kiro.dev"
3913
4090
  },
3914
4091
  {
@@ -3931,8 +4108,9 @@ var AGENTS_CATALOG = [
3931
4108
  id: "mistral-vibe",
3932
4109
  displayName: "Mistral Vibe",
3933
4110
  vendor: "community",
3934
- probe: { command: "vibe", args: ["--version"] },
3935
- acp: { command: "vibe", args: [] },
4111
+ probe: { command: "vibe-acp", args: ["--version"] },
4112
+ // Official registry ships a dedicated `vibe-acp` binary, not bare `vibe`.
4113
+ acp: { command: "vibe-acp", args: [] },
3936
4114
  supports: {
3937
4115
  loadSession: false,
3938
4116
  promptImages: false,
@@ -4054,6 +4232,10 @@ async function makeACPSubagentRunnerWithStop(options) {
4054
4232
  } catch {
4055
4233
  }
4056
4234
  options.onProgress?.(event);
4235
+ try {
4236
+ options.publishLive?.(ctx, task, event);
4237
+ } catch {
4238
+ }
4057
4239
  };
4058
4240
  try {
4059
4241
  const result = await session.prompt([textContent(task.description)], ctx.signal, onProgress);
@@ -4140,10 +4322,13 @@ var REGISTRY_ID_ALIASES = {
4140
4322
  "gemini-cli": "gemini",
4141
4323
  "codex-cli": "codex-acp",
4142
4324
  copilot: "github-copilot-cli",
4143
- // Kimi's live registry id is `kimi` — same as our catalog id, so the
4144
- // alias is identity. Listed explicitly so `resolveAcpAgentCommand`
4145
- // finds the live entry when the registry is synced.
4146
- kimi: "kimi"
4325
+ kimi: "kimi",
4326
+ cline: "cline",
4327
+ goose: "goose",
4328
+ opencode: "opencode",
4329
+ cursor: "cursor",
4330
+ "qwen-code": "qwen-code",
4331
+ "mistral-vibe": "mistral-vibe"
4147
4332
  };
4148
4333
  function resolveAcpAgentCommand(id, overrides, live) {
4149
4334
  const ov = overrides?.[id];
@@ -4566,20 +4751,20 @@ async function runEnsemble(opts) {
4566
4751
  const detectedById = new Map(detected.map((a) => [a.id, a]));
4567
4752
  const runnable = [];
4568
4753
  for (const id of requested) {
4754
+ const cmd = resolveCmd(id);
4569
4755
  const det = detectedById.get(id);
4570
- if (!det?.installed) {
4756
+ const pkgLauncher = cmd?.command === "npx" || cmd?.command === "uvx";
4757
+ if (det && !det.installed && !pkgLauncher) {
4571
4758
  setResult(results, id, {
4572
4759
  status: "skipped",
4573
- reason: det?.reason ?? "not in catalog"
4760
+ reason: det.reason ?? "binary not found"
4574
4761
  });
4575
4762
  continue;
4576
4763
  }
4577
- const cmd = resolveCmd(id);
4578
4764
  if (!cmd) {
4579
4765
  setResult(results, id, {
4580
- status: "failed",
4581
- error: { kind: "unknown_agent", message: `Unknown ACP agent: ${id}` },
4582
- durationMs: 0
4766
+ status: "skipped",
4767
+ reason: det?.reason ?? "not in catalog"
4583
4768
  });
4584
4769
  continue;
4585
4770
  }
@@ -4715,9 +4900,17 @@ function mapRegistryEntry(entry, platformKey = currentPlatformKey()) {
4715
4900
  const dist = entry.distribution;
4716
4901
  let acp = null;
4717
4902
  if (dist?.npx?.package) {
4718
- acp = { command: "npx", args: ["-y", dist.npx.package, ...dist.npx.args ?? []] };
4903
+ acp = {
4904
+ command: "npx",
4905
+ args: ["-y", dist.npx.package, ...dist.npx.args ?? []],
4906
+ ...dist.npx.env ? { env: dist.npx.env } : {}
4907
+ };
4719
4908
  } else if (dist?.uvx?.package) {
4720
- acp = { command: "uvx", args: [dist.uvx.package, ...dist.uvx.args ?? []] };
4909
+ acp = {
4910
+ command: "uvx",
4911
+ args: [dist.uvx.package, ...dist.uvx.args ?? []],
4912
+ ...dist.uvx.env ? { env: dist.uvx.env } : {}
4913
+ };
4721
4914
  } else if (dist?.binary) {
4722
4915
  const target = dist.binary[platformKey];
4723
4916
  if (target?.cmd) {
@@ -11,8 +11,8 @@
11
11
  * Connected to the Director / MultiAgentCoordinator via the
12
12
  * `SubagentRunner` interface (same shape as `AgentSubagentRunner`).
13
13
  */
14
- import type { SubagentRunner } from '@wrongstack/core/types';
15
- import { type ACPProgressHandler, ACPSession } from '../client/acp-session.js';
14
+ import type { SubagentRunContext, SubagentRunner, TaskSpec } from '@wrongstack/core/types';
15
+ import { type ACPProgressEvent, type ACPProgressHandler, ACPSession } from '../client/acp-session.js';
16
16
  import type { PermissionPolicy } from '../client/permission.js';
17
17
  import type { McpServer } from '../types/acp-v1.js';
18
18
  export interface ACPSubagentRunnerOptions {
@@ -40,6 +40,12 @@ export interface ACPSubagentRunnerOptions {
40
40
  * stream, instead of waiting for the buffered final result.
41
41
  */
42
42
  onProgress?: ACPProgressHandler | undefined;
43
+ /**
44
+ * Host live-view hook. Called with the run context so a fleet/TUI/WebUI
45
+ * publisher can attribute tool calls and text to the right subagent.
46
+ * Invoked in addition to `onProgress`.
47
+ */
48
+ publishLive?: ((ctx: SubagentRunContext, task: TaskSpec, event: ACPProgressEvent) => void) | undefined;
43
49
  /**
44
50
  * Permission policy for the external agent's `session/request_permission`
45
51
  * calls. Defaults to the session's own default. Inject the host's
@@ -39,10 +39,12 @@ export interface RegistryAgentEntry {
39
39
  npx?: {
40
40
  package: string;
41
41
  args?: string[];
42
+ env?: Record<string, string>;
42
43
  };
43
44
  uvx?: {
44
45
  package: string;
45
46
  args?: string[];
47
+ env?: Record<string, string>;
46
48
  };
47
49
  binary?: Record<string, {
48
50
  archive?: string;
@@ -95,7 +95,15 @@ export interface AuthMethod {
95
95
  id: string;
96
96
  name: string;
97
97
  description?: string | undefined;
98
- type?: 'agent' | 'oauth' | 'http' | undefined;
98
+ /**
99
+ * Registry agents advertise `agent` (OAuth in-process) or `terminal`
100
+ * (separate login CLI). `oauth`/`http` are spec aliases; `env_var` is
101
+ * used by some agents but is not a registry-supported setup path.
102
+ */
103
+ type?: 'agent' | 'oauth' | 'http' | 'terminal' | 'env_var' | undefined;
104
+ /** Extra argv for `type: 'terminal'` setup (replaces the ACP entry args). */
105
+ args?: string[] | undefined;
106
+ env?: Record<string, string> | undefined;
99
107
  }
100
108
  export interface AuthenticateRequest {
101
109
  methodId: string;
@@ -53,6 +53,64 @@ var DEFAULT_MODES = [
53
53
  description: "Default agent mode for code-generation tasks."
54
54
  }
55
55
  ];
56
+ function parseMcpServers(raw, onSkipped) {
57
+ if (!Array.isArray(raw)) return [];
58
+ const out = [];
59
+ for (const entry of raw) {
60
+ if (typeof entry !== "object" || entry === null) {
61
+ onSkipped?.("entry is not an object");
62
+ continue;
63
+ }
64
+ const e = entry;
65
+ const name = typeof e.name === "string" ? e.name.trim() : "";
66
+ if (name === "") {
67
+ onSkipped?.("entry has no name");
68
+ continue;
69
+ }
70
+ const type = typeof e.type === "string" ? e.type : "stdio";
71
+ if (type === "http" || type === "sse") {
72
+ if (typeof e.url !== "string" || e.url === "") {
73
+ onSkipped?.(`"${name}": ${type} server has no url`);
74
+ continue;
75
+ }
76
+ const headers = parseNameValuePairs(e.headers);
77
+ const url = e.url;
78
+ out.push(
79
+ type === "http" ? { type: "http", name, url, ...headers ? { headers } : {} } : { type: "sse", name, url, ...headers ? { headers } : {} }
80
+ );
81
+ continue;
82
+ }
83
+ if (type !== "stdio") {
84
+ onSkipped?.(`"${name}": unknown transport "${type}"`);
85
+ continue;
86
+ }
87
+ if (typeof e.command !== "string" || e.command === "") {
88
+ onSkipped?.(`"${name}": stdio server has no command`);
89
+ continue;
90
+ }
91
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : void 0;
92
+ const env = parseNameValuePairs(e.env);
93
+ out.push({
94
+ name,
95
+ command: e.command,
96
+ ...args && args.length > 0 ? { args } : {},
97
+ ...env ? { env } : {}
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+ function parseNameValuePairs(raw) {
103
+ if (!Array.isArray(raw)) return void 0;
104
+ const out = [];
105
+ for (const pair of raw) {
106
+ if (typeof pair !== "object" || pair === null) continue;
107
+ const p = pair;
108
+ if (typeof p.name === "string" && p.name !== "" && typeof p.value === "string") {
109
+ out.push({ name: p.name, value: p.value });
110
+ }
111
+ }
112
+ return out.length > 0 ? out : void 0;
113
+ }
56
114
  async function resolveSessionCwd(requested) {
57
115
  if (!path.isAbsolute(requested)) return null;
58
116
  const resolved = path.resolve(requested);
@@ -135,9 +193,16 @@ function buildInitializeResult(agentName, modes, configOptions) {
135
193
  audio: false,
136
194
  embeddedContext: true
137
195
  },
196
+ // All three ACP transports are supported. stdio is mandatory per spec
197
+ // and cannot be declined; http and sse are declared here because the
198
+ // agent now actually connects them (see `parseMcpServers` above and the
199
+ // per-session MCP registry in `buildAcpServerAgentFactory`). Before that
200
+ // wiring existed the array was destructured and thrown away at every
201
+ // entry point, so a client got a successful `session/new` and no tools —
202
+ // flip these back to false if that connection path is ever removed.
138
203
  mcpCapabilities: {
139
- http: false,
140
- sse: false
204
+ http: true,
205
+ sse: true
141
206
  },
142
207
  sessionCapabilities: {
143
208
  close: {},
@@ -177,6 +242,8 @@ async function handleSessionNewOp(ctx, id, params) {
177
242
  }
178
243
  cwd = resolved;
179
244
  }
245
+ const skipped = [];
246
+ const mcpServers = parseMcpServers(p.mcpServers, (reason) => skipped.push(reason));
180
247
  const sessionId = `sess_${ctx.allocId()}`;
181
248
  const now = (/* @__PURE__ */ new Date()).toISOString();
182
249
  const state = {
@@ -185,7 +252,8 @@ async function handleSessionNewOp(ctx, id, params) {
185
252
  abort: new AbortController(),
186
253
  modeId: DEFAULT_MODE_ID,
187
254
  createdAt: now,
188
- updatedAt: now
255
+ updatedAt: now,
256
+ ...mcpServers.length > 0 ? { mcpServers } : {}
189
257
  };
190
258
  ctx.sessions.set(sessionId, state);
191
259
  ctx.onSessionNew(state);
@@ -206,6 +274,7 @@ async function handleSessionNewOp(ctx, id, params) {
206
274
  }
207
275
  });
208
276
  }
277
+ await reportSkippedMcpServers(ctx, sessionId, skipped);
209
278
  await ctx.sendResult(id, {
210
279
  sessionId,
211
280
  modes: ctx.modes,
@@ -217,6 +286,8 @@ async function handleSessionLoadOp(ctx, id, params) {
217
286
  const p = params ?? {};
218
287
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
219
288
  const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
289
+ const loadSkipped = [];
290
+ const loadMcpServers = parseMcpServers(p.mcpServers, (reason) => loadSkipped.push(reason));
220
291
  const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
221
292
  if (!existing && sessionId && ctx.store) {
222
293
  const persisted = await ctx.store.load(sessionId);
@@ -238,7 +309,8 @@ async function handleSessionLoadOp(ctx, id, params) {
238
309
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
239
310
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
240
311
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
241
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
312
+ ...persisted.title !== void 0 ? { title: persisted.title } : {},
313
+ ...loadMcpServers.length > 0 ? { mcpServers: loadMcpServers } : {}
242
314
  };
243
315
  ctx.sessions.set(sessionId, restored);
244
316
  ctx.seedFor?.(sessionId, persisted.history ?? []);
@@ -249,6 +321,7 @@ async function handleSessionLoadOp(ctx, id, params) {
249
321
  sessionId,
250
322
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
251
323
  });
324
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
252
325
  await ctx.sendResult(id, {
253
326
  initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
254
327
  });
@@ -257,6 +330,9 @@ async function handleSessionLoadOp(ctx, id, params) {
257
330
  }
258
331
  if (existing) {
259
332
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
333
+ if (loadMcpServers.length > 0) {
334
+ existing.mcpServers = loadMcpServers;
335
+ }
260
336
  const replay = ctx.replayFor?.(sessionId);
261
337
  if (replay) {
262
338
  for (const update of replay) {
@@ -277,6 +353,7 @@ async function handleSessionLoadOp(ctx, id, params) {
277
353
  modeId: existing.modeId
278
354
  }
279
355
  });
356
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
280
357
  await ctx.sendResult(id, {
281
358
  initialMode: {
282
359
  currentModeId: existing.modeId,
@@ -309,6 +386,9 @@ async function handleSessionForkOp(ctx, id, params) {
309
386
  }
310
387
  forkCwd = resolved;
311
388
  }
389
+ const forkSkipped = [];
390
+ const forkRequested = parseMcpServers(p.mcpServers, (reason) => forkSkipped.push(reason));
391
+ const forkMcpServers = forkRequested.length > 0 ? forkRequested : source.mcpServers;
312
392
  const now = (/* @__PURE__ */ new Date()).toISOString();
313
393
  const sessionId = `sess_${ctx.allocId()}`;
314
394
  const forked = {
@@ -318,7 +398,8 @@ async function handleSessionForkOp(ctx, id, params) {
318
398
  modeId: source.modeId,
319
399
  createdAt: now,
320
400
  updatedAt: now,
321
- ...source.title !== void 0 ? { title: source.title } : {}
401
+ ...source.title !== void 0 ? { title: source.title } : {},
402
+ ...forkMcpServers && forkMcpServers.length > 0 ? { mcpServers: forkMcpServers } : {}
322
403
  };
323
404
  const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
324
405
  sessionUpdate: update.sessionUpdate,
@@ -332,6 +413,7 @@ async function handleSessionForkOp(ctx, id, params) {
332
413
  sessionId,
333
414
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
334
415
  });
416
+ await reportSkippedMcpServers(ctx, sessionId, forkSkipped);
335
417
  await ctx.sendResult(id, {
336
418
  sessionId,
337
419
  modes: ctx.modes,
@@ -371,7 +453,13 @@ async function handleSessionPromptOp(ctx, id, params) {
371
453
  };
372
454
  try {
373
455
  result = await ctx.runTurn(
374
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
456
+ {
457
+ sessionId,
458
+ prompt: p.prompt,
459
+ signal: turnSignal.signal,
460
+ cwd: session.cwd,
461
+ ...session.mcpServers ? { mcpServers: session.mcpServers } : {}
462
+ },
375
463
  emit,
376
464
  api
377
465
  );
@@ -429,6 +517,22 @@ async function handleSetConfigOptionOp(ctx, id, params) {
429
517
  await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
430
518
  return false;
431
519
  }
520
+ async function reportSkippedMcpServers(ctx, sessionId, skipped) {
521
+ if (skipped.length === 0) return;
522
+ try {
523
+ await ctx.sendNotification({
524
+ sessionId,
525
+ update: {
526
+ sessionUpdate: "agent_message_chunk",
527
+ content: {
528
+ type: "text",
529
+ text: `Ignored ${skipped.length} malformed mcpServers entr${skipped.length === 1 ? "y" : "ies"}: ${skipped.join("; ")}`
530
+ }
531
+ }
532
+ });
533
+ } catch {
534
+ }
535
+ }
432
536
 
433
537
  // src/agent/protocol-handler.ts
434
538
  var ACPProtocolHandler = class {
@@ -627,7 +731,7 @@ var ACPProtocolHandler = class {
627
731
  return false;
628
732
  }
629
733
  async handleAuthenticate(id, _params) {
630
- await this.sendResult(id, { outcome: "unauthenticated" });
734
+ await this.sendResult(id, {});
631
735
  return false;
632
736
  }
633
737
  async handleLogout(id, _params) {
@@ -640,6 +744,10 @@ var ACPProtocolHandler = class {
640
744
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
641
745
  if (existing) {
642
746
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
747
+ const resumeServers = parseMcpServers(p.mcpServers);
748
+ if (resumeServers.length > 0) {
749
+ existing.mcpServers = resumeServers;
750
+ }
643
751
  await this.sendResult(id, {
644
752
  initialMode: {
645
753
  currentModeId: existing.modeId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/acp",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "license": "MIT",
5
5
  "description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
6
6
  "keywords": [
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@agentclientprotocol/sdk": "^1.4.0",
55
- "@wrongstack/core": "1.0.3"
55
+ "@wrongstack/core": "1.0.4"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^26.2.0",