@wrongstack/acp 0.306.3 → 0.307.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // src/agent/protocol-handler.ts
2
2
  import { randomUUID } from "node:crypto";
3
- import * as fsp from "node:fs/promises";
4
- import * as path from "node:path";
5
3
 
6
4
  // src/types/acp-v1.ts
7
5
  var ACP_PROTOCOL_VERSION = 1;
@@ -32,7 +30,9 @@ function toWire(msg) {
32
30
  }
33
31
  var WRONGSTACK_VERSION = ACP_PACKAGE_VERSION;
34
32
 
35
- // src/agent/protocol-handler.ts
33
+ // src/agent/protocol-session-ops.ts
34
+ import * as fsp from "node:fs/promises";
35
+ import * as path from "node:path";
36
36
  var WRONGSTACK_AUTH_METHODS = [
37
37
  {
38
38
  id: "wrongstack-auth",
@@ -51,6 +51,384 @@ var DEFAULT_MODES = [
51
51
  description: "Default agent mode for code-generation tasks."
52
52
  }
53
53
  ];
54
+ async function resolveSessionCwd(requested) {
55
+ if (!path.isAbsolute(requested)) return null;
56
+ const resolved = path.resolve(requested);
57
+ try {
58
+ const stat3 = await fsp.stat(resolved);
59
+ return stat3.isDirectory() ? resolved : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function errorToJsonRpc(err) {
65
+ if (err && typeof err === "object") {
66
+ const e = err;
67
+ if (typeof e.code === "number" && typeof e.message === "string") {
68
+ const result = {
69
+ code: e.code,
70
+ message: e.message
71
+ };
72
+ if (e.data !== void 0) result.data = e.data;
73
+ return result;
74
+ }
75
+ }
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ return { code: -32603, message };
78
+ }
79
+ function createRunTurnApi(sessionId, clientCapabilities, request) {
80
+ return {
81
+ clientCapabilities,
82
+ requestPermission: async (req) => {
83
+ const res = await request("session/request_permission", {
84
+ sessionId,
85
+ toolCall: req.toolCall,
86
+ options: req.options
87
+ });
88
+ const outcome = res?.outcome;
89
+ return outcome ?? { outcome: "cancelled" };
90
+ },
91
+ readTextFile: async (params) => {
92
+ const res = await request("fs/read_text_file", { sessionId, ...params });
93
+ return String(res?.content ?? "");
94
+ },
95
+ writeTextFile: async (params) => {
96
+ await request("fs/write_text_file", { sessionId, ...params });
97
+ },
98
+ runTerminal: async ({ command, args, cwd }) => {
99
+ const created = await request("terminal/create", {
100
+ sessionId,
101
+ command,
102
+ ...args ? { args } : {},
103
+ ...cwd ? { cwd } : {}
104
+ });
105
+ const terminalId = created?.terminalId;
106
+ if (!terminalId) return { output: "", exitCode: null };
107
+ try {
108
+ const exit = await request("terminal/wait_for_exit", {
109
+ sessionId,
110
+ terminalId
111
+ });
112
+ const out = await request("terminal/output", { sessionId, terminalId });
113
+ return {
114
+ output: String(out?.output ?? ""),
115
+ exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
116
+ };
117
+ } finally {
118
+ try {
119
+ await request("terminal/release", { sessionId, terminalId });
120
+ } catch {
121
+ }
122
+ }
123
+ }
124
+ };
125
+ }
126
+ function buildInitializeResult(agentName, modes, configOptions) {
127
+ return {
128
+ protocolVersion: ACP_PROTOCOL_VERSION,
129
+ agentCapabilities: {
130
+ loadSession: true,
131
+ promptCapabilities: {
132
+ image: true,
133
+ audio: false,
134
+ embeddedContext: true
135
+ },
136
+ mcpCapabilities: {
137
+ http: false,
138
+ sse: false
139
+ },
140
+ sessionCapabilities: {
141
+ close: {},
142
+ list: {},
143
+ delete: {},
144
+ resume: {},
145
+ fork: {}
146
+ },
147
+ auth: {
148
+ logout: {}
149
+ }
150
+ },
151
+ agentInfo: {
152
+ name: agentName,
153
+ title: "WrongStack",
154
+ version: WRONGSTACK_VERSION
155
+ },
156
+ authMethods: WRONGSTACK_AUTH_METHODS,
157
+ modes,
158
+ configOptions
159
+ };
160
+ }
161
+
162
+ // src/agent/protocol-session-management.ts
163
+ async function handleSessionNewOp(ctx, id, params) {
164
+ if (ctx.sessions.size >= ctx.maxSessions) {
165
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
166
+ return false;
167
+ }
168
+ const p = params ?? {};
169
+ let cwd = ctx.defaultCwd;
170
+ if (typeof p.cwd === "string") {
171
+ const resolved = await resolveSessionCwd(p.cwd);
172
+ if (resolved === null) {
173
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
174
+ return false;
175
+ }
176
+ cwd = resolved;
177
+ }
178
+ const sessionId = `sess_${ctx.allocId()}`;
179
+ const now = (/* @__PURE__ */ new Date()).toISOString();
180
+ const state = {
181
+ id: sessionId,
182
+ cwd,
183
+ abort: new AbortController(),
184
+ modeId: DEFAULT_MODE_ID,
185
+ createdAt: now,
186
+ updatedAt: now
187
+ };
188
+ ctx.sessions.set(sessionId, state);
189
+ ctx.onSessionNew(state);
190
+ await ctx.persist(state);
191
+ await ctx.sendNotification({
192
+ sessionId,
193
+ update: {
194
+ sessionUpdate: "current_mode_update",
195
+ modeId: ctx.modes[0]?.id ?? DEFAULT_MODE_ID
196
+ }
197
+ });
198
+ if (ctx.configOptions.length > 0) {
199
+ await ctx.sendNotification({
200
+ sessionId,
201
+ update: {
202
+ sessionUpdate: "config_option_update",
203
+ configOptions: [...ctx.configOptions]
204
+ }
205
+ });
206
+ }
207
+ await ctx.sendResult(id, {
208
+ sessionId,
209
+ modes: ctx.modes,
210
+ configOptions: ctx.configOptions
211
+ });
212
+ return false;
213
+ }
214
+ async function handleSessionLoadOp(ctx, id, params) {
215
+ const p = params ?? {};
216
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
217
+ const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
218
+ const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
219
+ if (!existing && sessionId && ctx.store) {
220
+ const persisted = await ctx.store.load(sessionId);
221
+ if (persisted) {
222
+ if (ctx.sessions.size >= ctx.maxSessions) {
223
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
224
+ return false;
225
+ }
226
+ if (loadCwd !== void 0 && await resolveSessionCwd(loadCwd) === null) {
227
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
228
+ return false;
229
+ }
230
+ const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
231
+ const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
232
+ const restored = {
233
+ id: sessionId,
234
+ cwd: restoredCwd,
235
+ abort: new AbortController(),
236
+ modeId: persisted.modeId ?? DEFAULT_MODE_ID,
237
+ createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
238
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
239
+ ...persisted.title !== void 0 ? { title: persisted.title } : {}
240
+ };
241
+ ctx.sessions.set(sessionId, restored);
242
+ ctx.seedFor?.(sessionId, persisted.history ?? []);
243
+ for (const update of persisted.history ?? []) {
244
+ await ctx.sendNotification({ sessionId, update });
245
+ }
246
+ await ctx.sendNotification({
247
+ sessionId,
248
+ update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
249
+ });
250
+ await ctx.sendResult(id, {
251
+ initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
252
+ });
253
+ return false;
254
+ }
255
+ }
256
+ if (existing) {
257
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
258
+ const replay = ctx.replayFor?.(sessionId);
259
+ if (replay) {
260
+ for (const update of replay) {
261
+ await ctx.sendNotification({ sessionId, update });
262
+ }
263
+ }
264
+ await ctx.sendNotification({
265
+ sessionId,
266
+ update: {
267
+ sessionUpdate: "session_info_update",
268
+ updatedAt: existing.updatedAt
269
+ }
270
+ });
271
+ await ctx.sendNotification({
272
+ sessionId,
273
+ update: {
274
+ sessionUpdate: "current_mode_update",
275
+ modeId: existing.modeId
276
+ }
277
+ });
278
+ await ctx.sendResult(id, {
279
+ initialMode: {
280
+ currentModeId: existing.modeId,
281
+ availableModes: ctx.modes
282
+ }
283
+ });
284
+ return false;
285
+ }
286
+ await ctx.sendError(id, -32e3, `session not found: ${sessionId}`);
287
+ return false;
288
+ }
289
+ async function handleSessionForkOp(ctx, id, params) {
290
+ const p = params ?? {};
291
+ const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
292
+ const source = sourceId ? ctx.sessions.get(sourceId) : void 0;
293
+ if (!sourceId || !source) {
294
+ await ctx.sendError(id, -32e3, `session not found: ${sourceId}`);
295
+ return false;
296
+ }
297
+ if (ctx.sessions.size >= ctx.maxSessions) {
298
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
299
+ return false;
300
+ }
301
+ let forkCwd = source.cwd;
302
+ if (typeof p.cwd === "string") {
303
+ const resolved = await resolveSessionCwd(p.cwd);
304
+ if (resolved === null) {
305
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
306
+ return false;
307
+ }
308
+ forkCwd = resolved;
309
+ }
310
+ const now = (/* @__PURE__ */ new Date()).toISOString();
311
+ const sessionId = `sess_${ctx.allocId()}`;
312
+ const forked = {
313
+ id: sessionId,
314
+ cwd: forkCwd,
315
+ abort: new AbortController(),
316
+ modeId: source.modeId,
317
+ createdAt: now,
318
+ updatedAt: now,
319
+ ...source.title !== void 0 ? { title: source.title } : {}
320
+ };
321
+ const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
322
+ sessionUpdate: update.sessionUpdate,
323
+ content: structuredClone(update.content)
324
+ }));
325
+ ctx.sessions.set(sessionId, forked);
326
+ ctx.seedFor?.(sessionId, history);
327
+ ctx.onSessionNew(forked);
328
+ await ctx.persist(forked, history);
329
+ await ctx.sendNotification({
330
+ sessionId,
331
+ update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
332
+ });
333
+ await ctx.sendResult(id, {
334
+ sessionId,
335
+ modes: ctx.modes,
336
+ configOptions: ctx.configOptions
337
+ });
338
+ return false;
339
+ }
340
+ async function handleSessionPromptOp(ctx, id, params) {
341
+ const p = params ?? {};
342
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
343
+ if (!sessionId || !ctx.sessions.has(sessionId)) {
344
+ await ctx.sendError(id, -32e3, "unknown or missing sessionId");
345
+ return false;
346
+ }
347
+ if (!Array.isArray(p.prompt)) {
348
+ await ctx.sendError(id, -32602, "prompt must be an array of content blocks");
349
+ return false;
350
+ }
351
+ const session = ctx.sessions.get(sessionId);
352
+ if (session.abort.signal.aborted) {
353
+ session.abort = new AbortController();
354
+ }
355
+ const turnSignal = new AbortController();
356
+ const onCancel = () => turnSignal.abort();
357
+ session.abort.signal.addEventListener("abort", onCancel, { once: true });
358
+ const api = createRunTurnApi(
359
+ sessionId,
360
+ ctx.clientCapabilities ?? {},
361
+ (method, req) => ctx.request(method, req)
362
+ );
363
+ let result;
364
+ const pendingNotifications = [];
365
+ const emit = (update) => {
366
+ const notifPromise = ctx.sendNotification({ sessionId, update });
367
+ pendingNotifications.push(notifPromise.catch(() => {
368
+ }));
369
+ };
370
+ try {
371
+ result = await ctx.runTurn(
372
+ { sessionId, prompt: p.prompt, signal: turnSignal.signal },
373
+ emit,
374
+ api
375
+ );
376
+ } catch (err) {
377
+ session.abort.signal.removeEventListener("abort", onCancel);
378
+ const { code, message, data } = errorToJsonRpc(err);
379
+ await ctx.sendError(id, code, message, data);
380
+ return false;
381
+ }
382
+ await Promise.all(pendingNotifications);
383
+ session.abort.signal.removeEventListener("abort", onCancel);
384
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
385
+ await ctx.persist(session);
386
+ await ctx.sendResult(id, { stopReason: result.stopReason });
387
+ return false;
388
+ }
389
+ async function handleSetModeOp(ctx, id, params) {
390
+ const p = params ?? {};
391
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
392
+ const modeId = typeof p.modeId === "string" ? p.modeId : null;
393
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
394
+ if (!session || !modeId || !ctx.modes.some((m) => m.id === modeId)) {
395
+ await ctx.sendError(id, -32602, "invalid sessionId or modeId");
396
+ return false;
397
+ }
398
+ session.modeId = modeId;
399
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
400
+ await ctx.sendNotification({
401
+ sessionId,
402
+ update: { sessionUpdate: "current_mode_update", modeId }
403
+ });
404
+ await ctx.sendResult(id, {});
405
+ return false;
406
+ }
407
+ async function handleSetConfigOptionOp(ctx, id, params) {
408
+ const p = params ?? {};
409
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
410
+ const optionId = typeof p.configId === "string" ? p.configId : null;
411
+ const value = typeof p.value === "string" ? p.value : null;
412
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
413
+ const option = optionId ? ctx.configOptions.find((o) => o.id === optionId) : void 0;
414
+ if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
415
+ await ctx.sendError(id, -32602, "invalid sessionId, configId, or value");
416
+ return false;
417
+ }
418
+ option.currentValue = value;
419
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
420
+ await ctx.sendNotification({
421
+ sessionId,
422
+ update: {
423
+ sessionUpdate: "config_option_update",
424
+ configOptions: [...ctx.configOptions]
425
+ }
426
+ });
427
+ await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
428
+ return false;
429
+ }
430
+
431
+ // src/agent/protocol-handler.ts
54
432
  var ACPProtocolHandler = class {
55
433
  transport;
56
434
  defaultCwd;
@@ -159,6 +537,27 @@ var ACPProtocolHandler = class {
159
537
  } catch {
160
538
  }
161
539
  }
540
+ sessionContext() {
541
+ return {
542
+ sessions: this.sessions,
543
+ maxSessions: this.maxSessions,
544
+ defaultCwd: this.defaultCwd,
545
+ modes: this.modes,
546
+ configOptions: this.configOptions,
547
+ store: this.store,
548
+ replayFor: this.replayFor,
549
+ seedFor: this.seedFor,
550
+ onSessionNew: this.onSessionNew,
551
+ allocId: () => this.allocId(),
552
+ persist: (state, history) => this.persist(state, history),
553
+ sendNotification: (params) => this.sendNotification(params),
554
+ sendError: (id, code, message, data) => this.sendError(id, code, message, data),
555
+ sendResult: (id, result) => this.sendResult(id, result),
556
+ request: (method, params, timeoutMs) => this.request(method, params, timeoutMs),
557
+ runTurn: this.runTurn,
558
+ clientCapabilities: this.clientCapabilities
559
+ };
560
+ }
162
561
  // ────────────────────────────────────────────────────────────────────
163
562
  // Requests
164
563
  // ────────────────────────────────────────────────────────────────────
@@ -176,9 +575,9 @@ var ACPProtocolHandler = class {
176
575
  case "logout":
177
576
  return await this.handleLogout(id, params);
178
577
  case "session/new":
179
- return await this.handleSessionNew(id, params);
578
+ return await handleSessionNewOp(this.sessionContext(), id, params);
180
579
  case "session/load":
181
- return await this.handleSessionLoad(id, params);
580
+ return await handleSessionLoadOp(this.sessionContext(), id, params);
182
581
  case "session/resume":
183
582
  return await this.handleSessionResume(id, params);
184
583
  case "session/close":
@@ -186,15 +585,15 @@ var ACPProtocolHandler = class {
186
585
  case "session/delete":
187
586
  return await this.handleSessionDelete(id, params);
188
587
  case "session/prompt":
189
- return await this.handleSessionPrompt(id, params);
588
+ return await handleSessionPromptOp(this.sessionContext(), id, params);
190
589
  case "session/set_mode":
191
- return await this.handleSetMode(id, params);
590
+ return await handleSetModeOp(this.sessionContext(), id, params);
192
591
  case "session/set_config_option":
193
- return await this.handleSetConfigOption(id, params);
592
+ return await handleSetConfigOptionOp(this.sessionContext(), id, params);
194
593
  case "session/list":
195
594
  return await this.handleSessionList(id);
196
595
  case "session/fork":
197
- return await this.handleSessionFork(id, params);
596
+ return await handleSessionForkOp(this.sessionContext(), id, params);
198
597
  case "providers/list":
199
598
  return await this.handleProvidersList(id, params);
200
599
  case "providers/set":
@@ -219,232 +618,32 @@ var ACPProtocolHandler = class {
219
618
  this.clientCapabilities = p.clientCapabilities;
220
619
  }
221
620
  this.initialized = true;
222
- await this.transport.send(
223
- toWire({
224
- jsonrpc: "2.0",
225
- id,
226
- result: {
227
- protocolVersion: ACP_PROTOCOL_VERSION,
228
- agentCapabilities: {
229
- loadSession: true,
230
- promptCapabilities: {
231
- // We route ACP image blocks into the core agent's multimodal
232
- // input (server-agent-turn.promptToAgentInput); whether the
233
- // model can see them is the configured provider's concern.
234
- image: true,
235
- audio: false,
236
- embeddedContext: true
237
- },
238
- mcpCapabilities: {
239
- http: false,
240
- sse: false
241
- },
242
- sessionCapabilities: {
243
- close: {},
244
- list: {},
245
- delete: {},
246
- resume: {},
247
- fork: {}
248
- },
249
- auth: {
250
- logout: {}
251
- }
252
- },
253
- agentInfo: {
254
- name: this.agentName,
255
- title: "WrongStack",
256
- version: WRONGSTACK_VERSION
257
- },
258
- authMethods: WRONGSTACK_AUTH_METHODS,
259
- modes: this.modes,
260
- configOptions: this.configOptions
261
- }
262
- })
621
+ await this.sendResult(
622
+ id,
623
+ buildInitializeResult(this.agentName, this.modes, this.configOptions)
263
624
  );
264
625
  return false;
265
626
  }
266
627
  async handleAuthenticate(id, _params) {
267
- await this.transport.send(
268
- toWire({
269
- jsonrpc: "2.0",
270
- id,
271
- result: { outcome: "unauthenticated" }
272
- })
273
- );
628
+ await this.sendResult(id, { outcome: "unauthenticated" });
274
629
  return false;
275
630
  }
276
631
  async handleLogout(id, _params) {
277
- await this.transport.send(
278
- toWire({
279
- jsonrpc: "2.0",
280
- id,
281
- result: {}
282
- })
283
- );
632
+ await this.sendResult(id, {});
284
633
  return false;
285
634
  }
286
- async handleSessionNew(id, params) {
287
- if (this.sessions.size >= this.maxSessions) {
288
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
289
- return false;
290
- }
291
- const p = params ?? {};
292
- let cwd = this.defaultCwd;
293
- if (typeof p.cwd === "string") {
294
- const resolved = await this.resolveSessionCwd(p.cwd);
295
- if (resolved === null) {
296
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
297
- return false;
298
- }
299
- cwd = resolved;
300
- }
301
- const sessionId = `sess_${this.allocId()}`;
302
- const now = (/* @__PURE__ */ new Date()).toISOString();
303
- const state = {
304
- id: sessionId,
305
- cwd,
306
- abort: new AbortController(),
307
- modeId: DEFAULT_MODE_ID,
308
- createdAt: now,
309
- updatedAt: now
310
- };
311
- this.sessions.set(sessionId, state);
312
- this.onSessionNew(state);
313
- await this.persist(state);
314
- await this.sendNotification({
315
- sessionId,
316
- update: {
317
- sessionUpdate: "current_mode_update",
318
- modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
319
- }
320
- });
321
- if (this.configOptions.length > 0) {
322
- await this.sendNotification({
323
- sessionId,
324
- update: {
325
- sessionUpdate: "config_option_update",
326
- configOptions: [...this.configOptions]
327
- }
328
- });
329
- }
330
- await this.transport.send(
331
- toWire({
332
- jsonrpc: "2.0",
333
- id,
334
- result: {
335
- sessionId,
336
- modes: this.modes,
337
- configOptions: this.configOptions
338
- }
339
- })
340
- );
341
- return false;
342
- }
343
- async handleSessionLoad(id, params) {
635
+ async handleSessionResume(id, params) {
344
636
  const p = params ?? {};
345
637
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
346
- const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
347
638
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
348
- if (!existing && sessionId && this.store) {
349
- const persisted = await this.store.load(sessionId);
350
- if (persisted) {
351
- if (this.sessions.size >= this.maxSessions) {
352
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
353
- return false;
354
- }
355
- if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
356
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
357
- return false;
358
- }
359
- const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
360
- const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
361
- const restored = {
362
- id: sessionId,
363
- cwd: restoredCwd,
364
- abort: new AbortController(),
365
- modeId: persisted.modeId ?? DEFAULT_MODE_ID,
366
- createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
367
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
368
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
369
- };
370
- this.sessions.set(sessionId, restored);
371
- this.seedFor?.(sessionId, persisted.history ?? []);
372
- for (const update of persisted.history ?? []) {
373
- await this.sendNotification({ sessionId, update });
374
- }
375
- await this.sendNotification({
376
- sessionId,
377
- update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
378
- });
379
- await this.transport.send(
380
- toWire({
381
- jsonrpc: "2.0",
382
- id,
383
- result: {
384
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
385
- }
386
- })
387
- );
388
- return false;
389
- }
390
- }
391
639
  if (existing) {
392
640
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
393
- const replay = this.replayFor?.(sessionId);
394
- if (replay) {
395
- for (const update of replay) {
396
- await this.sendNotification({ sessionId, update });
397
- }
398
- }
399
- await this.sendNotification({
400
- sessionId,
401
- update: {
402
- sessionUpdate: "session_info_update",
403
- updatedAt: existing.updatedAt
404
- }
405
- });
406
- await this.sendNotification({
407
- sessionId,
408
- update: {
409
- sessionUpdate: "current_mode_update",
410
- modeId: existing.modeId
641
+ await this.sendResult(id, {
642
+ initialMode: {
643
+ currentModeId: existing.modeId,
644
+ availableModes: this.modes
411
645
  }
412
646
  });
413
- await this.transport.send(
414
- toWire({
415
- jsonrpc: "2.0",
416
- id,
417
- result: {
418
- initialMode: {
419
- currentModeId: existing.modeId,
420
- availableModes: this.modes
421
- }
422
- }
423
- })
424
- );
425
- return false;
426
- }
427
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
428
- return false;
429
- }
430
- async handleSessionResume(id, params) {
431
- const p = params ?? {};
432
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
433
- const existing = sessionId ? this.sessions.get(sessionId) : void 0;
434
- if (existing) {
435
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
436
- await this.transport.send(
437
- toWire({
438
- jsonrpc: "2.0",
439
- id,
440
- result: {
441
- initialMode: {
442
- currentModeId: existing.modeId,
443
- availableModes: this.modes
444
- }
445
- }
446
- })
447
- );
448
647
  return false;
449
648
  }
450
649
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -461,13 +660,7 @@ var ACPProtocolHandler = class {
461
660
  session.abort.abort();
462
661
  this.sessions.delete(sessionId);
463
662
  this.disposeSession(sessionId);
464
- await this.transport.send(
465
- toWire({
466
- jsonrpc: "2.0",
467
- id,
468
- result: {}
469
- })
470
- );
663
+ await this.sendResult(id, {});
471
664
  return false;
472
665
  }
473
666
  async handleSessionDelete(id, params) {
@@ -478,252 +671,37 @@ var ACPProtocolHandler = class {
478
671
  return false;
479
672
  }
480
673
  if (!this.sessions.has(sessionId)) {
481
- await this.transport.send(
482
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
483
- );
674
+ await this.sendResult(id, { configOptions: [...this.configOptions] });
484
675
  return false;
485
676
  }
486
677
  const session = this.sessions.get(sessionId);
487
678
  session.abort.abort();
488
679
  this.sessions.delete(sessionId);
489
680
  this.disposeSession(sessionId);
490
- await this.transport.send(
491
- toWire({
492
- jsonrpc: "2.0",
493
- id,
494
- result: {}
495
- })
496
- );
497
- return false;
498
- }
499
- async handleSessionFork(id, params) {
500
- const p = params ?? {};
501
- const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
502
- const source = sourceId ? this.sessions.get(sourceId) : void 0;
503
- if (!sourceId || !source) {
504
- await this.sendError(id, -32e3, `session not found: ${sourceId}`);
505
- return false;
506
- }
507
- if (this.sessions.size >= this.maxSessions) {
508
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
509
- return false;
510
- }
511
- let forkCwd = source.cwd;
512
- if (typeof p.cwd === "string") {
513
- const resolved = await this.resolveSessionCwd(p.cwd);
514
- if (resolved === null) {
515
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
516
- return false;
517
- }
518
- forkCwd = resolved;
519
- }
520
- const now = (/* @__PURE__ */ new Date()).toISOString();
521
- const sessionId = `sess_${this.allocId()}`;
522
- const forked = {
523
- id: sessionId,
524
- cwd: forkCwd,
525
- abort: new AbortController(),
526
- modeId: source.modeId,
527
- createdAt: now,
528
- updatedAt: now,
529
- ...source.title !== void 0 ? { title: source.title } : {}
530
- };
531
- const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
532
- sessionUpdate: update.sessionUpdate,
533
- content: structuredClone(update.content)
534
- }));
535
- this.sessions.set(sessionId, forked);
536
- this.seedFor?.(sessionId, history);
537
- this.onSessionNew(forked);
538
- await this.persist(forked, history);
539
- await this.sendNotification({
540
- sessionId,
541
- update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
542
- });
543
- await this.transport.send(
544
- toWire({
545
- jsonrpc: "2.0",
546
- id,
547
- result: {
548
- sessionId,
549
- modes: this.modes,
550
- configOptions: this.configOptions
551
- }
552
- })
553
- );
681
+ await this.sendResult(id, {});
554
682
  return false;
555
683
  }
556
684
  async handleProvidersList(id, _params) {
557
- await this.transport.send(
558
- toWire({
559
- jsonrpc: "2.0",
560
- id,
561
- result: {
562
- providers: [],
563
- currentProviderId: null
564
- }
565
- })
566
- );
685
+ await this.sendResult(id, {
686
+ providers: [],
687
+ currentProviderId: null
688
+ });
567
689
  return false;
568
690
  }
569
691
  async handleProvidersSet(id, _params) {
570
692
  await this.sendError(
571
693
  id,
572
- -32e3,
573
- "provider configuration not available through ACP; use wstack auth"
574
- );
575
- return false;
576
- }
577
- async handleProvidersDisable(id, _params) {
578
- await this.transport.send(
579
- toWire({
580
- jsonrpc: "2.0",
581
- id,
582
- result: {}
583
- })
584
- );
585
- return false;
586
- }
587
- async handleMcpMessage(id, _params) {
588
- await this.sendError(id, -32e3, "MCP message routing not available through ACP");
589
- return false;
590
- }
591
- async handleSessionPrompt(id, params) {
592
- const p = params ?? {};
593
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
594
- if (!sessionId || !this.sessions.has(sessionId)) {
595
- await this.sendError(id, -32e3, "unknown or missing sessionId");
596
- return false;
597
- }
598
- if (!Array.isArray(p.prompt)) {
599
- await this.sendError(id, -32602, "prompt must be an array of content blocks");
600
- return false;
601
- }
602
- const session = this.sessions.get(sessionId);
603
- if (session.abort.signal.aborted) {
604
- session.abort = new AbortController();
605
- }
606
- const turnSignal = new AbortController();
607
- const onCancel = () => turnSignal.abort();
608
- session.abort.signal.addEventListener("abort", onCancel, { once: true });
609
- const api = {
610
- clientCapabilities: this.clientCapabilities,
611
- requestPermission: async (req) => {
612
- const res = await this.request("session/request_permission", {
613
- sessionId,
614
- toolCall: req.toolCall,
615
- options: req.options
616
- });
617
- const outcome = res?.outcome;
618
- return outcome ?? { outcome: "cancelled" };
619
- },
620
- readTextFile: async (params2) => {
621
- const res = await this.request("fs/read_text_file", { sessionId, ...params2 });
622
- return String(res?.content ?? "");
623
- },
624
- writeTextFile: async (params2) => {
625
- await this.request("fs/write_text_file", { sessionId, ...params2 });
626
- },
627
- runTerminal: async ({ command, args, cwd }) => {
628
- const created = await this.request("terminal/create", {
629
- sessionId,
630
- command,
631
- ...args ? { args } : {},
632
- ...cwd ? { cwd } : {}
633
- });
634
- const terminalId = created?.terminalId;
635
- if (!terminalId) return { output: "", exitCode: null };
636
- try {
637
- const exit = await this.request("terminal/wait_for_exit", {
638
- sessionId,
639
- terminalId
640
- });
641
- const out = await this.request("terminal/output", { sessionId, terminalId });
642
- return {
643
- output: String(out?.output ?? ""),
644
- exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
645
- };
646
- } finally {
647
- try {
648
- await this.request("terminal/release", { sessionId, terminalId });
649
- } catch {
650
- }
651
- }
652
- }
653
- };
654
- let result;
655
- const pendingNotifications = [];
656
- const emit = (update) => {
657
- const p2 = this.sendNotification({ sessionId, update });
658
- pendingNotifications.push(p2.catch(() => {
659
- }));
660
- };
661
- try {
662
- result = await this.runTurn(
663
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
664
- emit,
665
- api
666
- );
667
- } catch (err) {
668
- session.abort.signal.removeEventListener("abort", onCancel);
669
- const { code, message, data } = errorToJsonRpc(err);
670
- await this.sendError(id, code, message, data);
671
- return false;
672
- }
673
- await Promise.all(pendingNotifications);
674
- session.abort.signal.removeEventListener("abort", onCancel);
675
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
676
- await this.persist(session);
677
- await this.transport.send(
678
- toWire({
679
- jsonrpc: "2.0",
680
- id,
681
- result: { stopReason: result.stopReason }
682
- })
694
+ -32e3,
695
+ "provider configuration not available through ACP; use wstack auth"
683
696
  );
684
697
  return false;
685
698
  }
686
- async handleSetMode(id, params) {
687
- const p = params ?? {};
688
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
689
- const modeId = typeof p.modeId === "string" ? p.modeId : null;
690
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
691
- if (!session || !modeId || !this.modes.some((m) => m.id === modeId)) {
692
- await this.sendError(id, -32602, "invalid sessionId or modeId");
693
- return false;
694
- }
695
- session.modeId = modeId;
696
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
697
- await this.sendNotification({
698
- sessionId,
699
- update: { sessionUpdate: "current_mode_update", modeId }
700
- });
701
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: {} }));
699
+ async handleProvidersDisable(id, _params) {
700
+ await this.sendResult(id, {});
702
701
  return false;
703
702
  }
704
- async handleSetConfigOption(id, params) {
705
- const p = params ?? {};
706
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
707
- const optionId = typeof p.configId === "string" ? p.configId : null;
708
- const value = typeof p.value === "string" ? p.value : null;
709
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
710
- const option = optionId ? this.configOptions.find((o) => o.id === optionId) : void 0;
711
- if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
712
- await this.sendError(id, -32602, "invalid sessionId, configId, or value");
713
- return false;
714
- }
715
- option.currentValue = value;
716
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
717
- await this.sendNotification({
718
- sessionId,
719
- update: {
720
- sessionUpdate: "config_option_update",
721
- configOptions: [...this.configOptions]
722
- }
723
- });
724
- await this.transport.send(
725
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
726
- );
703
+ async handleMcpMessage(id, _params) {
704
+ await this.sendError(id, -32e3, "MCP message routing not available through ACP");
727
705
  return false;
728
706
  }
729
707
  async handleSessionList(id) {
@@ -736,13 +714,7 @@ var ACPProtocolHandler = class {
736
714
  if (s.title !== void 0) out.title = s.title;
737
715
  return out;
738
716
  });
739
- await this.transport.send(
740
- toWire({
741
- jsonrpc: "2.0",
742
- id,
743
- result: { sessions }
744
- })
745
- );
717
+ await this.sendResult(id, { sessions });
746
718
  return false;
747
719
  }
748
720
  // ────────────────────────────────────────────────────────────────────
@@ -775,7 +747,9 @@ var ACPProtocolHandler = class {
775
747
  async sendNotification(params) {
776
748
  await this.transport.send(toWire({ jsonrpc: "2.0", method: "session/update", params }));
777
749
  }
778
- /** Best-effort durable persistence of a session + its recorded history. */
750
+ async sendResult(id, result) {
751
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result }));
752
+ }
779
753
  async persist(state, history = void 0) {
780
754
  if (!this.store) return;
781
755
  try {
@@ -788,71 +762,10 @@ var ACPProtocolHandler = class {
788
762
  if (data !== void 0) error.data = data;
789
763
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
790
764
  }
791
- /**
792
- * Allocate a session id (WS-015).
793
- *
794
- * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
795
- * handler has no per-connection ownership: any caller that names a session
796
- * id can `session/load`, `session/prompt`, `session/cancel` or
797
- * `session/delete` it. Over stdio that is academic (one client per process),
798
- * but the agent also serves over HTTP, where a guessable id is the whole
799
- * authorization story for any local process or page that reaches the port.
800
- *
801
- * Random ids do not create ownership — they remove the trivial enumeration
802
- * that made its absence exploitable. Real per-connection ownership is the
803
- * larger fix and is noted in the WS-015 test file.
804
- *
805
- * The counter is retained: it keeps ids ordered for debugging and guarantees
806
- * uniqueness within a process even in the (impossible) event of a UUID
807
- * collision. The random half is what makes the id unguessable.
808
- */
809
- /**
810
- * Resolve a client-supplied `cwd` for a session, or `null` when it is not
811
- * usable (WS-015).
812
- *
813
- * `session/new`, `session/load` and `session/fork` all took `params.cwd`
814
- * with a single `typeof === 'string'` check and nothing else. That value is
815
- * the working directory the agent then reads, writes and executes in.
816
- *
817
- * SCOPE, deliberately stated: this does NOT confine the session to a root.
818
- * In ACP the client IS the editor and legitimately names its own workspace —
819
- * Zed and JetBrains pass the project root — so a fixed boundary here would
820
- * break the integration this package exists for. What it enforces is that
821
- * the directory is absolute and actually exists as a directory: a relative
822
- * or missing `cwd` is a bug or an attack under either reading, and silently
823
- * running the agent somewhere other than where the client asked is worse
824
- * than refusing. Confinement, if wanted, belongs in an operator-set option
825
- * on top of this, not in place of it.
826
- */
827
- async resolveSessionCwd(requested) {
828
- if (!path.isAbsolute(requested)) return null;
829
- const resolved = path.resolve(requested);
830
- try {
831
- const stat3 = await fsp.stat(resolved);
832
- return stat3.isDirectory() ? resolved : null;
833
- } catch {
834
- return null;
835
- }
836
- }
837
765
  allocId() {
838
766
  return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
839
767
  }
840
768
  };
841
- function errorToJsonRpc(err) {
842
- if (err && typeof err === "object") {
843
- const e = err;
844
- if (typeof e.code === "number" && typeof e.message === "string") {
845
- const result = {
846
- code: e.code,
847
- message: e.message
848
- };
849
- if (e.data !== void 0) result.data = e.data;
850
- return result;
851
- }
852
- }
853
- const message = err instanceof Error ? err.message : String(err);
854
- return { code: -32603, message };
855
- }
856
769
 
857
770
  // src/agent/stdio-transport.ts
858
771
  import { expectDefined, writeErr } from "@wrongstack/core/utils";
@@ -2757,6 +2670,220 @@ function isBestEffortAckMethod(method) {
2757
2670
  return method === "mcp/connect" || method === "mcp/message" || method === "mcp/disconnect" || method === "elicitation/create" || method === "elicitation/complete";
2758
2671
  }
2759
2672
 
2673
+ // src/client/acp-session-ops.ts
2674
+ function filterMcpServers(agentCapabilities, servers) {
2675
+ if (!servers || servers.length === 0) return [];
2676
+ const mcpCaps = agentCapabilities.mcpCapabilities ?? {};
2677
+ return servers.filter((s) => {
2678
+ if ("type" in s && s.type === "http") return mcpCaps.http === true;
2679
+ if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
2680
+ return true;
2681
+ });
2682
+ }
2683
+ async function executeLoadSession(ctx, sessionId, mcpServers, cwd) {
2684
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2685
+ if (!ctx.agentCapabilities.loadSession) {
2686
+ throw new ACPSessionError(
2687
+ "unsupported_capability",
2688
+ "agent does not support session/load (loadSession capability not advertised)"
2689
+ );
2690
+ }
2691
+ if (ctx.sessionId) {
2692
+ await ctx.closeSession();
2693
+ }
2694
+ ctx.resetScratch();
2695
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2696
+ const id = ctx.allocId();
2697
+ const result = await ctx.sendRequest(id, "session/load", {
2698
+ sessionId,
2699
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2700
+ mcpServers: servers
2701
+ });
2702
+ if (isJsonRpcError(result)) {
2703
+ throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
2704
+ }
2705
+ ctx.setSessionId(sessionId);
2706
+ }
2707
+ async function executeResumeSession(ctx, sessionId, mcpServers, cwd) {
2708
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2709
+ if (!ctx.agentCapabilities.sessionCapabilities?.resume) {
2710
+ throw new ACPSessionError(
2711
+ "unsupported_capability",
2712
+ "agent does not support session/resume (sessionCapabilities.resume not advertised)"
2713
+ );
2714
+ }
2715
+ if (ctx.sessionId) {
2716
+ await ctx.closeSession();
2717
+ }
2718
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2719
+ const id = ctx.allocId();
2720
+ const result = await ctx.sendRequest(id, "session/resume", {
2721
+ sessionId,
2722
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2723
+ mcpServers: servers
2724
+ });
2725
+ if (isJsonRpcError(result)) {
2726
+ throw new ACPSessionError(
2727
+ "prompt_failed",
2728
+ `session/resume failed: ${result.message}`,
2729
+ result
2730
+ );
2731
+ }
2732
+ ctx.setSessionId(sessionId);
2733
+ }
2734
+ async function executeListSessions(ctx, cursor, cwd) {
2735
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2736
+ if (!ctx.agentCapabilities.sessionCapabilities?.list) {
2737
+ throw new ACPSessionError(
2738
+ "unsupported_capability",
2739
+ "agent does not support session/list (sessionCapabilities.list not advertised)"
2740
+ );
2741
+ }
2742
+ const id = ctx.allocId();
2743
+ const params = {};
2744
+ if (cursor !== void 0) params.cursor = cursor;
2745
+ if (cwd !== void 0) params.cwd = cwd;
2746
+ const result = await ctx.sendRequest(id, "session/list", params);
2747
+ if (isJsonRpcError(result)) {
2748
+ throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
2749
+ }
2750
+ const r = result;
2751
+ return {
2752
+ sessions: r.sessions ?? [],
2753
+ nextCursor: r.nextCursor
2754
+ };
2755
+ }
2756
+ async function executeDeleteSession(ctx, sessionId) {
2757
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2758
+ if (!ctx.agentCapabilities.sessionCapabilities?.delete) {
2759
+ throw new ACPSessionError(
2760
+ "unsupported_capability",
2761
+ "agent does not support session/delete (sessionCapabilities.delete not advertised)"
2762
+ );
2763
+ }
2764
+ const id = ctx.allocId();
2765
+ const result = await ctx.sendRequest(id, "session/delete", { sessionId });
2766
+ if (isJsonRpcError(result)) {
2767
+ throw new ACPSessionError(
2768
+ "prompt_failed",
2769
+ `session/delete failed: ${result.message}`,
2770
+ result
2771
+ );
2772
+ }
2773
+ if (ctx.sessionId === sessionId) {
2774
+ ctx.setSessionId(null);
2775
+ }
2776
+ }
2777
+ async function executeForkSession(ctx, sourceSessionId, cwd, mcpServers) {
2778
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2779
+ const servers = filterMcpServers(ctx.agentCapabilities, mcpServers ?? ctx.opts.mcpServers);
2780
+ const id = ctx.allocId();
2781
+ const result = await ctx.sendRequest(id, "session/fork", {
2782
+ sessionId: sourceSessionId,
2783
+ cwd: cwd ?? ctx.opts.cwd ?? ctx.opts.projectRoot,
2784
+ ...servers.length > 0 ? { mcpServers: servers } : {}
2785
+ });
2786
+ if (isJsonRpcError(result)) {
2787
+ throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
2788
+ }
2789
+ const newId = result.sessionId;
2790
+ if (typeof newId !== "string" || !newId) {
2791
+ throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
2792
+ }
2793
+ return newId;
2794
+ }
2795
+ async function executeSetMode(ctx, sessionId, modeId) {
2796
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2797
+ const id = ctx.allocId();
2798
+ const result = await ctx.sendRequest(id, "session/set_mode", { sessionId, modeId });
2799
+ if (isJsonRpcError(result)) {
2800
+ throw new ACPSessionError(
2801
+ "prompt_failed",
2802
+ `session/set_mode failed: ${result.message}`,
2803
+ result
2804
+ );
2805
+ }
2806
+ }
2807
+ async function executeSetConfigOption(ctx, sessionId, configId, value) {
2808
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2809
+ const id = ctx.allocId();
2810
+ const result = await ctx.sendRequest(id, "session/set_config_option", {
2811
+ sessionId,
2812
+ configId,
2813
+ value
2814
+ });
2815
+ if (isJsonRpcError(result)) {
2816
+ throw new ACPSessionError(
2817
+ "prompt_failed",
2818
+ `session/set_config_option failed: ${result.message}`,
2819
+ result
2820
+ );
2821
+ }
2822
+ }
2823
+ async function executeListProviders(ctx) {
2824
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2825
+ const id = ctx.allocId();
2826
+ const result = await ctx.sendRequest(id, "providers/list", {});
2827
+ if (isJsonRpcError(result)) {
2828
+ throw new ACPSessionError(
2829
+ "prompt_failed",
2830
+ `providers/list failed: ${result.message}`,
2831
+ result
2832
+ );
2833
+ }
2834
+ const r = result;
2835
+ return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
2836
+ }
2837
+ async function executeSetProvider(ctx, providerId, config) {
2838
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2839
+ const id = ctx.allocId();
2840
+ const result = await ctx.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
2841
+ if (isJsonRpcError(result)) {
2842
+ throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
2843
+ }
2844
+ }
2845
+ async function executeDisableProvider(ctx) {
2846
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2847
+ const id = ctx.allocId();
2848
+ const result = await ctx.sendRequest(id, "providers/disable", {});
2849
+ if (isJsonRpcError(result)) {
2850
+ throw new ACPSessionError(
2851
+ "prompt_failed",
2852
+ `providers/disable failed: ${result.message}`,
2853
+ result
2854
+ );
2855
+ }
2856
+ }
2857
+ async function executeMcpMessage(ctx, connectionId, message) {
2858
+ if (ctx.closed) throw new ACPSessionError("closed", "session is closed");
2859
+ const id = ctx.allocId();
2860
+ const result = await ctx.sendRequest(id, "mcp/message", { connectionId, message });
2861
+ if (isJsonRpcError(result)) {
2862
+ throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
2863
+ }
2864
+ return result;
2865
+ }
2866
+ async function executeCreateSession(ctx) {
2867
+ const servers = filterMcpServers(ctx.agentCapabilities, ctx.opts.mcpServers);
2868
+ const id = ctx.allocId();
2869
+ const result = await ctx.sendRequest(id, "session/new", {
2870
+ cwd: ctx.opts.cwd ?? ctx.opts.projectRoot,
2871
+ mcpServers: servers
2872
+ });
2873
+ if (isJsonRpcError(result)) {
2874
+ throw new ACPSessionError(
2875
+ "session_create_failed",
2876
+ `session/new failed: ${result.message}`,
2877
+ result
2878
+ );
2879
+ }
2880
+ const sessionId = result.sessionId;
2881
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
2882
+ throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
2883
+ }
2884
+ return sessionId;
2885
+ }
2886
+
2760
2887
  // src/client/acp-session.ts
2761
2888
  var ACPSession = class _ACPSession {
2762
2889
  transport;
@@ -2945,9 +3072,6 @@ var ACPSession = class _ACPSession {
2945
3072
  /**
2946
3073
  * Authenticate with the agent using one of the advertised auth methods.
2947
3074
  * Call this AFTER start() and BEFORE any session/new call.
2948
- *
2949
- * Throws ACPSessionError('auth_failed') if the agent rejects the
2950
- * authentication or if the methodId is not in the advertised list.
2951
3075
  */
2952
3076
  async authenticate(methodId) {
2953
3077
  if (this.state === "closed") {
@@ -2994,268 +3118,59 @@ var ACPSession = class _ACPSession {
2994
3118
  this.state = "ready";
2995
3119
  }
2996
3120
  // ──────────────────────────────────────────────────────────────────────
2997
- // Session management
3121
+ // Session management delegation
2998
3122
  // ──────────────────────────────────────────────────────────────────────
2999
- /**
3000
- * Load an existing session. The agent replays the conversation history
3001
- * via session/update notifications before responding.
3002
- *
3003
- * Only works if the agent advertises `loadSession` capability.
3004
- *
3005
- * @param sessionId - The session to load
3006
- * @param mcpServers - Optional MCP servers (defaults to options.mcpServers)
3007
- * @param cwd - Optional working directory (defaults to options.cwd or projectRoot)
3008
- */
3123
+ opContext() {
3124
+ return {
3125
+ closed: this.closed,
3126
+ sessionId: this.sessionId,
3127
+ agentCapabilities: this.agentCapabilities,
3128
+ opts: this.opts,
3129
+ allocId: () => this.allocId(),
3130
+ sendRequest: (id, method, params, timeoutMs) => this.sendRequest(id, method, params, timeoutMs),
3131
+ setSessionId: (id) => {
3132
+ this.sessionId = id;
3133
+ },
3134
+ resetScratch: () => this.resetScratch(),
3135
+ closeSession: () => this.closeSession()
3136
+ };
3137
+ }
3009
3138
  async loadSession(sessionId, mcpServers, cwd) {
3010
- if (this.closed) {
3011
- throw new ACPSessionError("closed", "session is closed");
3012
- }
3013
- if (!this.agentCapabilities.loadSession) {
3014
- throw new ACPSessionError(
3015
- "unsupported_capability",
3016
- "agent does not support session/load (loadSession capability not advertised)"
3017
- );
3018
- }
3019
- if (this.sessionId) {
3020
- await this.closeSession();
3021
- }
3022
- this.resetScratch();
3023
- const servers = this.filterMcpServers(mcpServers ?? this.opts.mcpServers);
3024
- const id = this.allocId();
3025
- const result = await this.sendRequest(id, "session/load", {
3026
- sessionId,
3027
- cwd: cwd ?? this.opts.cwd ?? this.opts.projectRoot,
3028
- mcpServers: servers
3029
- });
3030
- if (isJsonRpcError(result)) {
3031
- throw new ACPSessionError("prompt_failed", `session/load failed: ${result.message}`, result);
3032
- }
3033
- this.sessionId = sessionId;
3139
+ return executeLoadSession(this.opContext(), sessionId, mcpServers, cwd);
3034
3140
  }
3035
- /**
3036
- * Resume an existing session without replaying history.
3037
- *
3038
- * Only works if the agent advertises `sessionCapabilities.resume`.
3039
- *
3040
- * @param sessionId - The session to resume
3041
- * @param mcpServers - Optional MCP servers (defaults to options.mcpServers)
3042
- * @param cwd - Optional working directory (defaults to options.cwd or projectRoot)
3043
- */
3044
3141
  async resumeSession(sessionId, mcpServers, cwd) {
3045
- if (this.closed) {
3046
- throw new ACPSessionError("closed", "session is closed");
3047
- }
3048
- if (!this.agentCapabilities.sessionCapabilities?.resume) {
3049
- throw new ACPSessionError(
3050
- "unsupported_capability",
3051
- "agent does not support session/resume (sessionCapabilities.resume not advertised)"
3052
- );
3053
- }
3054
- if (this.sessionId) {
3055
- await this.closeSession();
3056
- }
3057
- const servers = this.filterMcpServers(mcpServers ?? this.opts.mcpServers);
3058
- const id = this.allocId();
3059
- const result = await this.sendRequest(id, "session/resume", {
3060
- sessionId,
3061
- cwd: cwd ?? this.opts.cwd ?? this.opts.projectRoot,
3062
- mcpServers: servers
3063
- });
3064
- if (isJsonRpcError(result)) {
3065
- throw new ACPSessionError(
3066
- "prompt_failed",
3067
- `session/resume failed: ${result.message}`,
3068
- result
3069
- );
3070
- }
3071
- this.sessionId = sessionId;
3142
+ return executeResumeSession(this.opContext(), sessionId, mcpServers, cwd);
3072
3143
  }
3073
- /**
3074
- * List existing sessions known to the agent.
3075
- *
3076
- * Only works if the agent advertises `sessionCapabilities.list`.
3077
- */
3078
3144
  async listSessions(cursor, cwd) {
3079
- if (this.closed) {
3080
- throw new ACPSessionError("closed", "session is closed");
3081
- }
3082
- if (!this.agentCapabilities.sessionCapabilities?.list) {
3083
- throw new ACPSessionError(
3084
- "unsupported_capability",
3085
- "agent does not support session/list (sessionCapabilities.list not advertised)"
3086
- );
3087
- }
3088
- const id = this.allocId();
3089
- const params = {};
3090
- if (cursor !== void 0) params.cursor = cursor;
3091
- if (cwd !== void 0) params.cwd = cwd;
3092
- const result = await this.sendRequest(id, "session/list", params);
3093
- if (isJsonRpcError(result)) {
3094
- throw new ACPSessionError("prompt_failed", `session/list failed: ${result.message}`, result);
3095
- }
3096
- const r = result;
3097
- return {
3098
- sessions: r.sessions ?? [],
3099
- nextCursor: r.nextCursor
3100
- };
3145
+ return executeListSessions(this.opContext(), cursor, cwd);
3101
3146
  }
3102
- /**
3103
- * Delete a session from the agent's session list.
3104
- *
3105
- * Only works if the agent advertises `sessionCapabilities.delete`.
3106
- */
3107
3147
  async deleteSession(sessionId) {
3108
- if (this.closed) {
3109
- throw new ACPSessionError("closed", "session is closed");
3110
- }
3111
- if (!this.agentCapabilities.sessionCapabilities?.delete) {
3112
- throw new ACPSessionError(
3113
- "unsupported_capability",
3114
- "agent does not support session/delete (sessionCapabilities.delete not advertised)"
3115
- );
3116
- }
3117
- const id = this.allocId();
3118
- const result = await this.sendRequest(id, "session/delete", { sessionId });
3119
- if (isJsonRpcError(result)) {
3120
- throw new ACPSessionError(
3121
- "prompt_failed",
3122
- `session/delete failed: ${result.message}`,
3123
- result
3124
- );
3125
- }
3126
- if (this.sessionId === sessionId) {
3127
- this.sessionId = null;
3128
- }
3148
+ return executeDeleteSession(this.opContext(), sessionId);
3129
3149
  }
3130
- /**
3131
- * Fork a session — create a new session from an existing one.
3132
- */
3133
3150
  async forkSession(sourceSessionId, cwd, mcpServers) {
3134
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3135
- const servers = this.filterMcpServers(mcpServers ?? this.opts.mcpServers);
3136
- const id = this.allocId();
3137
- const result = await this.sendRequest(id, "session/fork", {
3138
- sessionId: sourceSessionId,
3139
- cwd: cwd ?? this.opts.cwd ?? this.opts.projectRoot,
3140
- ...servers.length > 0 ? { mcpServers: servers } : {}
3141
- });
3142
- if (isJsonRpcError(result)) {
3143
- throw new ACPSessionError("prompt_failed", `session/fork failed: ${result.message}`, result);
3144
- }
3145
- const newId = result.sessionId;
3146
- if (typeof newId !== "string" || !newId) {
3147
- throw new ACPSessionError("protocol_error", "session/fork returned no sessionId", result);
3148
- }
3149
- return newId;
3151
+ return executeForkSession(this.opContext(), sourceSessionId, cwd, mcpServers);
3150
3152
  }
3151
- /**
3152
- * Set the active mode for a session.
3153
- */
3154
3153
  async setMode(sessionId, modeId) {
3155
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3156
- const id = this.allocId();
3157
- const result = await this.sendRequest(id, "session/set_mode", { sessionId, modeId });
3158
- if (isJsonRpcError(result)) {
3159
- throw new ACPSessionError(
3160
- "prompt_failed",
3161
- `session/set_mode failed: ${result.message}`,
3162
- result
3163
- );
3164
- }
3154
+ return executeSetMode(this.opContext(), sessionId, modeId);
3165
3155
  }
3166
- /**
3167
- * Set a configuration option for a session.
3168
- */
3169
3156
  async setConfigOption(sessionId, configId, value) {
3170
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3171
- const id = this.allocId();
3172
- const result = await this.sendRequest(id, "session/set_config_option", {
3173
- sessionId,
3174
- configId,
3175
- value
3176
- });
3177
- if (isJsonRpcError(result)) {
3178
- throw new ACPSessionError(
3179
- "prompt_failed",
3180
- `session/set_config_option failed: ${result.message}`,
3181
- result
3182
- );
3183
- }
3157
+ return executeSetConfigOption(this.opContext(), sessionId, configId, value);
3184
3158
  }
3185
- /**
3186
- * List available providers and the current provider.
3187
- */
3188
3159
  async listProviders() {
3189
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3190
- const id = this.allocId();
3191
- const result = await this.sendRequest(id, "providers/list", {});
3192
- if (isJsonRpcError(result)) {
3193
- throw new ACPSessionError(
3194
- "prompt_failed",
3195
- `providers/list failed: ${result.message}`,
3196
- result
3197
- );
3198
- }
3199
- const r = result;
3200
- return { providers: r.providers ?? [], currentProviderId: r.currentProviderId ?? null };
3160
+ return executeListProviders(this.opContext());
3201
3161
  }
3202
- /**
3203
- * Send an MCP message to the agent for routing.
3204
- */
3205
3162
  async mcpMessage(connectionId, message) {
3206
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3207
- const id = this.allocId();
3208
- const result = await this.sendRequest(id, "mcp/message", { connectionId, message });
3209
- if (isJsonRpcError(result)) {
3210
- throw new ACPSessionError("prompt_failed", `mcp/message failed: ${result.message}`, result);
3211
- }
3212
- return result;
3163
+ return executeMcpMessage(this.opContext(), connectionId, message);
3213
3164
  }
3214
- /**
3215
- * Set the active provider for the agent.
3216
- */
3217
3165
  async setProvider(providerId, config) {
3218
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3219
- const id = this.allocId();
3220
- const result = await this.sendRequest(id, "providers/set", { providerId, ...config ?? {} });
3221
- if (isJsonRpcError(result)) {
3222
- throw new ACPSessionError("prompt_failed", `providers/set failed: ${result.message}`, result);
3223
- }
3166
+ return executeSetProvider(this.opContext(), providerId, config);
3224
3167
  }
3225
- /**
3226
- * Disable the current provider.
3227
- */
3228
3168
  async disableProvider() {
3229
- if (this.closed) throw new ACPSessionError("closed", "session is closed");
3230
- const id = this.allocId();
3231
- const result = await this.sendRequest(id, "providers/disable", {});
3232
- if (isJsonRpcError(result)) {
3233
- throw new ACPSessionError(
3234
- "prompt_failed",
3235
- `providers/disable failed: ${result.message}`,
3236
- result
3237
- );
3238
- }
3169
+ return executeDisableProvider(this.opContext());
3239
3170
  }
3240
3171
  // ──────────────────────────────────────────────────────────────────────
3241
3172
  // Prompt
3242
3173
  // ──────────────────────────────────────────────────────────────────────
3243
- /**
3244
- * Run one prompt turn. Creates a session if needed, sends the
3245
- * prompt, streams session/update notifications, and resolves with
3246
- * the agent's response.
3247
- *
3248
- * @param blocks - Content blocks to send. Use `textContent()` for plain
3249
- * text, or include ImageContent/AudioContent if the agent's
3250
- * `promptCapabilities` allow it.
3251
- * @param signal - AbortSignal for cancellation.
3252
- *
3253
- * Cancellation: if `signal` aborts mid-prompt, we send
3254
- * `session/cancel` (a notification per spec) and keep accepting
3255
- * updates until the agent returns with `stopReason: 'cancelled'`.
3256
- * The result is the same shape as a normal turn, with
3257
- * `stopReason === 'cancelled'`.
3258
- */
3259
3174
  async prompt(blocks, signal, onProgress) {
3260
3175
  if (this.closed) {
3261
3176
  throw new ACPSessionError("closed", "session is closed");
@@ -3267,7 +3182,7 @@ var ACPSession = class _ACPSession {
3267
3182
  return emptyRunResult("cancelled");
3268
3183
  }
3269
3184
  if (!this.sessionId) {
3270
- await this.createSession();
3185
+ this.sessionId = await executeCreateSession(this.opContext());
3271
3186
  }
3272
3187
  this.resetScratch();
3273
3188
  this.progressHandler = onProgress ?? null;
@@ -3329,33 +3244,6 @@ var ACPSession = class _ACPSession {
3329
3244
  thoughts: this.scratch.thoughts
3330
3245
  };
3331
3246
  }
3332
- async createSession() {
3333
- const servers = this.filterMcpServers(this.opts.mcpServers);
3334
- const id = this.allocId();
3335
- const result = await this.sendRequest(id, "session/new", {
3336
- cwd: this.opts.cwd ?? this.opts.projectRoot,
3337
- mcpServers: servers
3338
- });
3339
- if (isJsonRpcError(result)) {
3340
- throw new ACPSessionError(
3341
- "session_create_failed",
3342
- `session/new failed: ${result.message}`,
3343
- result
3344
- );
3345
- }
3346
- const sessionId = result.sessionId;
3347
- if (typeof sessionId !== "string" || sessionId.length === 0) {
3348
- throw new ACPSessionError("protocol_error", "session/new returned no sessionId", result);
3349
- }
3350
- this.sessionId = sessionId;
3351
- }
3352
- /**
3353
- * Close the current session gracefully (if the agent supports it).
3354
- *
3355
- * Sends `session/close` JSON-RPC request, then clears the local
3356
- * session id. Best-effort — errors are swallowed so the caller can
3357
- * always proceed to transport teardown.
3358
- */
3359
3247
  async closeSession() {
3360
3248
  if (!this.sessionId) return;
3361
3249
  const sid = this.sessionId;
@@ -3371,7 +3259,6 @@ var ACPSession = class _ACPSession {
3371
3259
  // ──────────────────────────────────────────────────────────────────────
3372
3260
  // Lifecycle — close
3373
3261
  // ──────────────────────────────────────────────────────────────────────
3374
- /** Tear down the session and kill the child process. */
3375
3262
  async close() {
3376
3263
  if (this.closed) return;
3377
3264
  this.closed = true;
@@ -3398,24 +3285,6 @@ var ACPSession = class _ACPSession {
3398
3285
  } catch {
3399
3286
  }
3400
3287
  }
3401
- // ──────────────────────────────────────────────────────────────────────
3402
- // Helpers
3403
- // ──────────────────────────────────────────────────────────────────────
3404
- /**
3405
- * Filter MCP servers according to agent capabilities.
3406
- * - Stdio servers are always included.
3407
- * - HTTP servers are only included if agent supports mcpCapabilities.http.
3408
- * - SSE servers are only included if agent supports mcpCapabilities.sse.
3409
- */
3410
- filterMcpServers(servers) {
3411
- if (!servers || servers.length === 0) return [];
3412
- const mcpCaps = this.agentCapabilities.mcpCapabilities ?? {};
3413
- return servers.filter((s) => {
3414
- if ("type" in s && s.type === "http") return mcpCaps.http === true;
3415
- if ("type" in s && s.type === "sse") return mcpCaps.sse === true;
3416
- return true;
3417
- });
3418
- }
3419
3288
  // ────────────────────────────────────────────────────────────────────
3420
3289
  // Wire layer
3421
3290
  // ────────────────────────────────────────────────────────────────────
@@ -3446,21 +3315,9 @@ var ACPSession = class _ACPSession {
3446
3315
  });
3447
3316
  });
3448
3317
  }
3449
- /**
3450
- * Send a JSON-RPC 2.0 success response to an agent-initiated request.
3451
- *
3452
- * Per JSON-RPC 2.0 (and the official ACP SDK's message router) a Response
3453
- * object MUST carry `jsonrpc: "2.0"` and MUST NOT carry a `method` field —
3454
- * the SDK classifies any object with a `method` key as a Request and drops
3455
- * it as a response, so an agent's `fs/*`, `terminal/*`, or
3456
- * `session/request_permission` callback would hang forever. The legacy
3457
- * `ACPMessage` type predates v1 (requires `method`, lacks `jsonrpc`), so we
3458
- * build the correct wire object and cast at the boundary.
3459
- */
3460
3318
  sendResult(id, result) {
3461
3319
  return this.transport.send({ jsonrpc: "2.0", id, result });
3462
3320
  }
3463
- /** Send a JSON-RPC 2.0 error response (no `method` field, per spec). */
3464
3321
  sendErrorResponse(id, code, message) {
3465
3322
  return this.transport.send({
3466
3323
  jsonrpc: "2.0",
@@ -3556,9 +3413,7 @@ var ACPSession = class _ACPSession {
3556
3413
  } catch {
3557
3414
  }
3558
3415
  }
3559
- /** Live progress handler installed for the duration of a `prompt()` turn. */
3560
3416
  progressHandler = null;
3561
- // Per-prompt scratch state
3562
3417
  scratch = createSessionScratch();
3563
3418
  resetScratch() {
3564
3419
  this.scratch = createSessionScratch();