@moxt-ai/mobius 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/acp/activity.d.ts +5 -2
  2. package/dist/acp/activity.d.ts.map +1 -1
  3. package/dist/acp/activity.js +89 -5
  4. package/dist/acp/activity.js.map +1 -1
  5. package/dist/acp/create-client.d.ts +8 -2
  6. package/dist/acp/create-client.d.ts.map +1 -1
  7. package/dist/acp/create-client.js +10 -2
  8. package/dist/acp/create-client.js.map +1 -1
  9. package/dist/acp/run-prompt.d.ts.map +1 -1
  10. package/dist/acp/run-prompt.js +4 -0
  11. package/dist/acp/run-prompt.js.map +1 -1
  12. package/dist/acp/session-manager.d.ts +100 -4
  13. package/dist/acp/session-manager.d.ts.map +1 -1
  14. package/dist/acp/session-manager.js +1239 -138
  15. package/dist/acp/session-manager.js.map +1 -1
  16. package/dist/acp/session-store.d.ts +37 -0
  17. package/dist/acp/session-store.d.ts.map +1 -0
  18. package/dist/acp/session-store.js +158 -0
  19. package/dist/acp/session-store.js.map +1 -0
  20. package/dist/authorization/workspace-directory.d.ts +17 -0
  21. package/dist/authorization/workspace-directory.d.ts.map +1 -0
  22. package/dist/authorization/workspace-directory.js +164 -0
  23. package/dist/authorization/workspace-directory.js.map +1 -0
  24. package/dist/cli.js +22429 -697
  25. package/dist/cli.js.map +4 -4
  26. package/dist/index.js +22435 -677
  27. package/dist/index.js.map +4 -4
  28. package/dist/relay-connection/liveness.d.ts +14 -0
  29. package/dist/relay-connection/liveness.d.ts.map +1 -0
  30. package/dist/relay-connection/liveness.js +102 -0
  31. package/dist/relay-connection/liveness.js.map +1 -0
  32. package/dist/relay-connection/timer.d.ts +8 -0
  33. package/dist/relay-connection/timer.d.ts.map +1 -0
  34. package/dist/relay-connection/timer.js +8 -0
  35. package/dist/relay-connection/timer.js.map +1 -0
  36. package/dist/run-daemon.d.ts +2 -1
  37. package/dist/run-daemon.d.ts.map +1 -1
  38. package/dist/run-daemon.js +352 -20
  39. package/dist/run-daemon.js.map +1 -1
  40. package/package.json +1 -1
@@ -1,13 +1,44 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { once } from "node:events";
3
3
  import { methods, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
4
- import { AcpActivityTracker, isProgressMessage, } from "./activity.js";
4
+ import { AGENT_CONTENT_KIND, MOBIUS_PROTOCOL_VERSION, SESSION_COMMANDS_UPDATED_KIND, SESSION_CONFIG_UPDATED_KIND, SESSION_ELICITATION_REQUESTED_KIND, SESSION_ELICITATION_RESOLVED_KIND, SESSION_INFO_UPDATED_KIND, SESSION_PERMISSION_REQUESTED_KIND, SESSION_PERMISSION_RESOLVED_KIND, SESSION_USAGE_UPDATED_KIND, } from "@moxt-ai/mobius-protocol";
5
+ import { AcpActivityTracker, isProgressMessage, normalizeContentBlock, } from "./activity.js";
5
6
  import { createAcpClient } from "./create-client.js";
7
+ import { AcpStoredSession, FoundAcpStoredSession, MemoryAcpSessionStore, } from "./session-store.js";
6
8
  const PROCESS_STOP_TIMEOUT_MILLISECONDS = 1_000;
9
+ const SESSION_CONTROL_COMMAND_ID = "session-control";
10
+ export class NullAcpSessionObserver {
11
+ onCommands = () => Promise.resolve();
12
+ onConfig = () => Promise.resolve();
13
+ onContent = () => Promise.resolve();
14
+ onElicitation = () => Promise.resolve();
15
+ onElicitationResolved = () => Promise.resolve();
16
+ onInfo = () => Promise.resolve();
17
+ onPermission = () => Promise.resolve();
18
+ onPermissionResolved = () => Promise.resolve();
19
+ onUsage = () => Promise.resolve();
20
+ }
21
+ export class AcpPreparedSession {
22
+ agentName;
23
+ agentVersion;
24
+ capabilities;
25
+ commands;
26
+ configOptions;
27
+ restored;
28
+ constructor(agentName, agentVersion, capabilities, commands, configOptions, restored) {
29
+ this.agentName = agentName;
30
+ this.agentVersion = agentVersion;
31
+ this.capabilities = capabilities;
32
+ this.commands = commands;
33
+ this.configOptions = configOptions;
34
+ this.restored = restored;
35
+ }
36
+ }
7
37
  export class AcpExecutionError extends Error {
8
- code = "acp_execution_failed";
9
- constructor(message, options = {}) {
38
+ code;
39
+ constructor(message, options = {}, code = "acp_execution_failed") {
10
40
  super(message, options);
41
+ this.code = code;
11
42
  this.name = "AcpExecutionError";
12
43
  }
13
44
  }
@@ -46,15 +77,40 @@ function readableWebStream(input) {
46
77
  },
47
78
  });
48
79
  }
49
- function writableWebStream(output) {
80
+ export function createWritableAcpStream(output) {
81
+ let outputFailure;
82
+ const pendingWriteFailures = new Set();
83
+ const fail = (error) => {
84
+ outputFailure = error;
85
+ for (const reject of pendingWriteFailures) {
86
+ reject(error);
87
+ }
88
+ pendingWriteFailures.clear();
89
+ };
90
+ output.on("error", fail);
91
+ output.once("close", () => {
92
+ if (outputFailure === undefined) {
93
+ fail(new Error("ACP process input closed"));
94
+ }
95
+ });
50
96
  return new WritableStream({
51
97
  write(chunk) {
98
+ if (outputFailure !== undefined) {
99
+ return Promise.reject(outputFailure);
100
+ }
52
101
  return new Promise((resolve, reject) => {
102
+ pendingWriteFailures.add(reject);
53
103
  output.write(chunk, (error) => {
104
+ pendingWriteFailures.delete(reject);
54
105
  if (error) {
106
+ outputFailure = error;
55
107
  reject(error);
56
108
  return;
57
109
  }
110
+ if (outputFailure !== undefined) {
111
+ reject(outputFailure);
112
+ return;
113
+ }
58
114
  resolve();
59
115
  });
60
116
  });
@@ -75,176 +131,1199 @@ async function stopAgentProcess(child) {
75
131
  catch {
76
132
  if (child.exitCode === null) {
77
133
  child.kill("SIGKILL");
78
- await once(child, "exit");
134
+ try {
135
+ await once(child, "exit", {
136
+ signal: AbortSignal.timeout(PROCESS_STOP_TIMEOUT_MILLISECONDS),
137
+ });
138
+ }
139
+ catch {
140
+ // Teardown must not wait forever for a process the OS did not reap.
141
+ }
79
142
  }
80
143
  }
81
144
  }
82
- function rejectWhenAborted(signal) {
83
- return new Promise((_resolve, reject) => {
84
- const rejectAbort = () => {
85
- reject(signal.reason ?? new Error("ACP request was cancelled"));
86
- };
87
- signal.addEventListener("abort", rejectAbort, { once: true });
88
- if (signal.aborted) {
89
- rejectAbort();
145
+ function truncate(value, maximumLength) {
146
+ return value.length <= maximumLength
147
+ ? value
148
+ : `${value.slice(0, Math.max(0, maximumLength - 1))}…`;
149
+ }
150
+ function isRecord(value) {
151
+ return typeof value === "object" && value !== null && !Array.isArray(value);
152
+ }
153
+ function readString(value, fallback = "") {
154
+ return typeof value === "string" ? value : fallback;
155
+ }
156
+ function readBoolean(value, fallback = false) {
157
+ return typeof value === "boolean" ? value : fallback;
158
+ }
159
+ function arraysEqual(left, right) {
160
+ return (left.length === right.length &&
161
+ left.every((value, index) => value === right[index]));
162
+ }
163
+ function normalizeConfigValue(option, groupId = "", groupName = "") {
164
+ return {
165
+ description: truncate(readString(option["description"]), 500),
166
+ groupId: truncate(groupId, 128),
167
+ groupName: truncate(groupName, 255),
168
+ name: truncate(readString(option["name"], readString(option["value"])), 255),
169
+ value: truncate(readString(option["value"]), 500),
170
+ };
171
+ }
172
+ function normalizeConfigValues(value) {
173
+ if (!Array.isArray(value)) {
174
+ return [];
175
+ }
176
+ const normalized = [];
177
+ for (const entry of value) {
178
+ if (!isRecord(entry)) {
179
+ continue;
90
180
  }
91
- });
181
+ const groupedOptions = entry["options"];
182
+ if (Array.isArray(groupedOptions)) {
183
+ const groupId = readString(entry["group"]);
184
+ const groupName = readString(entry["name"]);
185
+ for (const groupedOption of groupedOptions) {
186
+ if (isRecord(groupedOption)) {
187
+ normalized.push(normalizeConfigValue(groupedOption, groupId, groupName));
188
+ }
189
+ }
190
+ continue;
191
+ }
192
+ normalized.push(normalizeConfigValue(entry));
193
+ }
194
+ return normalized.filter((entry) => entry.value.length > 0).slice(0, 500);
195
+ }
196
+ function normalizeConfigOptions(value, modes) {
197
+ const normalized = [];
198
+ if (Array.isArray(value)) {
199
+ for (const option of value) {
200
+ if (!isRecord(option)) {
201
+ continue;
202
+ }
203
+ const id = truncate(readString(option["id"]), 128);
204
+ const name = truncate(readString(option["name"], id), 255);
205
+ if (id.length === 0 || name.length === 0) {
206
+ continue;
207
+ }
208
+ if (option["type"] === "boolean") {
209
+ normalized.push({
210
+ booleanValue: readBoolean(option["currentValue"]),
211
+ category: truncate(readString(option["category"]), 128),
212
+ currentValue: "",
213
+ description: truncate(readString(option["description"]), 500),
214
+ id,
215
+ name,
216
+ type: "boolean",
217
+ values: [],
218
+ });
219
+ continue;
220
+ }
221
+ if (option["type"] === "select") {
222
+ const values = normalizeConfigValues(option["options"]);
223
+ const currentValue = truncate(readString(option["currentValue"]), 500);
224
+ if (currentValue.length > 0 &&
225
+ values.some((entry) => entry.value === currentValue)) {
226
+ normalized.push({
227
+ booleanValue: false,
228
+ category: truncate(readString(option["category"]), 128),
229
+ currentValue,
230
+ description: truncate(readString(option["description"]), 500),
231
+ id,
232
+ name,
233
+ type: "select",
234
+ values,
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+ if (isRecord(modes)) {
241
+ const values = [];
242
+ const availableModes = modes["availableModes"];
243
+ if (Array.isArray(availableModes)) {
244
+ for (const mode of availableModes) {
245
+ if (!isRecord(mode)) {
246
+ continue;
247
+ }
248
+ const id = truncate(readString(mode["id"]), 500);
249
+ if (id.length === 0) {
250
+ continue;
251
+ }
252
+ values.push({
253
+ description: truncate(readString(mode["description"]), 500),
254
+ groupId: "",
255
+ groupName: "",
256
+ name: truncate(readString(mode["name"], id), 255),
257
+ value: id,
258
+ });
259
+ }
260
+ }
261
+ const currentValue = truncate(readString(modes["currentModeId"]), 500);
262
+ if (currentValue.length > 0 &&
263
+ values.some((entry) => entry.value === currentValue) &&
264
+ !normalized.some((entry) => entry.category === "mode")) {
265
+ normalized.unshift({
266
+ booleanValue: false,
267
+ category: "mode",
268
+ currentValue,
269
+ description: "Agent session mode",
270
+ id: "mode",
271
+ name: "Mode",
272
+ type: "select",
273
+ values,
274
+ });
275
+ }
276
+ }
277
+ return normalized.slice(0, 100);
278
+ }
279
+ function replaceConfigOptionsKeepingMode(current, value) {
280
+ const next = normalizeConfigOptions(value, undefined);
281
+ if (next.some((option) => option.category === "mode")) {
282
+ return next;
283
+ }
284
+ return [
285
+ ...current.filter((option) => option.category === "mode"),
286
+ ...next,
287
+ ].slice(0, 100);
288
+ }
289
+ function normalizeCapabilities(capabilities) {
290
+ const session = capabilities.sessionCapabilities;
291
+ const prompt = capabilities.promptCapabilities;
292
+ return {
293
+ additionalDirectories: session?.additionalDirectories != null,
294
+ audioPrompt: prompt?.audio === true,
295
+ closeSession: session?.close != null,
296
+ deleteSession: session?.delete != null,
297
+ embeddedContextPrompt: prompt?.embeddedContext === true,
298
+ imagePrompt: prompt?.image === true,
299
+ listSessions: session?.list != null,
300
+ loadSession: capabilities.loadSession === true,
301
+ resumeSession: session?.resume != null,
302
+ };
303
+ }
304
+ function elicitationFieldType(value) {
305
+ if (value === "boolean") {
306
+ return "boolean";
307
+ }
308
+ if (value === "integer") {
309
+ return "integer";
310
+ }
311
+ if (value === "number") {
312
+ return "number";
313
+ }
314
+ if (value === "array") {
315
+ return "multiselect";
316
+ }
317
+ return "string";
318
+ }
319
+ function normalizeElicitationOptions(schema) {
320
+ const normalized = [];
321
+ const oneOf = schema["oneOf"];
322
+ if (Array.isArray(oneOf)) {
323
+ for (const option of oneOf) {
324
+ if (isRecord(option) && typeof option["const"] === "string") {
325
+ normalized.push({
326
+ description: truncate(readString(option["description"]), 500),
327
+ label: truncate(readString(option["title"], option["const"]), 255),
328
+ value: truncate(option["const"], 500),
329
+ });
330
+ }
331
+ }
332
+ return normalized.slice(0, 200);
333
+ }
334
+ const enumValues = schema["enum"];
335
+ if (Array.isArray(enumValues)) {
336
+ for (const entry of enumValues) {
337
+ if (typeof entry === "string") {
338
+ normalized.push({ description: "", label: entry, value: entry });
339
+ }
340
+ }
341
+ return normalized.slice(0, 200);
342
+ }
343
+ const items = schema["items"];
344
+ if (!isRecord(items)) {
345
+ return normalized;
346
+ }
347
+ const anyOf = items["anyOf"];
348
+ if (Array.isArray(anyOf)) {
349
+ return normalizeElicitationOptions({ oneOf: anyOf });
350
+ }
351
+ return normalizeElicitationOptions({ enum: items["enum"] });
352
+ }
353
+ function numericString(value) {
354
+ return typeof value === "number" && Number.isFinite(value)
355
+ ? value.toString()
356
+ : "";
357
+ }
358
+ function normalizeElicitationFields(requestedSchema) {
359
+ if (!isRecord(requestedSchema) || !isRecord(requestedSchema["properties"])) {
360
+ return [];
361
+ }
362
+ const properties = requestedSchema["properties"];
363
+ const requiredValues = requestedSchema["required"];
364
+ const required = new Set(Array.isArray(requiredValues)
365
+ ? requiredValues.filter((entry) => typeof entry === "string")
366
+ : []);
367
+ const normalized = [];
368
+ for (const [name, property] of Object.entries(properties)) {
369
+ if (!isRecord(property)) {
370
+ continue;
371
+ }
372
+ const type = elicitationFieldType(property["type"]);
373
+ const options = normalizeElicitationOptions(property);
374
+ const normalizedType = type === "string" && options.length > 0 ? "select" : type;
375
+ const defaultValue = property["default"];
376
+ normalized.push({
377
+ defaultBooleanValue: typeof defaultValue === "boolean" ? defaultValue : false,
378
+ defaultNumberValue: typeof defaultValue === "number" && Number.isFinite(defaultValue)
379
+ ? defaultValue
380
+ : 0,
381
+ defaultStringValue: typeof defaultValue === "string"
382
+ ? truncate(defaultValue, 10_000)
383
+ : Array.isArray(defaultValue)
384
+ ? truncate(JSON.stringify(defaultValue), 10_000)
385
+ : "",
386
+ description: truncate(readString(property["description"]), 500),
387
+ format: truncate(readString(property["format"]), 64),
388
+ hasDefaultValue: defaultValue !== undefined && defaultValue !== null,
389
+ maximum: numericString(property["maximum"]),
390
+ maximumLength: numericString(property["maxLength"] ?? property["maxItems"]),
391
+ minimum: numericString(property["minimum"]),
392
+ minimumLength: numericString(property["minLength"] ?? property["minItems"]),
393
+ name: truncate(name, 128),
394
+ options,
395
+ pattern: truncate(readString(property["pattern"]), 500),
396
+ required: required.has(name),
397
+ title: truncate(readString(property["title"], name), 255),
398
+ type: normalizedType,
399
+ });
400
+ }
401
+ return normalized.slice(0, 100);
402
+ }
403
+ function validateStringValue(name, value, schema) {
404
+ const minimum = schema["minLength"];
405
+ const maximum = schema["maxLength"];
406
+ if (typeof minimum === "number" && value.length < minimum) {
407
+ throw new AcpExecutionError(`Elicitation field ${name} is shorter than allowed`, {}, "invalid_elicitation_response");
408
+ }
409
+ if (typeof maximum === "number" && value.length > maximum) {
410
+ throw new AcpExecutionError(`Elicitation field ${name} is longer than allowed`, {}, "invalid_elicitation_response");
411
+ }
412
+ const pattern = schema["pattern"];
413
+ if (typeof pattern === "string" && !new RegExp(pattern, "u").test(value)) {
414
+ throw new AcpExecutionError(`Elicitation field ${name} does not match the required format`, {}, "invalid_elicitation_response");
415
+ }
416
+ const allowed = normalizeElicitationOptions(schema);
417
+ if (allowed.length > 0 && !allowed.some((entry) => entry.value === value)) {
418
+ throw new AcpExecutionError(`Elicitation field ${name} is not an allowed value`, {}, "invalid_elicitation_response");
419
+ }
420
+ }
421
+ function validateNumericValue(name, value, schema, integer) {
422
+ if (!Number.isFinite(value) || (integer && !Number.isInteger(value))) {
423
+ throw new AcpExecutionError(`Elicitation field ${name} is not a valid number`, {}, "invalid_elicitation_response");
424
+ }
425
+ const minimum = schema["minimum"];
426
+ const maximum = schema["maximum"];
427
+ if ((typeof minimum === "number" && value < minimum) ||
428
+ (typeof maximum === "number" && value > maximum)) {
429
+ throw new AcpExecutionError(`Elicitation field ${name} is outside the allowed range`, {}, "invalid_elicitation_response");
430
+ }
431
+ }
432
+ function validateElicitationContent(encoded, requestedSchema) {
433
+ let decoded;
434
+ try {
435
+ decoded = JSON.parse(encoded);
436
+ }
437
+ catch (error) {
438
+ throw new AcpExecutionError("Elicitation response is not valid JSON", { cause: error }, "invalid_elicitation_response");
439
+ }
440
+ if (!isRecord(decoded) ||
441
+ !isRecord(requestedSchema) ||
442
+ !isRecord(requestedSchema["properties"])) {
443
+ throw new AcpExecutionError("Elicitation response does not match the requested form", {}, "invalid_elicitation_response");
444
+ }
445
+ const properties = requestedSchema["properties"];
446
+ const requiredValue = requestedSchema["required"];
447
+ const required = new Set(Array.isArray(requiredValue)
448
+ ? requiredValue.filter((entry) => typeof entry === "string")
449
+ : []);
450
+ for (const name of required) {
451
+ if (!(name in decoded)) {
452
+ throw new AcpExecutionError(`Elicitation field ${name} is required`, {}, "invalid_elicitation_response");
453
+ }
454
+ }
455
+ const validated = {};
456
+ for (const [name, value] of Object.entries(decoded)) {
457
+ const property = properties[name];
458
+ if (!isRecord(property)) {
459
+ throw new AcpExecutionError(`Elicitation field ${name} was not requested`, {}, "invalid_elicitation_response");
460
+ }
461
+ if (property["type"] === "string" && typeof value === "string") {
462
+ validateStringValue(name, value, property);
463
+ validated[name] = value;
464
+ continue;
465
+ }
466
+ if (property["type"] === "number" && typeof value === "number") {
467
+ validateNumericValue(name, value, property, false);
468
+ validated[name] = value;
469
+ continue;
470
+ }
471
+ if (property["type"] === "integer" && typeof value === "number") {
472
+ validateNumericValue(name, value, property, true);
473
+ validated[name] = value;
474
+ continue;
475
+ }
476
+ if (property["type"] === "boolean" && typeof value === "boolean") {
477
+ validated[name] = value;
478
+ continue;
479
+ }
480
+ if (property["type"] === "array" &&
481
+ Array.isArray(value) &&
482
+ value.every((entry) => typeof entry === "string")) {
483
+ const minimum = property["minItems"];
484
+ const maximum = property["maxItems"];
485
+ if ((typeof minimum === "number" && value.length < minimum) ||
486
+ (typeof maximum === "number" && value.length > maximum)) {
487
+ throw new AcpExecutionError(`Elicitation field ${name} has an invalid selection count`, {}, "invalid_elicitation_response");
488
+ }
489
+ const allowed = normalizeElicitationOptions(property);
490
+ if (allowed.length > 0 &&
491
+ value.some((entry) => !allowed.some((option) => option.value === entry))) {
492
+ throw new AcpExecutionError(`Elicitation field ${name} contains an invalid selection`, {}, "invalid_elicitation_response");
493
+ }
494
+ validated[name] = value;
495
+ continue;
496
+ }
497
+ throw new AcpExecutionError(`Elicitation field ${name} has the wrong type`, {}, "invalid_elicitation_response");
498
+ }
499
+ return validated;
500
+ }
501
+ class AcpTurnState {
502
+ }
503
+ class IdleAcpTurn extends AcpTurnState {
504
+ commandId = SESSION_CONTROL_COMMAND_ID;
505
+ running = false;
506
+ activities = new AcpActivityTracker(() => Promise.resolve());
507
+ onText = () => Promise.resolve();
508
+ }
509
+ class RunningAcpTurn extends AcpTurnState {
510
+ commandId;
511
+ running = true;
512
+ activities;
513
+ onText;
514
+ constructor(commandId, onActivity, onText) {
515
+ super();
516
+ this.commandId = commandId;
517
+ this.activities = new AcpActivityTracker(onActivity);
518
+ this.onText = onText;
519
+ }
520
+ }
521
+ class PendingPermission {
522
+ completion = Promise.withResolvers();
523
+ interactionId;
524
+ optionIds;
525
+ #settled = false;
526
+ constructor(interactionId, optionIds) {
527
+ this.interactionId = interactionId;
528
+ this.optionIds = optionIds;
529
+ }
530
+ cancel = () => {
531
+ if (this.#settled) {
532
+ return false;
533
+ }
534
+ this.#settled = true;
535
+ this.completion.resolve({ outcome: { outcome: "cancelled" } });
536
+ return true;
537
+ };
538
+ select = (optionId) => {
539
+ if (this.#settled) {
540
+ return false;
541
+ }
542
+ this.#settled = true;
543
+ this.completion.resolve({
544
+ outcome: { optionId, outcome: "selected" },
545
+ });
546
+ return true;
547
+ };
548
+ }
549
+ class PendingElicitation {
550
+ completion = Promise.withResolvers();
551
+ elicitationId;
552
+ interactionId;
553
+ requestedSchema;
554
+ #settled = false;
555
+ constructor(interactionId, elicitationId, requestedSchema) {
556
+ this.elicitationId = elicitationId;
557
+ this.interactionId = interactionId;
558
+ this.requestedSchema = requestedSchema;
559
+ }
560
+ accept = (content) => {
561
+ if (this.#settled) {
562
+ return false;
563
+ }
564
+ this.#settled = true;
565
+ this.completion.resolve({ action: "accept", content });
566
+ return true;
567
+ };
568
+ cancel = () => {
569
+ if (this.#settled) {
570
+ return false;
571
+ }
572
+ this.#settled = true;
573
+ this.completion.resolve({ action: "cancel" });
574
+ return true;
575
+ };
576
+ decline = () => {
577
+ if (this.#settled) {
578
+ return false;
579
+ }
580
+ this.#settled = true;
581
+ this.completion.resolve({ action: "decline" });
582
+ return true;
583
+ };
92
584
  }
93
585
  class AcpProcessSession {
586
+ #additionalDirectories;
587
+ #acceptedUrlElicitations = new Map();
94
588
  #agentId;
95
589
  #child;
96
590
  #connection;
591
+ #cwd;
592
+ #getObserver;
593
+ #mobiusSessionId;
594
+ #pendingElicitations = new Map();
595
+ #pendingPermissions = new Map();
97
596
  #processExit;
98
- #session;
99
- #turnRunning = false;
100
- constructor(agentId, child, connection, session, processExit) {
101
- this.#agentId = agentId;
102
- this.#child = child;
103
- this.#connection = connection;
104
- this.#session = session;
105
- this.#processExit = processExit;
106
- }
107
- static async start(agent, cwd, signal) {
108
- signal.throwIfAborted();
109
- const child = spawn(agent.command, [...agent.arguments], {
597
+ #store;
598
+ #agentName = "";
599
+ #agentVersion = "";
600
+ #capabilities = {
601
+ additionalDirectories: false,
602
+ audioPrompt: false,
603
+ closeSession: false,
604
+ deleteSession: false,
605
+ embeddedContextPrompt: false,
606
+ imagePrompt: false,
607
+ listSessions: false,
608
+ loadSession: false,
609
+ resumeSession: false,
610
+ };
611
+ #commands = [];
612
+ #configOptions = [];
613
+ #nativeSessionId = "";
614
+ #prompted = false;
615
+ #replaying = false;
616
+ #restored = false;
617
+ #sessionTitle = "";
618
+ #sessionUpdatedAt = "";
619
+ #turn = new IdleAcpTurn();
620
+ #updateTail = Promise.resolve();
621
+ constructor(agent, cwd, additionalDirectories, mobiusSessionId, getObserver, store) {
622
+ this.#additionalDirectories = [...additionalDirectories];
623
+ this.#agentId = agent.id;
624
+ this.#cwd = cwd;
625
+ this.#getObserver = getObserver;
626
+ this.#mobiusSessionId = mobiusSessionId;
627
+ this.#store = store;
628
+ this.#child = spawn(agent.command, [...agent.arguments], {
110
629
  cwd,
111
630
  stdio: ["pipe", "pipe", "pipe"],
112
631
  });
113
- child.stderr.setEncoding("utf8");
114
- child.stderr.on("data", (chunk) => {
632
+ const stdin = this.#child.stdin;
633
+ const stdout = this.#child.stdout;
634
+ const stderr = this.#child.stderr;
635
+ if (stdin === null || stdout === null || stderr === null) {
636
+ this.#child.kill("SIGKILL");
637
+ throw new AcpExecutionError("ACP process streams are unavailable");
638
+ }
639
+ stderr.setEncoding("utf8");
640
+ stderr.on("data", (chunk) => {
115
641
  process.stderr.write(`[${agent.id}-acp] ${chunk}`);
116
642
  });
117
- const processExit = new Promise((resolve) => {
118
- child.once("error", resolve);
119
- child.once("exit", resolve);
643
+ this.#processExit = new Promise((resolve) => {
644
+ this.#child.once("error", resolve);
645
+ this.#child.once("exit", resolve);
120
646
  });
121
- const stream = ndJsonStream(writableWebStream(child.stdin), readableWebStream(child.stdout));
122
- const connection = createAcpClient().connect(stream);
647
+ const stream = ndJsonStream(createWritableAcpStream(stdin), readableWebStream(stdout));
648
+ this.#connection = createAcpClient(this).connect(stream);
649
+ }
650
+ static start = async (options, getObserver, store) => {
651
+ options.signal.throwIfAborted();
652
+ const runtime = new AcpProcessSession(options.agent, options.cwd, options.additionalDirectories, options.sessionId, getObserver, store);
123
653
  try {
124
- await Promise.race([
125
- connection.agent.request(methods.agent.initialize, {
126
- clientCapabilities: {},
127
- protocolVersion: PROTOCOL_VERSION,
128
- }, { cancellationSignal: signal }),
129
- processExit.then(() => {
130
- throw new AcpExecutionError("ACP agent exited during initialization");
131
- }),
132
- rejectWhenAborted(signal),
133
- ]);
134
- const session = await Promise.race([
135
- connection.agent
136
- .buildSession(cwd)
137
- .start({ cancellationSignal: signal }),
138
- processExit.then(() => {
139
- throw new AcpExecutionError("ACP agent exited while starting a session");
140
- }),
141
- rejectWhenAborted(signal),
142
- ]);
143
- return new AcpProcessSession(agent.id, child, connection, session, processExit);
654
+ await runtime.initialize(options.signal);
655
+ return runtime;
144
656
  }
145
657
  catch (error) {
146
- connection.close(error);
147
- await stopAgentProcess(child);
658
+ await runtime.closeProcess();
659
+ if (error instanceof AcpExecutionError) {
660
+ throw error;
661
+ }
148
662
  throw new AcpExecutionError("ACP agent initialization failed", {
149
663
  cause: error,
150
664
  });
151
665
  }
152
- }
153
- belongsTo = (agentId) => this.#agentId === agentId;
666
+ };
667
+ belongsTo = (agentId, cwd, additionalDirectories) => this.#agentId === agentId &&
668
+ this.#cwd === cwd &&
669
+ arraysEqual(this.#additionalDirectories, additionalDirectories);
670
+ canRebind = () => !this.#prompted && !this.#turn.running;
671
+ prepared = () => new AcpPreparedSession(this.#agentName, this.#agentVersion, this.#capabilities, this.#commands, this.#configOptions, this.#restored);
672
+ initialize = async (signal) => {
673
+ const initialized = await this.race(this.#connection.agent.request(methods.agent.initialize, {
674
+ clientCapabilities: {
675
+ elicitation: { form: {}, url: {} },
676
+ plan: {},
677
+ session: { configOptions: { boolean: {} } },
678
+ },
679
+ clientInfo: { name: "mobius", version: "0.0.2" },
680
+ protocolVersion: PROTOCOL_VERSION,
681
+ }, { cancellationSignal: signal }), "ACP agent exited during initialization", signal);
682
+ if (initialized.protocolVersion !== PROTOCOL_VERSION) {
683
+ throw new AcpExecutionError("ACP agent selected an unsupported protocol version", {}, "unsupported_acp_protocol");
684
+ }
685
+ const capabilities = initialized.agentCapabilities ?? {};
686
+ this.#capabilities = normalizeCapabilities(capabilities);
687
+ this.#agentName = truncate(initialized.agentInfo?.title ??
688
+ initialized.agentInfo?.name ??
689
+ this.#agentId, 255);
690
+ this.#agentVersion = truncate(initialized.agentInfo?.version ?? "", 128);
691
+ const stored = this.#store.read(this.#mobiusSessionId);
692
+ let lifecycleResponse;
693
+ if (stored instanceof FoundAcpStoredSession &&
694
+ stored.session.agentId === this.#agentId &&
695
+ stored.session.cwd === this.#cwd &&
696
+ arraysEqual(stored.session.additionalDirectories, this.#additionalDirectories)) {
697
+ this.#nativeSessionId = stored.session.nativeSessionId;
698
+ this.#prompted = stored.session.prompted;
699
+ this.#restored = true;
700
+ if (this.#capabilities.resumeSession) {
701
+ lifecycleResponse = await this.race(this.#connection.agent.request(methods.agent.session.resume, {
702
+ additionalDirectories: [...this.#additionalDirectories],
703
+ cwd: this.#cwd,
704
+ mcpServers: [],
705
+ sessionId: this.#nativeSessionId,
706
+ }, { cancellationSignal: signal }), "ACP agent exited while resuming a session", signal);
707
+ }
708
+ else if (this.#capabilities.loadSession) {
709
+ this.#replaying = true;
710
+ try {
711
+ lifecycleResponse = await this.race(this.#connection.agent.request(methods.agent.session.load, {
712
+ additionalDirectories: [...this.#additionalDirectories],
713
+ cwd: this.#cwd,
714
+ mcpServers: [],
715
+ sessionId: this.#nativeSessionId,
716
+ }, { cancellationSignal: signal }), "ACP agent exited while loading a session", signal);
717
+ }
718
+ finally {
719
+ this.#replaying = false;
720
+ }
721
+ }
722
+ else {
723
+ throw new AcpExecutionError("The ACP agent cannot restore its persisted session", {}, "session_restore_unsupported");
724
+ }
725
+ }
726
+ else {
727
+ if (stored instanceof FoundAcpStoredSession) {
728
+ if (stored.session.prompted) {
729
+ throw new AcpExecutionError("The agent or workspace cannot change after a conversation has started", {}, "session_identity_mismatch");
730
+ }
731
+ this.#store.remove(this.#mobiusSessionId);
732
+ }
733
+ const created = await this.race(this.#connection.agent.request(methods.agent.session.new, {
734
+ additionalDirectories: [...this.#additionalDirectories],
735
+ cwd: this.#cwd,
736
+ mcpServers: [],
737
+ }, { cancellationSignal: signal }), "ACP agent exited while starting a session", signal);
738
+ this.#nativeSessionId = created.sessionId;
739
+ lifecycleResponse = created;
740
+ this.#store.write(new AcpStoredSession(this.#mobiusSessionId, this.#agentId, this.#nativeSessionId, this.#cwd, this.#additionalDirectories, false));
741
+ }
742
+ const lifecycleRecord = isRecord(lifecycleResponse)
743
+ ? lifecycleResponse
744
+ : {};
745
+ this.#configOptions = normalizeConfigOptions(lifecycleRecord["configOptions"], lifecycleRecord["modes"]);
746
+ await this.#updateTail;
747
+ signal.throwIfAborted();
748
+ };
749
+ configure = async (options) => {
750
+ const existing = this.#configOptions.find((option) => option.id === options.configId);
751
+ if (existing === undefined || existing.type !== options.valueType) {
752
+ throw new AcpExecutionError("The requested ACP configuration option is unavailable", {}, "invalid_session_configuration");
753
+ }
754
+ if (existing.type === "select") {
755
+ if (!existing.values.some((entry) => entry.value === options.value)) {
756
+ throw new AcpExecutionError("The requested ACP configuration value is unavailable", {}, "invalid_session_configuration");
757
+ }
758
+ if (existing.category === "mode") {
759
+ await this.race(this.#connection.agent.request(methods.agent.session.setMode, { modeId: options.value, sessionId: this.#nativeSessionId }, { cancellationSignal: options.signal }), "ACP agent exited while setting the session mode", options.signal);
760
+ this.#configOptions = this.#configOptions.map((option) => option.id === existing.id
761
+ ? { ...option, currentValue: options.value }
762
+ : option);
763
+ }
764
+ else {
765
+ const response = await this.race(this.#connection.agent.request(methods.agent.session.setConfigOption, {
766
+ configId: existing.id,
767
+ sessionId: this.#nativeSessionId,
768
+ value: options.value,
769
+ }, { cancellationSignal: options.signal }), "ACP agent exited while changing session configuration", options.signal);
770
+ this.#configOptions = replaceConfigOptionsKeepingMode(this.#configOptions, response.configOptions);
771
+ }
772
+ }
773
+ else {
774
+ const response = await this.race(this.#connection.agent.request(methods.agent.session.setConfigOption, {
775
+ configId: existing.id,
776
+ sessionId: this.#nativeSessionId,
777
+ type: "boolean",
778
+ value: options.booleanValue,
779
+ }, { cancellationSignal: options.signal }), "ACP agent exited while changing session configuration", options.signal);
780
+ this.#configOptions = replaceConfigOptionsKeepingMode(this.#configOptions, response.configOptions);
781
+ }
782
+ return this.#configOptions;
783
+ };
784
+ prompt = async (options) => {
785
+ if (this.#turn.running) {
786
+ throw new AcpExecutionError("Another prompt is already running in this conversation", {}, "session_busy");
787
+ }
788
+ if (options.images.length > 0 && !this.#capabilities.imagePrompt) {
789
+ throw new AcpExecutionError("The selected agent does not accept image prompts", {}, "unsupported_prompt_content");
790
+ }
791
+ if (options.resources.some((resource) => resource.type === "embedded") &&
792
+ !this.#capabilities.embeddedContextPrompt) {
793
+ throw new AcpExecutionError("The selected agent does not accept embedded context", {}, "unsupported_prompt_content");
794
+ }
795
+ const prompt = [];
796
+ if (options.prompt.length > 0) {
797
+ prompt.push({ text: options.prompt, type: "text" });
798
+ }
799
+ for (const image of options.images) {
800
+ prompt.push({
801
+ _meta: { mobius: { name: image.name } },
802
+ data: image.data,
803
+ mimeType: image.mimeType,
804
+ type: "image",
805
+ });
806
+ }
807
+ for (const resource of options.resources) {
808
+ if (resource.type === "embedded") {
809
+ prompt.push({
810
+ resource: {
811
+ mimeType: resource.mimeType,
812
+ text: resource.text,
813
+ uri: resource.uri,
814
+ },
815
+ type: "resource",
816
+ });
817
+ }
818
+ else {
819
+ prompt.push({
820
+ mimeType: resource.mimeType,
821
+ name: resource.name,
822
+ type: "resource_link",
823
+ uri: resource.uri,
824
+ });
825
+ }
826
+ }
827
+ if (prompt.length === 0) {
828
+ throw new AcpExecutionError("A prompt must contain text or an attachment", {}, "empty_prompt");
829
+ }
830
+ this.#store.write(new AcpStoredSession(this.#mobiusSessionId, this.#agentId, this.#nativeSessionId, this.#cwd, this.#additionalDirectories, true));
831
+ this.#prompted = true;
832
+ const running = new RunningAcpTurn(options.commandId, options.onActivity, options.onText);
833
+ this.#turn = running;
834
+ try {
835
+ const response = await this.race(this.#connection.agent.request(methods.agent.session.prompt, { prompt, sessionId: this.#nativeSessionId }, { cancellationSignal: options.signal }), "ACP agent exited during a prompt", options.signal);
836
+ await this.#updateTail;
837
+ await running.activities.finish();
838
+ return { stopReason: response.stopReason };
839
+ }
840
+ catch (error) {
841
+ await running.activities.finish();
842
+ if (error instanceof AcpExecutionError) {
843
+ throw error;
844
+ }
845
+ throw new AcpExecutionError("ACP agent execution failed", {
846
+ cause: error,
847
+ });
848
+ }
849
+ finally {
850
+ this.#turn = new IdleAcpTurn();
851
+ }
852
+ };
154
853
  cancel = async () => {
155
- if (!this.#turnRunning) {
854
+ await this.cancelPendingInteractions();
855
+ if (!this.#turn.running) {
156
856
  return;
157
857
  }
158
858
  await this.#connection.agent.notify(methods.agent.session.cancel, {
159
- sessionId: this.#session.sessionId,
859
+ sessionId: this.#nativeSessionId,
160
860
  });
161
861
  };
162
- prompt = async (prompt, onActivity, onText, signal) => {
163
- if (this.#turnRunning) {
164
- throw new AcpExecutionError("Another prompt is already running in this conversation");
862
+ respondPermission = async (options) => {
863
+ const pending = this.#pendingPermissions.get(options.interactionId);
864
+ if (pending === undefined) {
865
+ throw new AcpExecutionError("The permission request is no longer active", {}, "permission_request_not_found");
165
866
  }
166
- this.#turnRunning = true;
167
- const activities = new AcpActivityTracker(onActivity);
168
- const runTurn = async () => {
169
- const completion = this.#session.prompt(prompt, {
170
- cancellationSignal: signal,
171
- });
172
- for (;;) {
173
- const message = await this.#session.nextUpdate();
174
- if (message.kind === "stop") {
175
- await completion;
176
- await activities.finish();
177
- return { stopReason: message.stopReason };
178
- }
179
- const update = message.update;
180
- if (update.sessionUpdate === "agent_message_chunk") {
181
- if (update.content.type === "text") {
182
- if (isProgressMessage(update)) {
183
- await activities.recordNarrative("progress", update.messageId ?? "current", update.content.text);
184
- }
185
- else {
186
- await activities.finishNarrative();
187
- await onText(update.content.text);
188
- }
189
- }
190
- continue;
191
- }
192
- if (update.sessionUpdate === "agent_thought_chunk") {
193
- if (update.content.type === "text") {
194
- await activities.recordNarrative("reasoning", update.messageId ?? "current", update.content.text);
195
- }
196
- continue;
197
- }
198
- if (update.sessionUpdate === "tool_call") {
199
- await activities.recordToolCall(update);
200
- continue;
201
- }
202
- if (update.sessionUpdate === "tool_call_update") {
203
- await activities.recordToolCallUpdate(update);
204
- continue;
205
- }
206
- if (update.sessionUpdate === "plan") {
207
- await activities.recordPlan(update);
208
- continue;
209
- }
210
- if (update.sessionUpdate === "plan_update") {
211
- await activities.recordPlanUpdate(update);
212
- continue;
213
- }
214
- if (update.sessionUpdate === "plan_removed") {
215
- await activities.removePlan(update);
867
+ if (options.outcome === "selected" &&
868
+ !pending.optionIds.has(options.optionId)) {
869
+ throw new AcpExecutionError("The selected permission option is unavailable", {}, "invalid_permission_response");
870
+ }
871
+ const resolved = options.outcome === "selected"
872
+ ? pending.select(options.optionId)
873
+ : pending.cancel();
874
+ if (!resolved) {
875
+ throw new AcpExecutionError("The permission request was already resolved", {}, "permission_request_not_found");
876
+ }
877
+ this.#pendingPermissions.delete(options.interactionId);
878
+ await this.#getObserver().onPermissionResolved({
879
+ interactionId: options.interactionId,
880
+ kind: SESSION_PERMISSION_RESOLVED_KIND,
881
+ optionId: options.outcome === "selected" ? options.optionId : "",
882
+ outcome: options.outcome,
883
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
884
+ sessionId: this.#mobiusSessionId,
885
+ });
886
+ };
887
+ respondElicitation = async (options) => {
888
+ const pending = this.#pendingElicitations.get(options.interactionId);
889
+ if (pending === undefined) {
890
+ throw new AcpExecutionError("The elicitation request is no longer active", {}, "elicitation_request_not_found");
891
+ }
892
+ let resolved = false;
893
+ if (options.action === "accept") {
894
+ if (pending.elicitationId.length > 0) {
895
+ resolved = pending.accept({});
896
+ if (resolved) {
897
+ this.#acceptedUrlElicitations.set(pending.elicitationId, pending.interactionId);
216
898
  }
217
899
  }
900
+ else {
901
+ resolved = pending.accept(validateElicitationContent(options.content, pending.requestedSchema));
902
+ }
903
+ }
904
+ else if (options.action === "decline") {
905
+ resolved = pending.decline();
906
+ }
907
+ else {
908
+ resolved = pending.cancel();
909
+ }
910
+ if (!resolved) {
911
+ throw new AcpExecutionError("The elicitation request was already resolved", {}, "elicitation_request_not_found");
912
+ }
913
+ this.#pendingElicitations.delete(options.interactionId);
914
+ await this.#getObserver().onElicitationResolved({
915
+ action: options.action,
916
+ interactionId: options.interactionId,
917
+ kind: SESSION_ELICITATION_RESOLVED_KIND,
918
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
919
+ sessionId: this.#mobiusSessionId,
920
+ });
921
+ };
922
+ onPermission = async (request, signal) => {
923
+ this.assertNativeSession(request.sessionId);
924
+ const interactionId = `permission-${crypto.randomUUID()}`;
925
+ const options = request.options
926
+ .slice(0, 20)
927
+ .map((option) => ({
928
+ kind: option.kind,
929
+ name: truncate(option.name, 255),
930
+ optionId: truncate(option.optionId, 255),
931
+ }));
932
+ if (options.length === 0) {
933
+ throw new AcpExecutionError("ACP permission request did not provide any choices", {}, "invalid_agent_permission_request");
934
+ }
935
+ const pending = new PendingPermission(interactionId, new Set(options.map((option) => option.optionId)));
936
+ this.#pendingPermissions.set(interactionId, pending);
937
+ const abort = () => {
938
+ void this.cancelPermission(interactionId);
218
939
  };
940
+ signal.addEventListener("abort", abort, { once: true });
219
941
  try {
220
- return await Promise.race([
221
- runTurn(),
222
- this.#processExit.then(() => {
223
- throw new AcpExecutionError("ACP agent exited during a prompt");
224
- }),
225
- rejectWhenAborted(signal),
226
- ]);
942
+ await this.#getObserver().onPermission({
943
+ commandId: this.#turn.commandId,
944
+ interactionId,
945
+ kind: SESSION_PERMISSION_REQUESTED_KIND,
946
+ options,
947
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
948
+ sessionId: this.#mobiusSessionId,
949
+ title: truncate(request.toolCall.title ?? "Permission required", 500),
950
+ toolCallId: truncate(request.toolCall.toolCallId, 255),
951
+ toolKind: truncate(request.toolCall.kind ?? "other", 64),
952
+ });
953
+ return await pending.completion.promise;
227
954
  }
228
- catch (error) {
229
- await activities.finish();
230
- throw new AcpExecutionError("ACP agent execution failed", {
231
- cause: error,
955
+ finally {
956
+ signal.removeEventListener("abort", abort);
957
+ this.#pendingPermissions.delete(interactionId);
958
+ }
959
+ };
960
+ onElicitation = async (request, signal) => {
961
+ const sessionId = Reflect.get(request, "sessionId");
962
+ if (typeof sessionId !== "string") {
963
+ throw new AcpExecutionError("Mobius only accepts session-scoped ACP elicitations", {}, "unsupported_elicitation_scope");
964
+ }
965
+ this.assertNativeSession(sessionId);
966
+ if (request.mode !== "form" && request.mode !== "url") {
967
+ throw new AcpExecutionError("The ACP agent requested an unsupported elicitation mode", {}, "unsupported_elicitation_mode");
968
+ }
969
+ const interactionId = `elicitation-${crypto.randomUUID()}`;
970
+ const schemaValue = Reflect.get(request, "requestedSchema");
971
+ const requestedSchema = request.mode === "form" ? schemaValue : {};
972
+ const elicitationIdValue = Reflect.get(request, "elicitationId");
973
+ const elicitationId = request.mode === "url" && typeof elicitationIdValue === "string"
974
+ ? truncate(elicitationIdValue, 255)
975
+ : "";
976
+ const pending = new PendingElicitation(interactionId, elicitationId, requestedSchema);
977
+ this.#pendingElicitations.set(interactionId, pending);
978
+ const abort = () => {
979
+ void this.cancelElicitation(interactionId);
980
+ };
981
+ signal.addEventListener("abort", abort, { once: true });
982
+ try {
983
+ await this.#getObserver().onElicitation({
984
+ accepted: false,
985
+ commandId: this.#turn.commandId,
986
+ elicitationId,
987
+ fields: normalizeElicitationFields(requestedSchema),
988
+ interactionId,
989
+ kind: SESSION_ELICITATION_REQUESTED_KIND,
990
+ message: truncate(request.message, 2_000),
991
+ mode: request.mode,
992
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
993
+ sessionId: this.#mobiusSessionId,
994
+ url: request.mode === "url" &&
995
+ typeof Reflect.get(request, "url") === "string"
996
+ ? truncate(readString(Reflect.get(request, "url")), 4_096)
997
+ : "",
232
998
  });
999
+ return await pending.completion.promise;
233
1000
  }
234
1001
  finally {
235
- this.#turnRunning = false;
1002
+ signal.removeEventListener("abort", abort);
1003
+ this.#pendingElicitations.delete(interactionId);
1004
+ }
1005
+ };
1006
+ onElicitationComplete = async (notification) => {
1007
+ const interactionId = this.#acceptedUrlElicitations.get(notification.elicitationId);
1008
+ if (interactionId === undefined) {
1009
+ return;
1010
+ }
1011
+ this.#acceptedUrlElicitations.delete(notification.elicitationId);
1012
+ await this.#getObserver().onElicitationResolved({
1013
+ action: "complete",
1014
+ interactionId,
1015
+ kind: SESSION_ELICITATION_RESOLVED_KIND,
1016
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1017
+ sessionId: this.#mobiusSessionId,
1018
+ });
1019
+ };
1020
+ onUpdate = (notification) => {
1021
+ this.#updateTail = this.#updateTail.then(async () => await this.processUpdate(notification));
1022
+ return this.#updateTail;
1023
+ };
1024
+ processUpdate = async (notification) => {
1025
+ this.assertNativeSession(notification.sessionId);
1026
+ const update = notification.update;
1027
+ if (this.#replaying &&
1028
+ (update.sessionUpdate === "user_message_chunk" ||
1029
+ update.sessionUpdate === "agent_message_chunk" ||
1030
+ update.sessionUpdate === "agent_thought_chunk" ||
1031
+ update.sessionUpdate === "tool_call" ||
1032
+ update.sessionUpdate === "tool_call_update" ||
1033
+ update.sessionUpdate === "plan" ||
1034
+ update.sessionUpdate === "plan_update" ||
1035
+ update.sessionUpdate === "plan_removed")) {
1036
+ return;
1037
+ }
1038
+ if (update.sessionUpdate === "agent_message_chunk") {
1039
+ if (update.content.type === "text") {
1040
+ if (isProgressMessage(update)) {
1041
+ await this.#turn.activities.recordNarrative("progress", update.messageId ?? "current", update.content.text);
1042
+ }
1043
+ else {
1044
+ await this.#turn.activities.finishNarrative();
1045
+ await this.#turn.onText(update.content.text);
1046
+ }
1047
+ }
1048
+ else {
1049
+ await this.#turn.activities.finishNarrative();
1050
+ await this.#getObserver().onContent({
1051
+ commandId: this.#turn.commandId,
1052
+ content: normalizeContentBlock(update.content),
1053
+ contentId: `content-${crypto.randomUUID()}`,
1054
+ kind: AGENT_CONTENT_KIND,
1055
+ messageId: truncate(update.messageId ?? "", 255),
1056
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1057
+ sessionId: this.#mobiusSessionId,
1058
+ });
1059
+ }
1060
+ return;
1061
+ }
1062
+ if (update.sessionUpdate === "agent_thought_chunk") {
1063
+ if (update.content.type === "text") {
1064
+ await this.#turn.activities.recordNarrative("reasoning", update.messageId ?? "current", update.content.text);
1065
+ }
1066
+ return;
1067
+ }
1068
+ if (update.sessionUpdate === "tool_call") {
1069
+ await this.#turn.activities.recordToolCall(update);
1070
+ return;
1071
+ }
1072
+ if (update.sessionUpdate === "tool_call_update") {
1073
+ await this.#turn.activities.recordToolCallUpdate(update);
1074
+ return;
1075
+ }
1076
+ if (update.sessionUpdate === "plan") {
1077
+ await this.#turn.activities.recordPlan(update);
1078
+ return;
1079
+ }
1080
+ if (update.sessionUpdate === "plan_update") {
1081
+ await this.#turn.activities.recordPlanUpdate(update);
1082
+ return;
1083
+ }
1084
+ if (update.sessionUpdate === "plan_removed") {
1085
+ await this.#turn.activities.removePlan(update);
1086
+ return;
1087
+ }
1088
+ if (update.sessionUpdate === "available_commands_update") {
1089
+ const commands = update.availableCommands
1090
+ .slice(0, 500)
1091
+ .map((command) => ({
1092
+ description: truncate(command.description, 500),
1093
+ inputHint: truncate(command.input?.hint ?? "", 255),
1094
+ name: truncate(command.name, 128),
1095
+ }))
1096
+ .filter((command) => command.name.length > 0);
1097
+ this.#commands = commands;
1098
+ await this.#getObserver().onCommands({
1099
+ commands,
1100
+ kind: SESSION_COMMANDS_UPDATED_KIND,
1101
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1102
+ sessionId: this.#mobiusSessionId,
1103
+ });
1104
+ return;
1105
+ }
1106
+ if (update.sessionUpdate === "config_option_update") {
1107
+ this.#configOptions = replaceConfigOptionsKeepingMode(this.#configOptions, update.configOptions);
1108
+ await this.emitConfigUpdate();
1109
+ return;
1110
+ }
1111
+ if (update.sessionUpdate === "current_mode_update") {
1112
+ this.#configOptions = this.#configOptions.map((option) => option.category === "mode"
1113
+ ? { ...option, currentValue: update.currentModeId }
1114
+ : option);
1115
+ await this.emitConfigUpdate();
1116
+ return;
1117
+ }
1118
+ if (update.sessionUpdate === "session_info_update") {
1119
+ if (Object.hasOwn(update, "title")) {
1120
+ this.#sessionTitle = truncate(update.title ?? "", 500);
1121
+ }
1122
+ if (Object.hasOwn(update, "updatedAt")) {
1123
+ this.#sessionUpdatedAt = truncate(update.updatedAt ?? "", 128);
1124
+ }
1125
+ await this.#getObserver().onInfo({
1126
+ kind: SESSION_INFO_UPDATED_KIND,
1127
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1128
+ sessionId: this.#mobiusSessionId,
1129
+ title: this.#sessionTitle,
1130
+ updatedAt: this.#sessionUpdatedAt,
1131
+ });
1132
+ return;
1133
+ }
1134
+ if (update.sessionUpdate === "usage_update") {
1135
+ await this.#getObserver().onUsage({
1136
+ costAmount: update.cost?.amount ?? 0,
1137
+ costCurrency: truncate(update.cost?.currency ?? "", 16),
1138
+ hasCost: update.cost !== undefined && update.cost !== null,
1139
+ kind: SESSION_USAGE_UPDATED_KIND,
1140
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1141
+ sessionId: this.#mobiusSessionId,
1142
+ size: Math.max(0, Math.trunc(update.size)),
1143
+ used: Math.max(0, Math.trunc(update.used)),
1144
+ });
236
1145
  }
237
1146
  };
238
1147
  close = async () => {
239
- this.#session.dispose();
1148
+ await this.cancelPendingInteractions();
1149
+ if (this.#capabilities.closeSession && this.#nativeSessionId.length > 0) {
1150
+ try {
1151
+ await this.#connection.agent.request(methods.agent.session.close, { sessionId: this.#nativeSessionId }, {
1152
+ cancellationSignal: AbortSignal.timeout(PROCESS_STOP_TIMEOUT_MILLISECONDS),
1153
+ });
1154
+ }
1155
+ catch {
1156
+ // Process teardown below remains authoritative.
1157
+ }
1158
+ }
1159
+ await this.closeProcess();
1160
+ };
1161
+ closeProcess = async () => {
240
1162
  this.#connection.close();
241
1163
  await stopAgentProcess(this.#child);
242
1164
  };
1165
+ race = async (operation, exitMessage, signal) => {
1166
+ signal.throwIfAborted();
1167
+ let removeAbortListener = () => { };
1168
+ const aborted = new Promise((_resolve, reject) => {
1169
+ const rejectAbort = () => {
1170
+ reject(signal.reason ?? new Error("ACP request was cancelled"));
1171
+ };
1172
+ signal.addEventListener("abort", rejectAbort, { once: true });
1173
+ removeAbortListener = () => {
1174
+ signal.removeEventListener("abort", rejectAbort);
1175
+ };
1176
+ });
1177
+ try {
1178
+ return await Promise.race([
1179
+ operation,
1180
+ this.#processExit.then(() => {
1181
+ throw new AcpExecutionError(exitMessage);
1182
+ }),
1183
+ aborted,
1184
+ ]);
1185
+ }
1186
+ finally {
1187
+ removeAbortListener();
1188
+ }
1189
+ };
1190
+ assertNativeSession = (sessionId) => {
1191
+ if (sessionId !== this.#nativeSessionId) {
1192
+ throw new AcpExecutionError("ACP agent referenced an unknown session", {}, "invalid_agent_session");
1193
+ }
1194
+ };
1195
+ emitConfigUpdate = async () => {
1196
+ await this.#getObserver().onConfig({
1197
+ configOptions: this.#configOptions,
1198
+ kind: SESSION_CONFIG_UPDATED_KIND,
1199
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1200
+ requestId: `agent-update-${crypto.randomUUID()}`,
1201
+ sessionId: this.#mobiusSessionId,
1202
+ });
1203
+ };
1204
+ cancelPermission = async (interactionId) => {
1205
+ const pending = this.#pendingPermissions.get(interactionId);
1206
+ if (pending === undefined || !pending.cancel()) {
1207
+ return;
1208
+ }
1209
+ this.#pendingPermissions.delete(interactionId);
1210
+ await this.#getObserver().onPermissionResolved({
1211
+ interactionId,
1212
+ kind: SESSION_PERMISSION_RESOLVED_KIND,
1213
+ optionId: "",
1214
+ outcome: "cancelled",
1215
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1216
+ sessionId: this.#mobiusSessionId,
1217
+ });
1218
+ };
1219
+ cancelElicitation = async (interactionId) => {
1220
+ const pending = this.#pendingElicitations.get(interactionId);
1221
+ if (pending === undefined || !pending.cancel()) {
1222
+ return;
1223
+ }
1224
+ this.#pendingElicitations.delete(interactionId);
1225
+ await this.#getObserver().onElicitationResolved({
1226
+ action: "cancel",
1227
+ interactionId,
1228
+ kind: SESSION_ELICITATION_RESOLVED_KIND,
1229
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1230
+ sessionId: this.#mobiusSessionId,
1231
+ });
1232
+ };
1233
+ cancelPendingInteractions = async () => {
1234
+ const permissionIds = [...this.#pendingPermissions.keys()];
1235
+ const elicitationIds = [...this.#pendingElicitations.keys()];
1236
+ const acceptedElicitationIds = [...this.#acceptedUrlElicitations.values()];
1237
+ this.#acceptedUrlElicitations.clear();
1238
+ for (const interactionId of permissionIds) {
1239
+ await this.cancelPermission(interactionId);
1240
+ }
1241
+ for (const interactionId of elicitationIds) {
1242
+ await this.cancelElicitation(interactionId);
1243
+ }
1244
+ for (const interactionId of acceptedElicitationIds) {
1245
+ await this.#getObserver().onElicitationResolved({
1246
+ action: "cancel",
1247
+ interactionId,
1248
+ kind: SESSION_ELICITATION_RESOLVED_KIND,
1249
+ protocolVersion: MOBIUS_PROTOCOL_VERSION,
1250
+ sessionId: this.#mobiusSessionId,
1251
+ });
1252
+ }
1253
+ };
243
1254
  }
244
1255
  export class AcpSessionManager {
245
1256
  #activeSessionIds = new Set();
246
1257
  #cancellationRequests = new Set();
1258
+ #preparing = new Map();
247
1259
  #sessions = new Map();
1260
+ #store;
1261
+ #observer;
1262
+ constructor(observer = new NullAcpSessionObserver(), store = new MemoryAcpSessionStore()) {
1263
+ this.#observer = observer;
1264
+ this.#store = store;
1265
+ }
1266
+ setObserver = (observer) => {
1267
+ this.#observer = observer;
1268
+ };
1269
+ async #startPreparation(options) {
1270
+ const operation = (async () => {
1271
+ try {
1272
+ const runtime = await AcpProcessSession.start(options, () => this.#observer, this.#store);
1273
+ this.#sessions.set(options.sessionId, runtime);
1274
+ return runtime;
1275
+ }
1276
+ finally {
1277
+ this.#preparing.delete(options.sessionId);
1278
+ }
1279
+ })();
1280
+ this.#preparing.set(options.sessionId, operation);
1281
+ return await operation;
1282
+ }
1283
+ async #replacePreparation(existing, options) {
1284
+ const operation = (async () => {
1285
+ try {
1286
+ this.#sessions.delete(options.sessionId);
1287
+ await existing.close();
1288
+ this.#store.remove(options.sessionId);
1289
+ const runtime = await AcpProcessSession.start(options, () => this.#observer, this.#store);
1290
+ this.#sessions.set(options.sessionId, runtime);
1291
+ return runtime;
1292
+ }
1293
+ finally {
1294
+ this.#preparing.delete(options.sessionId);
1295
+ }
1296
+ })();
1297
+ this.#preparing.set(options.sessionId, operation);
1298
+ return await operation;
1299
+ }
1300
+ prepareSession = async (options) => {
1301
+ const pending = this.#preparing.get(options.sessionId);
1302
+ if (pending !== undefined) {
1303
+ const runtime = await pending;
1304
+ if (runtime.belongsTo(options.agent.id, options.cwd, options.additionalDirectories)) {
1305
+ return runtime.prepared();
1306
+ }
1307
+ return await this.prepareSession(options);
1308
+ }
1309
+ const existing = this.#sessions.get(options.sessionId);
1310
+ if (existing !== undefined) {
1311
+ if (existing.belongsTo(options.agent.id, options.cwd, options.additionalDirectories)) {
1312
+ return existing.prepared();
1313
+ }
1314
+ if (!existing.canRebind()) {
1315
+ throw new AcpExecutionError("The agent or workspace cannot change after a conversation has started", {}, "session_identity_mismatch");
1316
+ }
1317
+ const runtime = await this.#replacePreparation(existing, options);
1318
+ return runtime.prepared();
1319
+ }
1320
+ const runtime = await this.#startPreparation(options);
1321
+ return runtime.prepared();
1322
+ };
1323
+ configureSession = async (options) => {
1324
+ const runtime = await this.ensureSession(options);
1325
+ return await runtime.configure(options);
1326
+ };
248
1327
  cancelPrompt = async (sessionId) => {
249
1328
  this.#cancellationRequests.add(sessionId);
250
1329
  const runtime = this.#sessions.get(sessionId);
@@ -254,31 +1333,24 @@ export class AcpSessionManager {
254
1333
  };
255
1334
  runPrompt = async (options) => {
256
1335
  if (this.#activeSessionIds.has(options.sessionId)) {
257
- throw new AcpExecutionError("Another prompt is already running in this conversation");
1336
+ throw new AcpExecutionError("Another prompt is already running in this conversation", {}, "session_busy");
258
1337
  }
259
1338
  this.#activeSessionIds.add(options.sessionId);
260
- let runtime = this.#sessions.get(options.sessionId);
261
- if (runtime !== undefined && !runtime.belongsTo(options.agent.id)) {
262
- this.#activeSessionIds.delete(options.sessionId);
263
- throw new AcpExecutionError("The agent cannot change after a conversation has started");
264
- }
265
1339
  try {
266
1340
  if (this.#cancellationRequests.has(options.sessionId)) {
267
1341
  return { stopReason: "cancelled" };
268
1342
  }
269
- if (runtime === undefined) {
270
- runtime = await AcpProcessSession.start(options.agent, options.cwd, options.signal);
271
- this.#sessions.set(options.sessionId, runtime);
272
- }
1343
+ const runtime = await this.ensureSession(options);
273
1344
  if (this.#cancellationRequests.has(options.sessionId)) {
274
1345
  return { stopReason: "cancelled" };
275
1346
  }
276
- return await runtime.prompt(options.prompt, options.onActivity, options.onText, options.signal);
1347
+ return await runtime.prompt(options);
277
1348
  }
278
1349
  catch (error) {
279
- if (runtime !== undefined) {
1350
+ const failedRuntime = this.#sessions.get(options.sessionId);
1351
+ if (failedRuntime !== undefined) {
280
1352
  this.#sessions.delete(options.sessionId);
281
- await runtime.close();
1353
+ await failedRuntime.close();
282
1354
  }
283
1355
  if (error instanceof AcpExecutionError) {
284
1356
  throw error;
@@ -292,10 +1364,39 @@ export class AcpSessionManager {
292
1364
  this.#cancellationRequests.delete(options.sessionId);
293
1365
  }
294
1366
  };
1367
+ respondPermission = async (options) => {
1368
+ const runtime = this.#sessions.get(options.sessionId);
1369
+ if (runtime === undefined) {
1370
+ throw new AcpExecutionError("The permission request session is unavailable", {}, "session_not_found");
1371
+ }
1372
+ await runtime.respondPermission(options);
1373
+ };
1374
+ respondElicitation = async (options) => {
1375
+ const runtime = this.#sessions.get(options.sessionId);
1376
+ if (runtime === undefined) {
1377
+ throw new AcpExecutionError("The elicitation request session is unavailable", {}, "session_not_found");
1378
+ }
1379
+ await runtime.respondElicitation(options);
1380
+ };
295
1381
  close = async () => {
296
1382
  const sessions = [...this.#sessions.values()];
297
1383
  this.#sessions.clear();
298
1384
  await Promise.all(sessions.map(async (session) => await session.close()));
299
1385
  };
1386
+ ensureSession = async (options) => {
1387
+ const existing = this.#sessions.get(options.sessionId);
1388
+ if (existing !== undefined) {
1389
+ if (!existing.belongsTo(options.agent.id, options.cwd, options.additionalDirectories)) {
1390
+ throw new AcpExecutionError("The agent or workspace cannot change after a conversation has started", {}, "session_identity_mismatch");
1391
+ }
1392
+ return existing;
1393
+ }
1394
+ await this.prepareSession(options);
1395
+ const started = this.#sessions.get(options.sessionId);
1396
+ if (started === undefined) {
1397
+ throw new AcpExecutionError("ACP session did not start");
1398
+ }
1399
+ return started;
1400
+ };
300
1401
  }
301
1402
  //# sourceMappingURL=session-manager.js.map