@wrongstack/acp 0.306.4 → 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.
@@ -7,8 +7,6 @@ import { expandIPv6, writeErr as writeErr2 } from "@wrongstack/core/utils";
7
7
 
8
8
  // src/agent/protocol-handler.ts
9
9
  import { randomUUID } from "node:crypto";
10
- import * as fsp from "node:fs/promises";
11
- import * as path from "node:path";
12
10
 
13
11
  // src/types/acp-v1.ts
14
12
  var ACP_PROTOCOL_VERSION = 1;
@@ -34,7 +32,9 @@ function toWire(msg) {
34
32
  }
35
33
  var WRONGSTACK_VERSION = ACP_PACKAGE_VERSION;
36
34
 
37
- // src/agent/protocol-handler.ts
35
+ // src/agent/protocol-session-ops.ts
36
+ import * as fsp from "node:fs/promises";
37
+ import * as path from "node:path";
38
38
  var WRONGSTACK_AUTH_METHODS = [
39
39
  {
40
40
  id: "wrongstack-auth",
@@ -53,6 +53,384 @@ var DEFAULT_MODES = [
53
53
  description: "Default agent mode for code-generation tasks."
54
54
  }
55
55
  ];
56
+ async function resolveSessionCwd(requested) {
57
+ if (!path.isAbsolute(requested)) return null;
58
+ const resolved = path.resolve(requested);
59
+ try {
60
+ const stat2 = await fsp.stat(resolved);
61
+ return stat2.isDirectory() ? resolved : null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ function errorToJsonRpc(err) {
67
+ if (err && typeof err === "object") {
68
+ const e = err;
69
+ if (typeof e.code === "number" && typeof e.message === "string") {
70
+ const result = {
71
+ code: e.code,
72
+ message: e.message
73
+ };
74
+ if (e.data !== void 0) result.data = e.data;
75
+ return result;
76
+ }
77
+ }
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ return { code: -32603, message };
80
+ }
81
+ function createRunTurnApi(sessionId, clientCapabilities, request) {
82
+ return {
83
+ clientCapabilities,
84
+ requestPermission: async (req) => {
85
+ const res = await request("session/request_permission", {
86
+ sessionId,
87
+ toolCall: req.toolCall,
88
+ options: req.options
89
+ });
90
+ const outcome = res?.outcome;
91
+ return outcome ?? { outcome: "cancelled" };
92
+ },
93
+ readTextFile: async (params) => {
94
+ const res = await request("fs/read_text_file", { sessionId, ...params });
95
+ return String(res?.content ?? "");
96
+ },
97
+ writeTextFile: async (params) => {
98
+ await request("fs/write_text_file", { sessionId, ...params });
99
+ },
100
+ runTerminal: async ({ command, args, cwd }) => {
101
+ const created = await request("terminal/create", {
102
+ sessionId,
103
+ command,
104
+ ...args ? { args } : {},
105
+ ...cwd ? { cwd } : {}
106
+ });
107
+ const terminalId = created?.terminalId;
108
+ if (!terminalId) return { output: "", exitCode: null };
109
+ try {
110
+ const exit = await request("terminal/wait_for_exit", {
111
+ sessionId,
112
+ terminalId
113
+ });
114
+ const out = await request("terminal/output", { sessionId, terminalId });
115
+ return {
116
+ output: String(out?.output ?? ""),
117
+ exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
118
+ };
119
+ } finally {
120
+ try {
121
+ await request("terminal/release", { sessionId, terminalId });
122
+ } catch {
123
+ }
124
+ }
125
+ }
126
+ };
127
+ }
128
+ function buildInitializeResult(agentName, modes, configOptions) {
129
+ return {
130
+ protocolVersion: ACP_PROTOCOL_VERSION,
131
+ agentCapabilities: {
132
+ loadSession: true,
133
+ promptCapabilities: {
134
+ image: true,
135
+ audio: false,
136
+ embeddedContext: true
137
+ },
138
+ mcpCapabilities: {
139
+ http: false,
140
+ sse: false
141
+ },
142
+ sessionCapabilities: {
143
+ close: {},
144
+ list: {},
145
+ delete: {},
146
+ resume: {},
147
+ fork: {}
148
+ },
149
+ auth: {
150
+ logout: {}
151
+ }
152
+ },
153
+ agentInfo: {
154
+ name: agentName,
155
+ title: "WrongStack",
156
+ version: WRONGSTACK_VERSION
157
+ },
158
+ authMethods: WRONGSTACK_AUTH_METHODS,
159
+ modes,
160
+ configOptions
161
+ };
162
+ }
163
+
164
+ // src/agent/protocol-session-management.ts
165
+ async function handleSessionNewOp(ctx, id, params) {
166
+ if (ctx.sessions.size >= ctx.maxSessions) {
167
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
168
+ return false;
169
+ }
170
+ const p = params ?? {};
171
+ let cwd = ctx.defaultCwd;
172
+ if (typeof p.cwd === "string") {
173
+ const resolved = await resolveSessionCwd(p.cwd);
174
+ if (resolved === null) {
175
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
176
+ return false;
177
+ }
178
+ cwd = resolved;
179
+ }
180
+ const sessionId = `sess_${ctx.allocId()}`;
181
+ const now = (/* @__PURE__ */ new Date()).toISOString();
182
+ const state = {
183
+ id: sessionId,
184
+ cwd,
185
+ abort: new AbortController(),
186
+ modeId: DEFAULT_MODE_ID,
187
+ createdAt: now,
188
+ updatedAt: now
189
+ };
190
+ ctx.sessions.set(sessionId, state);
191
+ ctx.onSessionNew(state);
192
+ await ctx.persist(state);
193
+ await ctx.sendNotification({
194
+ sessionId,
195
+ update: {
196
+ sessionUpdate: "current_mode_update",
197
+ modeId: ctx.modes[0]?.id ?? DEFAULT_MODE_ID
198
+ }
199
+ });
200
+ if (ctx.configOptions.length > 0) {
201
+ await ctx.sendNotification({
202
+ sessionId,
203
+ update: {
204
+ sessionUpdate: "config_option_update",
205
+ configOptions: [...ctx.configOptions]
206
+ }
207
+ });
208
+ }
209
+ await ctx.sendResult(id, {
210
+ sessionId,
211
+ modes: ctx.modes,
212
+ configOptions: ctx.configOptions
213
+ });
214
+ return false;
215
+ }
216
+ async function handleSessionLoadOp(ctx, id, params) {
217
+ const p = params ?? {};
218
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
219
+ const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
220
+ const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
221
+ if (!existing && sessionId && ctx.store) {
222
+ const persisted = await ctx.store.load(sessionId);
223
+ if (persisted) {
224
+ if (ctx.sessions.size >= ctx.maxSessions) {
225
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
226
+ return false;
227
+ }
228
+ if (loadCwd !== void 0 && await resolveSessionCwd(loadCwd) === null) {
229
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
230
+ return false;
231
+ }
232
+ const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
233
+ const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
234
+ const restored = {
235
+ id: sessionId,
236
+ cwd: restoredCwd,
237
+ abort: new AbortController(),
238
+ modeId: persisted.modeId ?? DEFAULT_MODE_ID,
239
+ createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
240
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
241
+ ...persisted.title !== void 0 ? { title: persisted.title } : {}
242
+ };
243
+ ctx.sessions.set(sessionId, restored);
244
+ ctx.seedFor?.(sessionId, persisted.history ?? []);
245
+ for (const update of persisted.history ?? []) {
246
+ await ctx.sendNotification({ sessionId, update });
247
+ }
248
+ await ctx.sendNotification({
249
+ sessionId,
250
+ update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
251
+ });
252
+ await ctx.sendResult(id, {
253
+ initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
254
+ });
255
+ return false;
256
+ }
257
+ }
258
+ if (existing) {
259
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
260
+ const replay = ctx.replayFor?.(sessionId);
261
+ if (replay) {
262
+ for (const update of replay) {
263
+ await ctx.sendNotification({ sessionId, update });
264
+ }
265
+ }
266
+ await ctx.sendNotification({
267
+ sessionId,
268
+ update: {
269
+ sessionUpdate: "session_info_update",
270
+ updatedAt: existing.updatedAt
271
+ }
272
+ });
273
+ await ctx.sendNotification({
274
+ sessionId,
275
+ update: {
276
+ sessionUpdate: "current_mode_update",
277
+ modeId: existing.modeId
278
+ }
279
+ });
280
+ await ctx.sendResult(id, {
281
+ initialMode: {
282
+ currentModeId: existing.modeId,
283
+ availableModes: ctx.modes
284
+ }
285
+ });
286
+ return false;
287
+ }
288
+ await ctx.sendError(id, -32e3, `session not found: ${sessionId}`);
289
+ return false;
290
+ }
291
+ async function handleSessionForkOp(ctx, id, params) {
292
+ const p = params ?? {};
293
+ const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
294
+ const source = sourceId ? ctx.sessions.get(sourceId) : void 0;
295
+ if (!sourceId || !source) {
296
+ await ctx.sendError(id, -32e3, `session not found: ${sourceId}`);
297
+ return false;
298
+ }
299
+ if (ctx.sessions.size >= ctx.maxSessions) {
300
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
301
+ return false;
302
+ }
303
+ let forkCwd = source.cwd;
304
+ if (typeof p.cwd === "string") {
305
+ const resolved = await resolveSessionCwd(p.cwd);
306
+ if (resolved === null) {
307
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
308
+ return false;
309
+ }
310
+ forkCwd = resolved;
311
+ }
312
+ const now = (/* @__PURE__ */ new Date()).toISOString();
313
+ const sessionId = `sess_${ctx.allocId()}`;
314
+ const forked = {
315
+ id: sessionId,
316
+ cwd: forkCwd,
317
+ abort: new AbortController(),
318
+ modeId: source.modeId,
319
+ createdAt: now,
320
+ updatedAt: now,
321
+ ...source.title !== void 0 ? { title: source.title } : {}
322
+ };
323
+ const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
324
+ sessionUpdate: update.sessionUpdate,
325
+ content: structuredClone(update.content)
326
+ }));
327
+ ctx.sessions.set(sessionId, forked);
328
+ ctx.seedFor?.(sessionId, history);
329
+ ctx.onSessionNew(forked);
330
+ await ctx.persist(forked, history);
331
+ await ctx.sendNotification({
332
+ sessionId,
333
+ update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
334
+ });
335
+ await ctx.sendResult(id, {
336
+ sessionId,
337
+ modes: ctx.modes,
338
+ configOptions: ctx.configOptions
339
+ });
340
+ return false;
341
+ }
342
+ async function handleSessionPromptOp(ctx, id, params) {
343
+ const p = params ?? {};
344
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
345
+ if (!sessionId || !ctx.sessions.has(sessionId)) {
346
+ await ctx.sendError(id, -32e3, "unknown or missing sessionId");
347
+ return false;
348
+ }
349
+ if (!Array.isArray(p.prompt)) {
350
+ await ctx.sendError(id, -32602, "prompt must be an array of content blocks");
351
+ return false;
352
+ }
353
+ const session = ctx.sessions.get(sessionId);
354
+ if (session.abort.signal.aborted) {
355
+ session.abort = new AbortController();
356
+ }
357
+ const turnSignal = new AbortController();
358
+ const onCancel = () => turnSignal.abort();
359
+ session.abort.signal.addEventListener("abort", onCancel, { once: true });
360
+ const api = createRunTurnApi(
361
+ sessionId,
362
+ ctx.clientCapabilities ?? {},
363
+ (method, req) => ctx.request(method, req)
364
+ );
365
+ let result;
366
+ const pendingNotifications = [];
367
+ const emit = (update) => {
368
+ const notifPromise = ctx.sendNotification({ sessionId, update });
369
+ pendingNotifications.push(notifPromise.catch(() => {
370
+ }));
371
+ };
372
+ try {
373
+ result = await ctx.runTurn(
374
+ { sessionId, prompt: p.prompt, signal: turnSignal.signal },
375
+ emit,
376
+ api
377
+ );
378
+ } catch (err) {
379
+ session.abort.signal.removeEventListener("abort", onCancel);
380
+ const { code, message, data } = errorToJsonRpc(err);
381
+ await ctx.sendError(id, code, message, data);
382
+ return false;
383
+ }
384
+ await Promise.all(pendingNotifications);
385
+ session.abort.signal.removeEventListener("abort", onCancel);
386
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
387
+ await ctx.persist(session);
388
+ await ctx.sendResult(id, { stopReason: result.stopReason });
389
+ return false;
390
+ }
391
+ async function handleSetModeOp(ctx, id, params) {
392
+ const p = params ?? {};
393
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
394
+ const modeId = typeof p.modeId === "string" ? p.modeId : null;
395
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
396
+ if (!session || !modeId || !ctx.modes.some((m) => m.id === modeId)) {
397
+ await ctx.sendError(id, -32602, "invalid sessionId or modeId");
398
+ return false;
399
+ }
400
+ session.modeId = modeId;
401
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
402
+ await ctx.sendNotification({
403
+ sessionId,
404
+ update: { sessionUpdate: "current_mode_update", modeId }
405
+ });
406
+ await ctx.sendResult(id, {});
407
+ return false;
408
+ }
409
+ async function handleSetConfigOptionOp(ctx, id, params) {
410
+ const p = params ?? {};
411
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
412
+ const optionId = typeof p.configId === "string" ? p.configId : null;
413
+ const value = typeof p.value === "string" ? p.value : null;
414
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
415
+ const option = optionId ? ctx.configOptions.find((o) => o.id === optionId) : void 0;
416
+ if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
417
+ await ctx.sendError(id, -32602, "invalid sessionId, configId, or value");
418
+ return false;
419
+ }
420
+ option.currentValue = value;
421
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
422
+ await ctx.sendNotification({
423
+ sessionId,
424
+ update: {
425
+ sessionUpdate: "config_option_update",
426
+ configOptions: [...ctx.configOptions]
427
+ }
428
+ });
429
+ await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
430
+ return false;
431
+ }
432
+
433
+ // src/agent/protocol-handler.ts
56
434
  var ACPProtocolHandler = class {
57
435
  transport;
58
436
  defaultCwd;
@@ -161,6 +539,27 @@ var ACPProtocolHandler = class {
161
539
  } catch {
162
540
  }
163
541
  }
542
+ sessionContext() {
543
+ return {
544
+ sessions: this.sessions,
545
+ maxSessions: this.maxSessions,
546
+ defaultCwd: this.defaultCwd,
547
+ modes: this.modes,
548
+ configOptions: this.configOptions,
549
+ store: this.store,
550
+ replayFor: this.replayFor,
551
+ seedFor: this.seedFor,
552
+ onSessionNew: this.onSessionNew,
553
+ allocId: () => this.allocId(),
554
+ persist: (state, history) => this.persist(state, history),
555
+ sendNotification: (params) => this.sendNotification(params),
556
+ sendError: (id, code, message, data) => this.sendError(id, code, message, data),
557
+ sendResult: (id, result) => this.sendResult(id, result),
558
+ request: (method, params, timeoutMs) => this.request(method, params, timeoutMs),
559
+ runTurn: this.runTurn,
560
+ clientCapabilities: this.clientCapabilities
561
+ };
562
+ }
164
563
  // ────────────────────────────────────────────────────────────────────
165
564
  // Requests
166
565
  // ────────────────────────────────────────────────────────────────────
@@ -178,9 +577,9 @@ var ACPProtocolHandler = class {
178
577
  case "logout":
179
578
  return await this.handleLogout(id, params);
180
579
  case "session/new":
181
- return await this.handleSessionNew(id, params);
580
+ return await handleSessionNewOp(this.sessionContext(), id, params);
182
581
  case "session/load":
183
- return await this.handleSessionLoad(id, params);
582
+ return await handleSessionLoadOp(this.sessionContext(), id, params);
184
583
  case "session/resume":
185
584
  return await this.handleSessionResume(id, params);
186
585
  case "session/close":
@@ -188,15 +587,15 @@ var ACPProtocolHandler = class {
188
587
  case "session/delete":
189
588
  return await this.handleSessionDelete(id, params);
190
589
  case "session/prompt":
191
- return await this.handleSessionPrompt(id, params);
590
+ return await handleSessionPromptOp(this.sessionContext(), id, params);
192
591
  case "session/set_mode":
193
- return await this.handleSetMode(id, params);
592
+ return await handleSetModeOp(this.sessionContext(), id, params);
194
593
  case "session/set_config_option":
195
- return await this.handleSetConfigOption(id, params);
594
+ return await handleSetConfigOptionOp(this.sessionContext(), id, params);
196
595
  case "session/list":
197
596
  return await this.handleSessionList(id);
198
597
  case "session/fork":
199
- return await this.handleSessionFork(id, params);
598
+ return await handleSessionForkOp(this.sessionContext(), id, params);
200
599
  case "providers/list":
201
600
  return await this.handleProvidersList(id, params);
202
601
  case "providers/set":
@@ -221,232 +620,32 @@ var ACPProtocolHandler = class {
221
620
  this.clientCapabilities = p.clientCapabilities;
222
621
  }
223
622
  this.initialized = true;
224
- await this.transport.send(
225
- toWire({
226
- jsonrpc: "2.0",
227
- id,
228
- result: {
229
- protocolVersion: ACP_PROTOCOL_VERSION,
230
- agentCapabilities: {
231
- loadSession: true,
232
- promptCapabilities: {
233
- // We route ACP image blocks into the core agent's multimodal
234
- // input (server-agent-turn.promptToAgentInput); whether the
235
- // model can see them is the configured provider's concern.
236
- image: true,
237
- audio: false,
238
- embeddedContext: true
239
- },
240
- mcpCapabilities: {
241
- http: false,
242
- sse: false
243
- },
244
- sessionCapabilities: {
245
- close: {},
246
- list: {},
247
- delete: {},
248
- resume: {},
249
- fork: {}
250
- },
251
- auth: {
252
- logout: {}
253
- }
254
- },
255
- agentInfo: {
256
- name: this.agentName,
257
- title: "WrongStack",
258
- version: WRONGSTACK_VERSION
259
- },
260
- authMethods: WRONGSTACK_AUTH_METHODS,
261
- modes: this.modes,
262
- configOptions: this.configOptions
263
- }
264
- })
623
+ await this.sendResult(
624
+ id,
625
+ buildInitializeResult(this.agentName, this.modes, this.configOptions)
265
626
  );
266
627
  return false;
267
628
  }
268
629
  async handleAuthenticate(id, _params) {
269
- await this.transport.send(
270
- toWire({
271
- jsonrpc: "2.0",
272
- id,
273
- result: { outcome: "unauthenticated" }
274
- })
275
- );
630
+ await this.sendResult(id, { outcome: "unauthenticated" });
276
631
  return false;
277
632
  }
278
633
  async handleLogout(id, _params) {
279
- await this.transport.send(
280
- toWire({
281
- jsonrpc: "2.0",
282
- id,
283
- result: {}
284
- })
285
- );
634
+ await this.sendResult(id, {});
286
635
  return false;
287
636
  }
288
- async handleSessionNew(id, params) {
289
- if (this.sessions.size >= this.maxSessions) {
290
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
291
- return false;
292
- }
293
- const p = params ?? {};
294
- let cwd = this.defaultCwd;
295
- if (typeof p.cwd === "string") {
296
- const resolved = await this.resolveSessionCwd(p.cwd);
297
- if (resolved === null) {
298
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
299
- return false;
300
- }
301
- cwd = resolved;
302
- }
303
- const sessionId = `sess_${this.allocId()}`;
304
- const now = (/* @__PURE__ */ new Date()).toISOString();
305
- const state = {
306
- id: sessionId,
307
- cwd,
308
- abort: new AbortController(),
309
- modeId: DEFAULT_MODE_ID,
310
- createdAt: now,
311
- updatedAt: now
312
- };
313
- this.sessions.set(sessionId, state);
314
- this.onSessionNew(state);
315
- await this.persist(state);
316
- await this.sendNotification({
317
- sessionId,
318
- update: {
319
- sessionUpdate: "current_mode_update",
320
- modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
321
- }
322
- });
323
- if (this.configOptions.length > 0) {
324
- await this.sendNotification({
325
- sessionId,
326
- update: {
327
- sessionUpdate: "config_option_update",
328
- configOptions: [...this.configOptions]
329
- }
330
- });
331
- }
332
- await this.transport.send(
333
- toWire({
334
- jsonrpc: "2.0",
335
- id,
336
- result: {
337
- sessionId,
338
- modes: this.modes,
339
- configOptions: this.configOptions
340
- }
341
- })
342
- );
343
- return false;
344
- }
345
- async handleSessionLoad(id, params) {
637
+ async handleSessionResume(id, params) {
346
638
  const p = params ?? {};
347
639
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
348
- const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
349
640
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
350
- if (!existing && sessionId && this.store) {
351
- const persisted = await this.store.load(sessionId);
352
- if (persisted) {
353
- if (this.sessions.size >= this.maxSessions) {
354
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
355
- return false;
356
- }
357
- if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
358
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
359
- return false;
360
- }
361
- const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
362
- const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
363
- const restored = {
364
- id: sessionId,
365
- cwd: restoredCwd,
366
- abort: new AbortController(),
367
- modeId: persisted.modeId ?? DEFAULT_MODE_ID,
368
- createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
369
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
370
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
371
- };
372
- this.sessions.set(sessionId, restored);
373
- this.seedFor?.(sessionId, persisted.history ?? []);
374
- for (const update of persisted.history ?? []) {
375
- await this.sendNotification({ sessionId, update });
376
- }
377
- await this.sendNotification({
378
- sessionId,
379
- update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
380
- });
381
- await this.transport.send(
382
- toWire({
383
- jsonrpc: "2.0",
384
- id,
385
- result: {
386
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
387
- }
388
- })
389
- );
390
- return false;
391
- }
392
- }
393
641
  if (existing) {
394
642
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
395
- const replay = this.replayFor?.(sessionId);
396
- if (replay) {
397
- for (const update of replay) {
398
- await this.sendNotification({ sessionId, update });
399
- }
400
- }
401
- await this.sendNotification({
402
- sessionId,
403
- update: {
404
- sessionUpdate: "session_info_update",
405
- updatedAt: existing.updatedAt
406
- }
407
- });
408
- await this.sendNotification({
409
- sessionId,
410
- update: {
411
- sessionUpdate: "current_mode_update",
412
- modeId: existing.modeId
643
+ await this.sendResult(id, {
644
+ initialMode: {
645
+ currentModeId: existing.modeId,
646
+ availableModes: this.modes
413
647
  }
414
648
  });
415
- await this.transport.send(
416
- toWire({
417
- jsonrpc: "2.0",
418
- id,
419
- result: {
420
- initialMode: {
421
- currentModeId: existing.modeId,
422
- availableModes: this.modes
423
- }
424
- }
425
- })
426
- );
427
- return false;
428
- }
429
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
430
- return false;
431
- }
432
- async handleSessionResume(id, params) {
433
- const p = params ?? {};
434
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
435
- const existing = sessionId ? this.sessions.get(sessionId) : void 0;
436
- if (existing) {
437
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
438
- await this.transport.send(
439
- toWire({
440
- jsonrpc: "2.0",
441
- id,
442
- result: {
443
- initialMode: {
444
- currentModeId: existing.modeId,
445
- availableModes: this.modes
446
- }
447
- }
448
- })
449
- );
450
649
  return false;
451
650
  }
452
651
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -463,13 +662,7 @@ var ACPProtocolHandler = class {
463
662
  session.abort.abort();
464
663
  this.sessions.delete(sessionId);
465
664
  this.disposeSession(sessionId);
466
- await this.transport.send(
467
- toWire({
468
- jsonrpc: "2.0",
469
- id,
470
- result: {}
471
- })
472
- );
665
+ await this.sendResult(id, {});
473
666
  return false;
474
667
  }
475
668
  async handleSessionDelete(id, params) {
@@ -480,92 +673,21 @@ var ACPProtocolHandler = class {
480
673
  return false;
481
674
  }
482
675
  if (!this.sessions.has(sessionId)) {
483
- await this.transport.send(
484
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
485
- );
676
+ await this.sendResult(id, { configOptions: [...this.configOptions] });
486
677
  return false;
487
678
  }
488
679
  const session = this.sessions.get(sessionId);
489
680
  session.abort.abort();
490
681
  this.sessions.delete(sessionId);
491
682
  this.disposeSession(sessionId);
492
- await this.transport.send(
493
- toWire({
494
- jsonrpc: "2.0",
495
- id,
496
- result: {}
497
- })
498
- );
499
- return false;
500
- }
501
- async handleSessionFork(id, params) {
502
- const p = params ?? {};
503
- const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
504
- const source = sourceId ? this.sessions.get(sourceId) : void 0;
505
- if (!sourceId || !source) {
506
- await this.sendError(id, -32e3, `session not found: ${sourceId}`);
507
- return false;
508
- }
509
- if (this.sessions.size >= this.maxSessions) {
510
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
511
- return false;
512
- }
513
- let forkCwd = source.cwd;
514
- if (typeof p.cwd === "string") {
515
- const resolved = await this.resolveSessionCwd(p.cwd);
516
- if (resolved === null) {
517
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
518
- return false;
519
- }
520
- forkCwd = resolved;
521
- }
522
- const now = (/* @__PURE__ */ new Date()).toISOString();
523
- const sessionId = `sess_${this.allocId()}`;
524
- const forked = {
525
- id: sessionId,
526
- cwd: forkCwd,
527
- abort: new AbortController(),
528
- modeId: source.modeId,
529
- createdAt: now,
530
- updatedAt: now,
531
- ...source.title !== void 0 ? { title: source.title } : {}
532
- };
533
- const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
534
- sessionUpdate: update.sessionUpdate,
535
- content: structuredClone(update.content)
536
- }));
537
- this.sessions.set(sessionId, forked);
538
- this.seedFor?.(sessionId, history);
539
- this.onSessionNew(forked);
540
- await this.persist(forked, history);
541
- await this.sendNotification({
542
- sessionId,
543
- update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
544
- });
545
- await this.transport.send(
546
- toWire({
547
- jsonrpc: "2.0",
548
- id,
549
- result: {
550
- sessionId,
551
- modes: this.modes,
552
- configOptions: this.configOptions
553
- }
554
- })
555
- );
683
+ await this.sendResult(id, {});
556
684
  return false;
557
685
  }
558
686
  async handleProvidersList(id, _params) {
559
- await this.transport.send(
560
- toWire({
561
- jsonrpc: "2.0",
562
- id,
563
- result: {
564
- providers: [],
565
- currentProviderId: null
566
- }
567
- })
568
- );
687
+ await this.sendResult(id, {
688
+ providers: [],
689
+ currentProviderId: null
690
+ });
569
691
  return false;
570
692
  }
571
693
  async handleProvidersSet(id, _params) {
@@ -577,157 +699,13 @@ var ACPProtocolHandler = class {
577
699
  return false;
578
700
  }
579
701
  async handleProvidersDisable(id, _params) {
580
- await this.transport.send(
581
- toWire({
582
- jsonrpc: "2.0",
583
- id,
584
- result: {}
585
- })
586
- );
702
+ await this.sendResult(id, {});
587
703
  return false;
588
704
  }
589
705
  async handleMcpMessage(id, _params) {
590
706
  await this.sendError(id, -32e3, "MCP message routing not available through ACP");
591
707
  return false;
592
708
  }
593
- async handleSessionPrompt(id, params) {
594
- const p = params ?? {};
595
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
596
- if (!sessionId || !this.sessions.has(sessionId)) {
597
- await this.sendError(id, -32e3, "unknown or missing sessionId");
598
- return false;
599
- }
600
- if (!Array.isArray(p.prompt)) {
601
- await this.sendError(id, -32602, "prompt must be an array of content blocks");
602
- return false;
603
- }
604
- const session = this.sessions.get(sessionId);
605
- if (session.abort.signal.aborted) {
606
- session.abort = new AbortController();
607
- }
608
- const turnSignal = new AbortController();
609
- const onCancel = () => turnSignal.abort();
610
- session.abort.signal.addEventListener("abort", onCancel, { once: true });
611
- const api = {
612
- clientCapabilities: this.clientCapabilities,
613
- requestPermission: async (req) => {
614
- const res = await this.request("session/request_permission", {
615
- sessionId,
616
- toolCall: req.toolCall,
617
- options: req.options
618
- });
619
- const outcome = res?.outcome;
620
- return outcome ?? { outcome: "cancelled" };
621
- },
622
- readTextFile: async (params2) => {
623
- const res = await this.request("fs/read_text_file", { sessionId, ...params2 });
624
- return String(res?.content ?? "");
625
- },
626
- writeTextFile: async (params2) => {
627
- await this.request("fs/write_text_file", { sessionId, ...params2 });
628
- },
629
- runTerminal: async ({ command, args, cwd }) => {
630
- const created = await this.request("terminal/create", {
631
- sessionId,
632
- command,
633
- ...args ? { args } : {},
634
- ...cwd ? { cwd } : {}
635
- });
636
- const terminalId = created?.terminalId;
637
- if (!terminalId) return { output: "", exitCode: null };
638
- try {
639
- const exit = await this.request("terminal/wait_for_exit", {
640
- sessionId,
641
- terminalId
642
- });
643
- const out = await this.request("terminal/output", { sessionId, terminalId });
644
- return {
645
- output: String(out?.output ?? ""),
646
- exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
647
- };
648
- } finally {
649
- try {
650
- await this.request("terminal/release", { sessionId, terminalId });
651
- } catch {
652
- }
653
- }
654
- }
655
- };
656
- let result;
657
- const pendingNotifications = [];
658
- const emit = (update) => {
659
- const p2 = this.sendNotification({ sessionId, update });
660
- pendingNotifications.push(p2.catch(() => {
661
- }));
662
- };
663
- try {
664
- result = await this.runTurn(
665
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
666
- emit,
667
- api
668
- );
669
- } catch (err) {
670
- session.abort.signal.removeEventListener("abort", onCancel);
671
- const { code, message, data } = errorToJsonRpc(err);
672
- await this.sendError(id, code, message, data);
673
- return false;
674
- }
675
- await Promise.all(pendingNotifications);
676
- session.abort.signal.removeEventListener("abort", onCancel);
677
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
678
- await this.persist(session);
679
- await this.transport.send(
680
- toWire({
681
- jsonrpc: "2.0",
682
- id,
683
- result: { stopReason: result.stopReason }
684
- })
685
- );
686
- return false;
687
- }
688
- async handleSetMode(id, params) {
689
- const p = params ?? {};
690
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
691
- const modeId = typeof p.modeId === "string" ? p.modeId : null;
692
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
693
- if (!session || !modeId || !this.modes.some((m) => m.id === modeId)) {
694
- await this.sendError(id, -32602, "invalid sessionId or modeId");
695
- return false;
696
- }
697
- session.modeId = modeId;
698
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
699
- await this.sendNotification({
700
- sessionId,
701
- update: { sessionUpdate: "current_mode_update", modeId }
702
- });
703
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: {} }));
704
- return false;
705
- }
706
- async handleSetConfigOption(id, params) {
707
- const p = params ?? {};
708
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
709
- const optionId = typeof p.configId === "string" ? p.configId : null;
710
- const value = typeof p.value === "string" ? p.value : null;
711
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
712
- const option = optionId ? this.configOptions.find((o) => o.id === optionId) : void 0;
713
- if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
714
- await this.sendError(id, -32602, "invalid sessionId, configId, or value");
715
- return false;
716
- }
717
- option.currentValue = value;
718
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
719
- await this.sendNotification({
720
- sessionId,
721
- update: {
722
- sessionUpdate: "config_option_update",
723
- configOptions: [...this.configOptions]
724
- }
725
- });
726
- await this.transport.send(
727
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
728
- );
729
- return false;
730
- }
731
709
  async handleSessionList(id) {
732
710
  const sessions = Array.from(this.sessions.values()).map((s) => {
733
711
  const out = {
@@ -738,13 +716,7 @@ var ACPProtocolHandler = class {
738
716
  if (s.title !== void 0) out.title = s.title;
739
717
  return out;
740
718
  });
741
- await this.transport.send(
742
- toWire({
743
- jsonrpc: "2.0",
744
- id,
745
- result: { sessions }
746
- })
747
- );
719
+ await this.sendResult(id, { sessions });
748
720
  return false;
749
721
  }
750
722
  // ────────────────────────────────────────────────────────────────────
@@ -777,7 +749,9 @@ var ACPProtocolHandler = class {
777
749
  async sendNotification(params) {
778
750
  await this.transport.send(toWire({ jsonrpc: "2.0", method: "session/update", params }));
779
751
  }
780
- /** Best-effort durable persistence of a session + its recorded history. */
752
+ async sendResult(id, result) {
753
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result }));
754
+ }
781
755
  async persist(state, history = void 0) {
782
756
  if (!this.store) return;
783
757
  try {
@@ -790,71 +764,10 @@ var ACPProtocolHandler = class {
790
764
  if (data !== void 0) error.data = data;
791
765
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
792
766
  }
793
- /**
794
- * Allocate a session id (WS-015).
795
- *
796
- * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
797
- * handler has no per-connection ownership: any caller that names a session
798
- * id can `session/load`, `session/prompt`, `session/cancel` or
799
- * `session/delete` it. Over stdio that is academic (one client per process),
800
- * but the agent also serves over HTTP, where a guessable id is the whole
801
- * authorization story for any local process or page that reaches the port.
802
- *
803
- * Random ids do not create ownership — they remove the trivial enumeration
804
- * that made its absence exploitable. Real per-connection ownership is the
805
- * larger fix and is noted in the WS-015 test file.
806
- *
807
- * The counter is retained: it keeps ids ordered for debugging and guarantees
808
- * uniqueness within a process even in the (impossible) event of a UUID
809
- * collision. The random half is what makes the id unguessable.
810
- */
811
- /**
812
- * Resolve a client-supplied `cwd` for a session, or `null` when it is not
813
- * usable (WS-015).
814
- *
815
- * `session/new`, `session/load` and `session/fork` all took `params.cwd`
816
- * with a single `typeof === 'string'` check and nothing else. That value is
817
- * the working directory the agent then reads, writes and executes in.
818
- *
819
- * SCOPE, deliberately stated: this does NOT confine the session to a root.
820
- * In ACP the client IS the editor and legitimately names its own workspace —
821
- * Zed and JetBrains pass the project root — so a fixed boundary here would
822
- * break the integration this package exists for. What it enforces is that
823
- * the directory is absolute and actually exists as a directory: a relative
824
- * or missing `cwd` is a bug or an attack under either reading, and silently
825
- * running the agent somewhere other than where the client asked is worse
826
- * than refusing. Confinement, if wanted, belongs in an operator-set option
827
- * on top of this, not in place of it.
828
- */
829
- async resolveSessionCwd(requested) {
830
- if (!path.isAbsolute(requested)) return null;
831
- const resolved = path.resolve(requested);
832
- try {
833
- const stat2 = await fsp.stat(resolved);
834
- return stat2.isDirectory() ? resolved : null;
835
- } catch {
836
- return null;
837
- }
838
- }
839
767
  allocId() {
840
768
  return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
841
769
  }
842
770
  };
843
- function errorToJsonRpc(err) {
844
- if (err && typeof err === "object") {
845
- const e = err;
846
- if (typeof e.code === "number" && typeof e.message === "string") {
847
- const result = {
848
- code: e.code,
849
- message: e.message
850
- };
851
- if (e.data !== void 0) result.data = e.data;
852
- return result;
853
- }
854
- }
855
- const message = err instanceof Error ? err.message : String(err);
856
- return { code: -32603, message };
857
- }
858
771
 
859
772
  // src/agent/stdio-transport.ts
860
773
  import { expectDefined, writeErr } from "@wrongstack/core/utils";