@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.
package/dist/agent.js CHANGED
@@ -286,8 +286,6 @@ function toolToPriority(tool) {
286
286
 
287
287
  // src/agent/protocol-handler.ts
288
288
  import { randomUUID } from "node:crypto";
289
- import * as fsp from "node:fs/promises";
290
- import * as path from "node:path";
291
289
 
292
290
  // src/types/acp-v1.ts
293
291
  var ACP_PROTOCOL_VERSION = 1;
@@ -313,7 +311,9 @@ function toWire(msg) {
313
311
  }
314
312
  var WRONGSTACK_VERSION = ACP_PACKAGE_VERSION;
315
313
 
316
- // src/agent/protocol-handler.ts
314
+ // src/agent/protocol-session-ops.ts
315
+ import * as fsp from "node:fs/promises";
316
+ import * as path from "node:path";
317
317
  var WRONGSTACK_AUTH_METHODS = [
318
318
  {
319
319
  id: "wrongstack-auth",
@@ -332,6 +332,384 @@ var DEFAULT_MODES = [
332
332
  description: "Default agent mode for code-generation tasks."
333
333
  }
334
334
  ];
335
+ async function resolveSessionCwd(requested) {
336
+ if (!path.isAbsolute(requested)) return null;
337
+ const resolved = path.resolve(requested);
338
+ try {
339
+ const stat2 = await fsp.stat(resolved);
340
+ return stat2.isDirectory() ? resolved : null;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+ function errorToJsonRpc(err) {
346
+ if (err && typeof err === "object") {
347
+ const e = err;
348
+ if (typeof e.code === "number" && typeof e.message === "string") {
349
+ const result = {
350
+ code: e.code,
351
+ message: e.message
352
+ };
353
+ if (e.data !== void 0) result.data = e.data;
354
+ return result;
355
+ }
356
+ }
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ return { code: -32603, message };
359
+ }
360
+ function createRunTurnApi(sessionId, clientCapabilities, request) {
361
+ return {
362
+ clientCapabilities,
363
+ requestPermission: async (req) => {
364
+ const res = await request("session/request_permission", {
365
+ sessionId,
366
+ toolCall: req.toolCall,
367
+ options: req.options
368
+ });
369
+ const outcome = res?.outcome;
370
+ return outcome ?? { outcome: "cancelled" };
371
+ },
372
+ readTextFile: async (params) => {
373
+ const res = await request("fs/read_text_file", { sessionId, ...params });
374
+ return String(res?.content ?? "");
375
+ },
376
+ writeTextFile: async (params) => {
377
+ await request("fs/write_text_file", { sessionId, ...params });
378
+ },
379
+ runTerminal: async ({ command, args, cwd }) => {
380
+ const created = await request("terminal/create", {
381
+ sessionId,
382
+ command,
383
+ ...args ? { args } : {},
384
+ ...cwd ? { cwd } : {}
385
+ });
386
+ const terminalId = created?.terminalId;
387
+ if (!terminalId) return { output: "", exitCode: null };
388
+ try {
389
+ const exit = await request("terminal/wait_for_exit", {
390
+ sessionId,
391
+ terminalId
392
+ });
393
+ const out = await request("terminal/output", { sessionId, terminalId });
394
+ return {
395
+ output: String(out?.output ?? ""),
396
+ exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
397
+ };
398
+ } finally {
399
+ try {
400
+ await request("terminal/release", { sessionId, terminalId });
401
+ } catch {
402
+ }
403
+ }
404
+ }
405
+ };
406
+ }
407
+ function buildInitializeResult(agentName, modes, configOptions) {
408
+ return {
409
+ protocolVersion: ACP_PROTOCOL_VERSION,
410
+ agentCapabilities: {
411
+ loadSession: true,
412
+ promptCapabilities: {
413
+ image: true,
414
+ audio: false,
415
+ embeddedContext: true
416
+ },
417
+ mcpCapabilities: {
418
+ http: false,
419
+ sse: false
420
+ },
421
+ sessionCapabilities: {
422
+ close: {},
423
+ list: {},
424
+ delete: {},
425
+ resume: {},
426
+ fork: {}
427
+ },
428
+ auth: {
429
+ logout: {}
430
+ }
431
+ },
432
+ agentInfo: {
433
+ name: agentName,
434
+ title: "WrongStack",
435
+ version: WRONGSTACK_VERSION
436
+ },
437
+ authMethods: WRONGSTACK_AUTH_METHODS,
438
+ modes,
439
+ configOptions
440
+ };
441
+ }
442
+
443
+ // src/agent/protocol-session-management.ts
444
+ async function handleSessionNewOp(ctx, id, params) {
445
+ if (ctx.sessions.size >= ctx.maxSessions) {
446
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
447
+ return false;
448
+ }
449
+ const p = params ?? {};
450
+ let cwd = ctx.defaultCwd;
451
+ if (typeof p.cwd === "string") {
452
+ const resolved = await resolveSessionCwd(p.cwd);
453
+ if (resolved === null) {
454
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
455
+ return false;
456
+ }
457
+ cwd = resolved;
458
+ }
459
+ const sessionId = `sess_${ctx.allocId()}`;
460
+ const now = (/* @__PURE__ */ new Date()).toISOString();
461
+ const state = {
462
+ id: sessionId,
463
+ cwd,
464
+ abort: new AbortController(),
465
+ modeId: DEFAULT_MODE_ID,
466
+ createdAt: now,
467
+ updatedAt: now
468
+ };
469
+ ctx.sessions.set(sessionId, state);
470
+ ctx.onSessionNew(state);
471
+ await ctx.persist(state);
472
+ await ctx.sendNotification({
473
+ sessionId,
474
+ update: {
475
+ sessionUpdate: "current_mode_update",
476
+ modeId: ctx.modes[0]?.id ?? DEFAULT_MODE_ID
477
+ }
478
+ });
479
+ if (ctx.configOptions.length > 0) {
480
+ await ctx.sendNotification({
481
+ sessionId,
482
+ update: {
483
+ sessionUpdate: "config_option_update",
484
+ configOptions: [...ctx.configOptions]
485
+ }
486
+ });
487
+ }
488
+ await ctx.sendResult(id, {
489
+ sessionId,
490
+ modes: ctx.modes,
491
+ configOptions: ctx.configOptions
492
+ });
493
+ return false;
494
+ }
495
+ async function handleSessionLoadOp(ctx, id, params) {
496
+ const p = params ?? {};
497
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
498
+ const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
499
+ const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
500
+ if (!existing && sessionId && ctx.store) {
501
+ const persisted = await ctx.store.load(sessionId);
502
+ if (persisted) {
503
+ if (ctx.sessions.size >= ctx.maxSessions) {
504
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
505
+ return false;
506
+ }
507
+ if (loadCwd !== void 0 && await resolveSessionCwd(loadCwd) === null) {
508
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
509
+ return false;
510
+ }
511
+ const candidateCwd = persisted.cwd ?? loadCwd ?? ctx.defaultCwd;
512
+ const restoredCwd = await resolveSessionCwd(candidateCwd) ?? ctx.defaultCwd;
513
+ const restored = {
514
+ id: sessionId,
515
+ cwd: restoredCwd,
516
+ abort: new AbortController(),
517
+ modeId: persisted.modeId ?? DEFAULT_MODE_ID,
518
+ createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
519
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
520
+ ...persisted.title !== void 0 ? { title: persisted.title } : {}
521
+ };
522
+ ctx.sessions.set(sessionId, restored);
523
+ ctx.seedFor?.(sessionId, persisted.history ?? []);
524
+ for (const update of persisted.history ?? []) {
525
+ await ctx.sendNotification({ sessionId, update });
526
+ }
527
+ await ctx.sendNotification({
528
+ sessionId,
529
+ update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
530
+ });
531
+ await ctx.sendResult(id, {
532
+ initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
533
+ });
534
+ return false;
535
+ }
536
+ }
537
+ if (existing) {
538
+ existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
539
+ const replay = ctx.replayFor?.(sessionId);
540
+ if (replay) {
541
+ for (const update of replay) {
542
+ await ctx.sendNotification({ sessionId, update });
543
+ }
544
+ }
545
+ await ctx.sendNotification({
546
+ sessionId,
547
+ update: {
548
+ sessionUpdate: "session_info_update",
549
+ updatedAt: existing.updatedAt
550
+ }
551
+ });
552
+ await ctx.sendNotification({
553
+ sessionId,
554
+ update: {
555
+ sessionUpdate: "current_mode_update",
556
+ modeId: existing.modeId
557
+ }
558
+ });
559
+ await ctx.sendResult(id, {
560
+ initialMode: {
561
+ currentModeId: existing.modeId,
562
+ availableModes: ctx.modes
563
+ }
564
+ });
565
+ return false;
566
+ }
567
+ await ctx.sendError(id, -32e3, `session not found: ${sessionId}`);
568
+ return false;
569
+ }
570
+ async function handleSessionForkOp(ctx, id, params) {
571
+ const p = params ?? {};
572
+ const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
573
+ const source = sourceId ? ctx.sessions.get(sourceId) : void 0;
574
+ if (!sourceId || !source) {
575
+ await ctx.sendError(id, -32e3, `session not found: ${sourceId}`);
576
+ return false;
577
+ }
578
+ if (ctx.sessions.size >= ctx.maxSessions) {
579
+ await ctx.sendError(id, -32e3, `active session limit reached (${ctx.maxSessions})`);
580
+ return false;
581
+ }
582
+ let forkCwd = source.cwd;
583
+ if (typeof p.cwd === "string") {
584
+ const resolved = await resolveSessionCwd(p.cwd);
585
+ if (resolved === null) {
586
+ await ctx.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
587
+ return false;
588
+ }
589
+ forkCwd = resolved;
590
+ }
591
+ const now = (/* @__PURE__ */ new Date()).toISOString();
592
+ const sessionId = `sess_${ctx.allocId()}`;
593
+ const forked = {
594
+ id: sessionId,
595
+ cwd: forkCwd,
596
+ abort: new AbortController(),
597
+ modeId: source.modeId,
598
+ createdAt: now,
599
+ updatedAt: now,
600
+ ...source.title !== void 0 ? { title: source.title } : {}
601
+ };
602
+ const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
603
+ sessionUpdate: update.sessionUpdate,
604
+ content: structuredClone(update.content)
605
+ }));
606
+ ctx.sessions.set(sessionId, forked);
607
+ ctx.seedFor?.(sessionId, history);
608
+ ctx.onSessionNew(forked);
609
+ await ctx.persist(forked, history);
610
+ await ctx.sendNotification({
611
+ sessionId,
612
+ update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
613
+ });
614
+ await ctx.sendResult(id, {
615
+ sessionId,
616
+ modes: ctx.modes,
617
+ configOptions: ctx.configOptions
618
+ });
619
+ return false;
620
+ }
621
+ async function handleSessionPromptOp(ctx, id, params) {
622
+ const p = params ?? {};
623
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
624
+ if (!sessionId || !ctx.sessions.has(sessionId)) {
625
+ await ctx.sendError(id, -32e3, "unknown or missing sessionId");
626
+ return false;
627
+ }
628
+ if (!Array.isArray(p.prompt)) {
629
+ await ctx.sendError(id, -32602, "prompt must be an array of content blocks");
630
+ return false;
631
+ }
632
+ const session = ctx.sessions.get(sessionId);
633
+ if (session.abort.signal.aborted) {
634
+ session.abort = new AbortController();
635
+ }
636
+ const turnSignal = new AbortController();
637
+ const onCancel = () => turnSignal.abort();
638
+ session.abort.signal.addEventListener("abort", onCancel, { once: true });
639
+ const api = createRunTurnApi(
640
+ sessionId,
641
+ ctx.clientCapabilities ?? {},
642
+ (method, req) => ctx.request(method, req)
643
+ );
644
+ let result;
645
+ const pendingNotifications = [];
646
+ const emit = (update) => {
647
+ const notifPromise = ctx.sendNotification({ sessionId, update });
648
+ pendingNotifications.push(notifPromise.catch(() => {
649
+ }));
650
+ };
651
+ try {
652
+ result = await ctx.runTurn(
653
+ { sessionId, prompt: p.prompt, signal: turnSignal.signal },
654
+ emit,
655
+ api
656
+ );
657
+ } catch (err) {
658
+ session.abort.signal.removeEventListener("abort", onCancel);
659
+ const { code, message, data } = errorToJsonRpc(err);
660
+ await ctx.sendError(id, code, message, data);
661
+ return false;
662
+ }
663
+ await Promise.all(pendingNotifications);
664
+ session.abort.signal.removeEventListener("abort", onCancel);
665
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
666
+ await ctx.persist(session);
667
+ await ctx.sendResult(id, { stopReason: result.stopReason });
668
+ return false;
669
+ }
670
+ async function handleSetModeOp(ctx, id, params) {
671
+ const p = params ?? {};
672
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
673
+ const modeId = typeof p.modeId === "string" ? p.modeId : null;
674
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
675
+ if (!session || !modeId || !ctx.modes.some((m) => m.id === modeId)) {
676
+ await ctx.sendError(id, -32602, "invalid sessionId or modeId");
677
+ return false;
678
+ }
679
+ session.modeId = modeId;
680
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
681
+ await ctx.sendNotification({
682
+ sessionId,
683
+ update: { sessionUpdate: "current_mode_update", modeId }
684
+ });
685
+ await ctx.sendResult(id, {});
686
+ return false;
687
+ }
688
+ async function handleSetConfigOptionOp(ctx, id, params) {
689
+ const p = params ?? {};
690
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
691
+ const optionId = typeof p.configId === "string" ? p.configId : null;
692
+ const value = typeof p.value === "string" ? p.value : null;
693
+ const session = sessionId ? ctx.sessions.get(sessionId) : void 0;
694
+ const option = optionId ? ctx.configOptions.find((o) => o.id === optionId) : void 0;
695
+ if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
696
+ await ctx.sendError(id, -32602, "invalid sessionId, configId, or value");
697
+ return false;
698
+ }
699
+ option.currentValue = value;
700
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
701
+ await ctx.sendNotification({
702
+ sessionId,
703
+ update: {
704
+ sessionUpdate: "config_option_update",
705
+ configOptions: [...ctx.configOptions]
706
+ }
707
+ });
708
+ await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
709
+ return false;
710
+ }
711
+
712
+ // src/agent/protocol-handler.ts
335
713
  var ACPProtocolHandler = class {
336
714
  transport;
337
715
  defaultCwd;
@@ -440,6 +818,27 @@ var ACPProtocolHandler = class {
440
818
  } catch {
441
819
  }
442
820
  }
821
+ sessionContext() {
822
+ return {
823
+ sessions: this.sessions,
824
+ maxSessions: this.maxSessions,
825
+ defaultCwd: this.defaultCwd,
826
+ modes: this.modes,
827
+ configOptions: this.configOptions,
828
+ store: this.store,
829
+ replayFor: this.replayFor,
830
+ seedFor: this.seedFor,
831
+ onSessionNew: this.onSessionNew,
832
+ allocId: () => this.allocId(),
833
+ persist: (state, history) => this.persist(state, history),
834
+ sendNotification: (params) => this.sendNotification(params),
835
+ sendError: (id, code, message, data) => this.sendError(id, code, message, data),
836
+ sendResult: (id, result) => this.sendResult(id, result),
837
+ request: (method, params, timeoutMs) => this.request(method, params, timeoutMs),
838
+ runTurn: this.runTurn,
839
+ clientCapabilities: this.clientCapabilities
840
+ };
841
+ }
443
842
  // ────────────────────────────────────────────────────────────────────
444
843
  // Requests
445
844
  // ────────────────────────────────────────────────────────────────────
@@ -457,9 +856,9 @@ var ACPProtocolHandler = class {
457
856
  case "logout":
458
857
  return await this.handleLogout(id, params);
459
858
  case "session/new":
460
- return await this.handleSessionNew(id, params);
859
+ return await handleSessionNewOp(this.sessionContext(), id, params);
461
860
  case "session/load":
462
- return await this.handleSessionLoad(id, params);
861
+ return await handleSessionLoadOp(this.sessionContext(), id, params);
463
862
  case "session/resume":
464
863
  return await this.handleSessionResume(id, params);
465
864
  case "session/close":
@@ -467,15 +866,15 @@ var ACPProtocolHandler = class {
467
866
  case "session/delete":
468
867
  return await this.handleSessionDelete(id, params);
469
868
  case "session/prompt":
470
- return await this.handleSessionPrompt(id, params);
869
+ return await handleSessionPromptOp(this.sessionContext(), id, params);
471
870
  case "session/set_mode":
472
- return await this.handleSetMode(id, params);
871
+ return await handleSetModeOp(this.sessionContext(), id, params);
473
872
  case "session/set_config_option":
474
- return await this.handleSetConfigOption(id, params);
873
+ return await handleSetConfigOptionOp(this.sessionContext(), id, params);
475
874
  case "session/list":
476
875
  return await this.handleSessionList(id);
477
876
  case "session/fork":
478
- return await this.handleSessionFork(id, params);
877
+ return await handleSessionForkOp(this.sessionContext(), id, params);
479
878
  case "providers/list":
480
879
  return await this.handleProvidersList(id, params);
481
880
  case "providers/set":
@@ -500,232 +899,32 @@ var ACPProtocolHandler = class {
500
899
  this.clientCapabilities = p.clientCapabilities;
501
900
  }
502
901
  this.initialized = true;
503
- await this.transport.send(
504
- toWire({
505
- jsonrpc: "2.0",
506
- id,
507
- result: {
508
- protocolVersion: ACP_PROTOCOL_VERSION,
509
- agentCapabilities: {
510
- loadSession: true,
511
- promptCapabilities: {
512
- // We route ACP image blocks into the core agent's multimodal
513
- // input (server-agent-turn.promptToAgentInput); whether the
514
- // model can see them is the configured provider's concern.
515
- image: true,
516
- audio: false,
517
- embeddedContext: true
518
- },
519
- mcpCapabilities: {
520
- http: false,
521
- sse: false
522
- },
523
- sessionCapabilities: {
524
- close: {},
525
- list: {},
526
- delete: {},
527
- resume: {},
528
- fork: {}
529
- },
530
- auth: {
531
- logout: {}
532
- }
533
- },
534
- agentInfo: {
535
- name: this.agentName,
536
- title: "WrongStack",
537
- version: WRONGSTACK_VERSION
538
- },
539
- authMethods: WRONGSTACK_AUTH_METHODS,
540
- modes: this.modes,
541
- configOptions: this.configOptions
542
- }
543
- })
902
+ await this.sendResult(
903
+ id,
904
+ buildInitializeResult(this.agentName, this.modes, this.configOptions)
544
905
  );
545
906
  return false;
546
907
  }
547
908
  async handleAuthenticate(id, _params) {
548
- await this.transport.send(
549
- toWire({
550
- jsonrpc: "2.0",
551
- id,
552
- result: { outcome: "unauthenticated" }
553
- })
554
- );
909
+ await this.sendResult(id, { outcome: "unauthenticated" });
555
910
  return false;
556
911
  }
557
912
  async handleLogout(id, _params) {
558
- await this.transport.send(
559
- toWire({
560
- jsonrpc: "2.0",
561
- id,
562
- result: {}
563
- })
564
- );
913
+ await this.sendResult(id, {});
565
914
  return false;
566
915
  }
567
- async handleSessionNew(id, params) {
568
- if (this.sessions.size >= this.maxSessions) {
569
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
570
- return false;
571
- }
572
- const p = params ?? {};
573
- let cwd = this.defaultCwd;
574
- if (typeof p.cwd === "string") {
575
- const resolved = await this.resolveSessionCwd(p.cwd);
576
- if (resolved === null) {
577
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
578
- return false;
579
- }
580
- cwd = resolved;
581
- }
582
- const sessionId = `sess_${this.allocId()}`;
583
- const now = (/* @__PURE__ */ new Date()).toISOString();
584
- const state = {
585
- id: sessionId,
586
- cwd,
587
- abort: new AbortController(),
588
- modeId: DEFAULT_MODE_ID,
589
- createdAt: now,
590
- updatedAt: now
591
- };
592
- this.sessions.set(sessionId, state);
593
- this.onSessionNew(state);
594
- await this.persist(state);
595
- await this.sendNotification({
596
- sessionId,
597
- update: {
598
- sessionUpdate: "current_mode_update",
599
- modeId: this.modes[0]?.id ?? DEFAULT_MODE_ID
600
- }
601
- });
602
- if (this.configOptions.length > 0) {
603
- await this.sendNotification({
604
- sessionId,
605
- update: {
606
- sessionUpdate: "config_option_update",
607
- configOptions: [...this.configOptions]
608
- }
609
- });
610
- }
611
- await this.transport.send(
612
- toWire({
613
- jsonrpc: "2.0",
614
- id,
615
- result: {
616
- sessionId,
617
- modes: this.modes,
618
- configOptions: this.configOptions
619
- }
620
- })
621
- );
622
- return false;
623
- }
624
- async handleSessionLoad(id, params) {
916
+ async handleSessionResume(id, params) {
625
917
  const p = params ?? {};
626
918
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
627
- const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
628
919
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
629
- if (!existing && sessionId && this.store) {
630
- const persisted = await this.store.load(sessionId);
631
- if (persisted) {
632
- if (this.sessions.size >= this.maxSessions) {
633
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
634
- return false;
635
- }
636
- if (loadCwd !== void 0 && await this.resolveSessionCwd(loadCwd) === null) {
637
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
638
- return false;
639
- }
640
- const candidateCwd = persisted.cwd ?? loadCwd ?? this.defaultCwd;
641
- const restoredCwd = await this.resolveSessionCwd(candidateCwd) ?? this.defaultCwd;
642
- const restored = {
643
- id: sessionId,
644
- cwd: restoredCwd,
645
- abort: new AbortController(),
646
- modeId: persisted.modeId ?? DEFAULT_MODE_ID,
647
- createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
648
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
649
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
650
- };
651
- this.sessions.set(sessionId, restored);
652
- this.seedFor?.(sessionId, persisted.history ?? []);
653
- for (const update of persisted.history ?? []) {
654
- await this.sendNotification({ sessionId, update });
655
- }
656
- await this.sendNotification({
657
- sessionId,
658
- update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
659
- });
660
- await this.transport.send(
661
- toWire({
662
- jsonrpc: "2.0",
663
- id,
664
- result: {
665
- initialMode: { currentModeId: restored.modeId, availableModes: this.modes }
666
- }
667
- })
668
- );
669
- return false;
670
- }
671
- }
672
920
  if (existing) {
673
921
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
674
- const replay = this.replayFor?.(sessionId);
675
- if (replay) {
676
- for (const update of replay) {
677
- await this.sendNotification({ sessionId, update });
678
- }
679
- }
680
- await this.sendNotification({
681
- sessionId,
682
- update: {
683
- sessionUpdate: "session_info_update",
684
- updatedAt: existing.updatedAt
685
- }
686
- });
687
- await this.sendNotification({
688
- sessionId,
689
- update: {
690
- sessionUpdate: "current_mode_update",
691
- modeId: existing.modeId
922
+ await this.sendResult(id, {
923
+ initialMode: {
924
+ currentModeId: existing.modeId,
925
+ availableModes: this.modes
692
926
  }
693
927
  });
694
- await this.transport.send(
695
- toWire({
696
- jsonrpc: "2.0",
697
- id,
698
- result: {
699
- initialMode: {
700
- currentModeId: existing.modeId,
701
- availableModes: this.modes
702
- }
703
- }
704
- })
705
- );
706
- return false;
707
- }
708
- await this.sendError(id, -32e3, `session not found: ${sessionId}`);
709
- return false;
710
- }
711
- async handleSessionResume(id, params) {
712
- const p = params ?? {};
713
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
714
- const existing = sessionId ? this.sessions.get(sessionId) : void 0;
715
- if (existing) {
716
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
717
- await this.transport.send(
718
- toWire({
719
- jsonrpc: "2.0",
720
- id,
721
- result: {
722
- initialMode: {
723
- currentModeId: existing.modeId,
724
- availableModes: this.modes
725
- }
726
- }
727
- })
728
- );
729
928
  return false;
730
929
  }
731
930
  await this.sendError(id, -32e3, `session not found: ${sessionId}`);
@@ -742,13 +941,7 @@ var ACPProtocolHandler = class {
742
941
  session.abort.abort();
743
942
  this.sessions.delete(sessionId);
744
943
  this.disposeSession(sessionId);
745
- await this.transport.send(
746
- toWire({
747
- jsonrpc: "2.0",
748
- id,
749
- result: {}
750
- })
751
- );
944
+ await this.sendResult(id, {});
752
945
  return false;
753
946
  }
754
947
  async handleSessionDelete(id, params) {
@@ -759,92 +952,21 @@ var ACPProtocolHandler = class {
759
952
  return false;
760
953
  }
761
954
  if (!this.sessions.has(sessionId)) {
762
- await this.transport.send(
763
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
764
- );
955
+ await this.sendResult(id, { configOptions: [...this.configOptions] });
765
956
  return false;
766
957
  }
767
958
  const session = this.sessions.get(sessionId);
768
959
  session.abort.abort();
769
960
  this.sessions.delete(sessionId);
770
961
  this.disposeSession(sessionId);
771
- await this.transport.send(
772
- toWire({
773
- jsonrpc: "2.0",
774
- id,
775
- result: {}
776
- })
777
- );
778
- return false;
779
- }
780
- async handleSessionFork(id, params) {
781
- const p = params ?? {};
782
- const sourceId = typeof p.sessionId === "string" ? p.sessionId : null;
783
- const source = sourceId ? this.sessions.get(sourceId) : void 0;
784
- if (!sourceId || !source) {
785
- await this.sendError(id, -32e3, `session not found: ${sourceId}`);
786
- return false;
787
- }
788
- if (this.sessions.size >= this.maxSessions) {
789
- await this.sendError(id, -32e3, `active session limit reached (${this.maxSessions})`);
790
- return false;
791
- }
792
- let forkCwd = source.cwd;
793
- if (typeof p.cwd === "string") {
794
- const resolved = await this.resolveSessionCwd(p.cwd);
795
- if (resolved === null) {
796
- await this.sendError(id, -32602, `cwd must be an absolute path to an existing directory`);
797
- return false;
798
- }
799
- forkCwd = resolved;
800
- }
801
- const now = (/* @__PURE__ */ new Date()).toISOString();
802
- const sessionId = `sess_${this.allocId()}`;
803
- const forked = {
804
- id: sessionId,
805
- cwd: forkCwd,
806
- abort: new AbortController(),
807
- modeId: source.modeId,
808
- createdAt: now,
809
- updatedAt: now,
810
- ...source.title !== void 0 ? { title: source.title } : {}
811
- };
812
- const history = (this.replayFor?.(sourceId) ?? []).map((update) => ({
813
- sessionUpdate: update.sessionUpdate,
814
- content: structuredClone(update.content)
815
- }));
816
- this.sessions.set(sessionId, forked);
817
- this.seedFor?.(sessionId, history);
818
- this.onSessionNew(forked);
819
- await this.persist(forked, history);
820
- await this.sendNotification({
821
- sessionId,
822
- update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
823
- });
824
- await this.transport.send(
825
- toWire({
826
- jsonrpc: "2.0",
827
- id,
828
- result: {
829
- sessionId,
830
- modes: this.modes,
831
- configOptions: this.configOptions
832
- }
833
- })
834
- );
962
+ await this.sendResult(id, {});
835
963
  return false;
836
964
  }
837
965
  async handleProvidersList(id, _params) {
838
- await this.transport.send(
839
- toWire({
840
- jsonrpc: "2.0",
841
- id,
842
- result: {
843
- providers: [],
844
- currentProviderId: null
845
- }
846
- })
847
- );
966
+ await this.sendResult(id, {
967
+ providers: [],
968
+ currentProviderId: null
969
+ });
848
970
  return false;
849
971
  }
850
972
  async handleProvidersSet(id, _params) {
@@ -856,157 +978,13 @@ var ACPProtocolHandler = class {
856
978
  return false;
857
979
  }
858
980
  async handleProvidersDisable(id, _params) {
859
- await this.transport.send(
860
- toWire({
861
- jsonrpc: "2.0",
862
- id,
863
- result: {}
864
- })
865
- );
981
+ await this.sendResult(id, {});
866
982
  return false;
867
983
  }
868
984
  async handleMcpMessage(id, _params) {
869
985
  await this.sendError(id, -32e3, "MCP message routing not available through ACP");
870
986
  return false;
871
987
  }
872
- async handleSessionPrompt(id, params) {
873
- const p = params ?? {};
874
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
875
- if (!sessionId || !this.sessions.has(sessionId)) {
876
- await this.sendError(id, -32e3, "unknown or missing sessionId");
877
- return false;
878
- }
879
- if (!Array.isArray(p.prompt)) {
880
- await this.sendError(id, -32602, "prompt must be an array of content blocks");
881
- return false;
882
- }
883
- const session = this.sessions.get(sessionId);
884
- if (session.abort.signal.aborted) {
885
- session.abort = new AbortController();
886
- }
887
- const turnSignal = new AbortController();
888
- const onCancel = () => turnSignal.abort();
889
- session.abort.signal.addEventListener("abort", onCancel, { once: true });
890
- const api = {
891
- clientCapabilities: this.clientCapabilities,
892
- requestPermission: async (req) => {
893
- const res = await this.request("session/request_permission", {
894
- sessionId,
895
- toolCall: req.toolCall,
896
- options: req.options
897
- });
898
- const outcome = res?.outcome;
899
- return outcome ?? { outcome: "cancelled" };
900
- },
901
- readTextFile: async (params2) => {
902
- const res = await this.request("fs/read_text_file", { sessionId, ...params2 });
903
- return String(res?.content ?? "");
904
- },
905
- writeTextFile: async (params2) => {
906
- await this.request("fs/write_text_file", { sessionId, ...params2 });
907
- },
908
- runTerminal: async ({ command, args, cwd }) => {
909
- const created = await this.request("terminal/create", {
910
- sessionId,
911
- command,
912
- ...args ? { args } : {},
913
- ...cwd ? { cwd } : {}
914
- });
915
- const terminalId = created?.terminalId;
916
- if (!terminalId) return { output: "", exitCode: null };
917
- try {
918
- const exit = await this.request("terminal/wait_for_exit", {
919
- sessionId,
920
- terminalId
921
- });
922
- const out = await this.request("terminal/output", { sessionId, terminalId });
923
- return {
924
- output: String(out?.output ?? ""),
925
- exitCode: typeof exit?.exitCode === "number" ? exit.exitCode : null
926
- };
927
- } finally {
928
- try {
929
- await this.request("terminal/release", { sessionId, terminalId });
930
- } catch {
931
- }
932
- }
933
- }
934
- };
935
- let result;
936
- const pendingNotifications = [];
937
- const emit = (update) => {
938
- const p2 = this.sendNotification({ sessionId, update });
939
- pendingNotifications.push(p2.catch(() => {
940
- }));
941
- };
942
- try {
943
- result = await this.runTurn(
944
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
945
- emit,
946
- api
947
- );
948
- } catch (err) {
949
- session.abort.signal.removeEventListener("abort", onCancel);
950
- const { code, message, data } = errorToJsonRpc(err);
951
- await this.sendError(id, code, message, data);
952
- return false;
953
- }
954
- await Promise.all(pendingNotifications);
955
- session.abort.signal.removeEventListener("abort", onCancel);
956
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
957
- await this.persist(session);
958
- await this.transport.send(
959
- toWire({
960
- jsonrpc: "2.0",
961
- id,
962
- result: { stopReason: result.stopReason }
963
- })
964
- );
965
- return false;
966
- }
967
- async handleSetMode(id, params) {
968
- const p = params ?? {};
969
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
970
- const modeId = typeof p.modeId === "string" ? p.modeId : null;
971
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
972
- if (!session || !modeId || !this.modes.some((m) => m.id === modeId)) {
973
- await this.sendError(id, -32602, "invalid sessionId or modeId");
974
- return false;
975
- }
976
- session.modeId = modeId;
977
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
978
- await this.sendNotification({
979
- sessionId,
980
- update: { sessionUpdate: "current_mode_update", modeId }
981
- });
982
- await this.transport.send(toWire({ jsonrpc: "2.0", id, result: {} }));
983
- return false;
984
- }
985
- async handleSetConfigOption(id, params) {
986
- const p = params ?? {};
987
- const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
988
- const optionId = typeof p.configId === "string" ? p.configId : null;
989
- const value = typeof p.value === "string" ? p.value : null;
990
- const session = sessionId ? this.sessions.get(sessionId) : void 0;
991
- const option = optionId ? this.configOptions.find((o) => o.id === optionId) : void 0;
992
- if (!session || !option || value === null || !option.options.some((o) => o.value === value)) {
993
- await this.sendError(id, -32602, "invalid sessionId, configId, or value");
994
- return false;
995
- }
996
- option.currentValue = value;
997
- session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
998
- await this.sendNotification({
999
- sessionId,
1000
- update: {
1001
- sessionUpdate: "config_option_update",
1002
- configOptions: [...this.configOptions]
1003
- }
1004
- });
1005
- await this.transport.send(
1006
- toWire({ jsonrpc: "2.0", id, result: { configOptions: [...this.configOptions] } })
1007
- );
1008
- return false;
1009
- }
1010
988
  async handleSessionList(id) {
1011
989
  const sessions = Array.from(this.sessions.values()).map((s) => {
1012
990
  const out = {
@@ -1017,13 +995,7 @@ var ACPProtocolHandler = class {
1017
995
  if (s.title !== void 0) out.title = s.title;
1018
996
  return out;
1019
997
  });
1020
- await this.transport.send(
1021
- toWire({
1022
- jsonrpc: "2.0",
1023
- id,
1024
- result: { sessions }
1025
- })
1026
- );
998
+ await this.sendResult(id, { sessions });
1027
999
  return false;
1028
1000
  }
1029
1001
  // ────────────────────────────────────────────────────────────────────
@@ -1056,7 +1028,9 @@ var ACPProtocolHandler = class {
1056
1028
  async sendNotification(params) {
1057
1029
  await this.transport.send(toWire({ jsonrpc: "2.0", method: "session/update", params }));
1058
1030
  }
1059
- /** Best-effort durable persistence of a session + its recorded history. */
1031
+ async sendResult(id, result) {
1032
+ await this.transport.send(toWire({ jsonrpc: "2.0", id, result }));
1033
+ }
1060
1034
  async persist(state, history = void 0) {
1061
1035
  if (!this.store) return;
1062
1036
  try {
@@ -1069,71 +1043,10 @@ var ACPProtocolHandler = class {
1069
1043
  if (data !== void 0) error.data = data;
1070
1044
  await this.transport.send(toWire({ jsonrpc: "2.0", id, error }));
1071
1045
  }
1072
- /**
1073
- * Allocate a session id (WS-015).
1074
- *
1075
- * This was `this.nextId++`, so ids were `sess_1`, `sess_2`, … — and the
1076
- * handler has no per-connection ownership: any caller that names a session
1077
- * id can `session/load`, `session/prompt`, `session/cancel` or
1078
- * `session/delete` it. Over stdio that is academic (one client per process),
1079
- * but the agent also serves over HTTP, where a guessable id is the whole
1080
- * authorization story for any local process or page that reaches the port.
1081
- *
1082
- * Random ids do not create ownership — they remove the trivial enumeration
1083
- * that made its absence exploitable. Real per-connection ownership is the
1084
- * larger fix and is noted in the WS-015 test file.
1085
- *
1086
- * The counter is retained: it keeps ids ordered for debugging and guarantees
1087
- * uniqueness within a process even in the (impossible) event of a UUID
1088
- * collision. The random half is what makes the id unguessable.
1089
- */
1090
- /**
1091
- * Resolve a client-supplied `cwd` for a session, or `null` when it is not
1092
- * usable (WS-015).
1093
- *
1094
- * `session/new`, `session/load` and `session/fork` all took `params.cwd`
1095
- * with a single `typeof === 'string'` check and nothing else. That value is
1096
- * the working directory the agent then reads, writes and executes in.
1097
- *
1098
- * SCOPE, deliberately stated: this does NOT confine the session to a root.
1099
- * In ACP the client IS the editor and legitimately names its own workspace —
1100
- * Zed and JetBrains pass the project root — so a fixed boundary here would
1101
- * break the integration this package exists for. What it enforces is that
1102
- * the directory is absolute and actually exists as a directory: a relative
1103
- * or missing `cwd` is a bug or an attack under either reading, and silently
1104
- * running the agent somewhere other than where the client asked is worse
1105
- * than refusing. Confinement, if wanted, belongs in an operator-set option
1106
- * on top of this, not in place of it.
1107
- */
1108
- async resolveSessionCwd(requested) {
1109
- if (!path.isAbsolute(requested)) return null;
1110
- const resolved = path.resolve(requested);
1111
- try {
1112
- const stat2 = await fsp.stat(resolved);
1113
- return stat2.isDirectory() ? resolved : null;
1114
- } catch {
1115
- return null;
1116
- }
1117
- }
1118
1046
  allocId() {
1119
1047
  return `${this.nextId++}_${randomUUID().replaceAll("-", "")}`;
1120
1048
  }
1121
1049
  };
1122
- function errorToJsonRpc(err) {
1123
- if (err && typeof err === "object") {
1124
- const e = err;
1125
- if (typeof e.code === "number" && typeof e.message === "string") {
1126
- const result = {
1127
- code: e.code,
1128
- message: e.message
1129
- };
1130
- if (e.data !== void 0) result.data = e.data;
1131
- return result;
1132
- }
1133
- }
1134
- const message = err instanceof Error ? err.message : String(err);
1135
- return { code: -32603, message };
1136
- }
1137
1050
 
1138
1051
  // src/agent/session-store.ts
1139
1052
  import * as fsp2 from "node:fs/promises";