farming-code 2.2.19 → 2.2.22

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 (53) hide show
  1. package/README.md +5 -5
  2. package/README.zh_cn.md +5 -5
  3. package/THIRD_PARTY_NOTICES.md +5 -8
  4. package/backend/acp/client-services.js +37 -5
  5. package/backend/acp/patch-decisions.js +19 -3
  6. package/backend/acp-runtime.js +1201 -198
  7. package/backend/acp-transcript.js +66 -0
  8. package/backend/agent-lifecycle-journal.js +133 -0
  9. package/backend/agent-manager.js +2715 -354
  10. package/backend/agent-order.js +8 -4
  11. package/backend/agent-runtime-binding.js +1 -0
  12. package/backend/bundle-update-helper.js +5 -1
  13. package/backend/config-manager.js +16 -0
  14. package/backend/control-api.js +146 -20
  15. package/backend/farming-app-cli.js +335 -10
  16. package/backend/farming-session-store.js +283 -36
  17. package/backend/json-cli-runtime.js +120 -6
  18. package/backend/npm-update-helper.js +97 -18
  19. package/backend/provider-adapters.js +18 -6
  20. package/backend/review-session-service.js +31 -17
  21. package/backend/server.js +337 -103
  22. package/backend/storage-layout.js +10 -0
  23. package/backend/terminal-screen-worker-pool.js +15 -6
  24. package/backend/theme-manager.js +23 -5
  25. package/backend/update-service.js +239 -27
  26. package/backend/usage-monitor.js +11 -0
  27. package/backend/workspace-file-router.js +9 -3
  28. package/backend/workspace-file-service.js +292 -62
  29. package/dist/acp/codex-acp-1.1.4.js +32 -1
  30. package/dist/assets/App-CmZdNavW.js +210 -0
  31. package/dist/assets/{FileEditorMarkdownPreview-R0jwwLME.js → FileEditorMarkdownPreview-KbnE8VC8.js} +1 -1
  32. package/dist/assets/FileEditorPane-5_HUejjd.js +2 -0
  33. package/dist/assets/IconGlyphs-C2WFcoJS.js +1 -0
  34. package/dist/assets/ProjectFilesSection-C1RAJWOE.js +12 -0
  35. package/dist/assets/{ReviewPage-DBKNOWsJ.js → ReviewPage-L1vh1Ggs.js} +3 -3
  36. package/dist/assets/code-dark-BKtvf0Xc.css +1 -0
  37. package/dist/assets/file-icons-CHWt5nSy.js +1 -0
  38. package/dist/assets/{index-DQdtPfCk.js → index-RvUiMpGP.js} +2 -2
  39. package/dist/assets/main-XMXgyJJt.css +1 -0
  40. package/dist/assets/workspace-view-state-BqB4HM7r.js +7 -0
  41. package/dist/index.html +1 -1
  42. package/frontend/skins/crt/app.js +365 -107
  43. package/frontend/skins/crt/index.html +5 -10
  44. package/frontend/skins/crt/styles/billing.css +6 -1
  45. package/package.json +9 -3
  46. package/dist/assets/App-CbXYBccJ.js +0 -210
  47. package/dist/assets/FileEditorPane-CJs6kfZO.js +0 -2
  48. package/dist/assets/IconGlyphs-CZzDr5o2.js +0 -1
  49. package/dist/assets/ProjectFilesSection-DWCV07lA.js +0 -12
  50. package/dist/assets/code-dark-qIBiLBWV.css +0 -1
  51. package/dist/assets/file-icons-DpbrD5ZP.js +0 -1
  52. package/dist/assets/main-yzZEfgft.css +0 -1
  53. package/dist/assets/workspace-view-state-BpPINu84.js +0 -7
@@ -13,7 +13,7 @@ const { readCodexHistoryImageData } = require('./codex-transcript');
13
13
  const { AcpClientFileSystem, AcpClientTerminalManager } = require('./acp/client-services');
14
14
  const { PACKAGED_CODEX_ACP_ARG } = require('./acp/packaged-codex-acp');
15
15
  const { permissionSecurityWarnings } = require('./acp/permission-security');
16
- const { rejectPatch } = require('./acp/patch-decisions');
16
+ const { patchBlock, rejectPatch } = require('./acp/patch-decisions');
17
17
  const { getProviderAdapter, listProviderAdapters } = require('./provider-adapters');
18
18
  const { isSafeProviderSessionId } = require('./provider-session-id');
19
19
 
@@ -35,7 +35,7 @@ const CODEX_SET_SESSION_MODEL_METHOD = 'session/set_model';
35
35
  const CODEX_STEER_METHOD = '_codex/session/steer';
36
36
  const CODEX_ACP_PACKAGE = '@agentclientprotocol/codex-acp';
37
37
  const CODEX_ACP_VERSION = '1.1.4';
38
- const CODEX_ACP_SHA256 = '39cbae01e336c2ca185d624358e03280d1f6fef6d73bbe42dd9eb77e2b1efb32';
38
+ const CODEX_ACP_SHA256 = 'e36876cc2250737c719644e6c69ab054a3f8b58071fad45c44407013082942ff';
39
39
  const CODEX_ACP_VENDOR_ENTRY = path.join(
40
40
  __dirname,
41
41
  '..',
@@ -234,7 +234,8 @@ function validateElicitationContent(request, content) {
234
234
  function interactiveRuntimeState(binding, fallback = '') {
235
235
  if (binding.pendingPermissions.size > 0) return 'waiting-for-permission';
236
236
  if (binding.pendingElicitations.size > 0) return 'waiting-for-input';
237
- if (binding.promptActive) return 'working';
237
+ if (binding.activeTurn?.phase === 'running') return 'working';
238
+ if (binding.activeTurn?.phase === 'cancelling') return 'interrupting';
238
239
  if (['connecting', 'idle', 'error'].includes(String(fallback || ''))) return fallback;
239
240
  return binding.sessionId ? 'idle' : 'connecting';
240
241
  }
@@ -336,6 +337,98 @@ function signalProcessTree(binding, signal) {
336
337
  binding.child.kill(signal);
337
338
  }
338
339
 
340
+ async function stopBindingProcessAndWait(binding) {
341
+ if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_GRACEFUL_EXIT_MS)) return;
342
+ signalProcessTree(binding, 'SIGTERM');
343
+ if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_TERMINATE_MS)) return;
344
+ signalProcessTree(binding, 'SIGKILL');
345
+ if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_KILL_MS)) return;
346
+ throw new Error(`ACP identity adapter process tree ${binding.child?.pid || ''} did not exit`);
347
+ }
348
+
349
+ async function describeAcpProcessGroup(pid) {
350
+ const processId = Number(pid);
351
+ if (!Number.isSafeInteger(processId) || processId <= 0 || process.platform === 'win32') {
352
+ return null;
353
+ }
354
+ let stdout;
355
+ try {
356
+ ({ stdout } = await execFileAsync(
357
+ '/bin/ps',
358
+ ['-p', String(processId), '-o', 'pid=', '-o', 'pgid=', '-o', 'lstart='],
359
+ { encoding: 'utf8', timeout: 1_000, maxBuffer: 16_384 },
360
+ ));
361
+ } catch (error) {
362
+ if (error?.code === 1 || error?.code === 'ESRCH') return null;
363
+ throw error;
364
+ }
365
+ const match = String(stdout || '').trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
366
+ if (!match || Number(match[1]) !== processId) {
367
+ throw new Error(`Could not read ACP process identity for pid ${processId}`);
368
+ }
369
+ if (Number(match[2]) !== processId) {
370
+ throw new Error(`ACP process ${processId} did not become its own process-group leader`);
371
+ }
372
+ return {
373
+ pid: processId,
374
+ processGroupId: Number(match[2]),
375
+ startedAt: match[3].trim(),
376
+ };
377
+ }
378
+
379
+ async function stopPersistedAcpProcessGroup(identity) {
380
+ const expected = identity && typeof identity === 'object' ? identity : null;
381
+ if (
382
+ !expected
383
+ || !Number.isSafeInteger(Number(expected.pid))
384
+ || !Number.isSafeInteger(Number(expected.processGroupId))
385
+ || !String(expected.startedAt || '').trim()
386
+ ) {
387
+ return { stopped: false, missingProof: true };
388
+ }
389
+ const processGroupId = Number(expected.processGroupId);
390
+ const groupHasExited = () => {
391
+ try {
392
+ process.kill(-processGroupId, 0);
393
+ return false;
394
+ } catch (error) {
395
+ return error?.code === 'ESRCH';
396
+ }
397
+ };
398
+ const current = await describeAcpProcessGroup(Number(expected.pid));
399
+ if (!current && groupHasExited()) return { stopped: true, alreadyExited: true };
400
+ if (
401
+ current
402
+ && (
403
+ current.processGroupId !== processGroupId
404
+ || current.startedAt !== String(expected.startedAt)
405
+ )
406
+ ) {
407
+ return { stopped: false, identityMismatch: true };
408
+ }
409
+ const waitForExit = async timeoutMs => {
410
+ const deadline = Date.now() + timeoutMs;
411
+ while (Date.now() <= deadline) {
412
+ if (groupHasExited()) return true;
413
+ await new Promise(resolve => setTimeout(resolve, 25));
414
+ }
415
+ return groupHasExited();
416
+ };
417
+ const signal = value => {
418
+ try {
419
+ process.kill(-processGroupId, value);
420
+ } catch (error) {
421
+ if (error?.code !== 'ESRCH') throw error;
422
+ }
423
+ };
424
+
425
+ signal('SIGTERM');
426
+ if (await waitForExit(IDENTITY_ADAPTER_TERMINATE_MS)) return { stopped: true };
427
+ signal('SIGKILL');
428
+ if (await waitForExit(IDENTITY_ADAPTER_KILL_MS)) return { stopped: true };
429
+ return { stopped: false, timedOut: true };
430
+ }
431
+
339
432
  async function deleteProviderSessionIdentity(options = {}) {
340
433
  const provider = String(options.provider || '').trim().toLowerCase();
341
434
  const sessionId = String(options.sessionId || '').trim();
@@ -386,22 +479,66 @@ class AcpRuntime extends EventEmitter {
386
479
  this.historyReplayQuietMs = options.historyReplayQuietMs ?? DEFAULT_HISTORY_REPLAY_QUIET_MS;
387
480
  this.historyReplayMaxWaitMs = options.historyReplayMaxWaitMs ?? DEFAULT_HISTORY_REPLAY_MAX_WAIT_MS;
388
481
  this.deleteProviderSessionIdentity = options.deleteProviderSessionIdentity || deleteProviderSessionIdentity;
482
+ this.describeProcessGroup = options.describeAcpProcessGroup || describeAcpProcessGroup;
389
483
  this.checkpointStore = options.checkpointStore
390
484
  || (options.configDir ? new AcpCheckpointStore(options.configDir, options.checkpointOptions) : null);
391
485
  this.bindings = new Map();
392
- this.permissionSequence = 0;
393
- this.elicitationSequence = 0;
486
+ this.disposing = false;
487
+ this.disposePromise = null;
488
+ this.disposed = false;
394
489
  this.clientFileSystem = options.clientFileSystem || new AcpClientFileSystem();
395
490
  this.clientTerminals = options.clientTerminals || new AcpClientTerminalManager({ spawn: options.terminalSpawn });
396
491
  }
397
492
 
398
493
  async prepareAgent(options = {}) {
494
+ if (this.disposing || this.disposed) {
495
+ throw new Error('ACP runtime is shutting down');
496
+ }
399
497
  const agentId = String(options.agentId || '');
400
498
  if (!agentId) throw new Error('ACP Agent id is required');
401
499
  if (this.bindings.has(agentId)) throw new Error('ACP Agent is already registered');
402
500
  const provider = String(options.provider || '').trim().toLowerCase();
403
501
  const launch = this.resolveLaunch(provider, options);
404
502
  const cwd = path.resolve(options.cwd || process.cwd());
503
+ const requestedSessionId = String(options.sessionId || '').trim();
504
+ const forkSourceSessionId = String(options.forkSourceSessionId || '').trim();
505
+ const forkSourceCheckpoint = options.forkSourceCheckpoint || null;
506
+ const forkSourceSessionCheckpoint = forkSourceCheckpoint?.version === 2
507
+ ? forkSourceCheckpoint.sessionState
508
+ : forkSourceCheckpoint;
509
+ const revisionBase = Number.isFinite(Number(options.revisionBase))
510
+ ? Math.max(0, Math.floor(Number(options.revisionBase)))
511
+ : 0;
512
+ let forkSourceCheckpointState = null;
513
+ if (forkSourceSessionId) {
514
+ if (requestedSessionId) {
515
+ throw new Error('ACP fork startup cannot also load a target session');
516
+ }
517
+ if (!isSafeProviderSessionId(forkSourceSessionId)) {
518
+ throw new Error('ACP fork startup requires a safe exact source session id');
519
+ }
520
+ if (!forkSourceCheckpoint) {
521
+ throw new Error('ACP fork startup requires an exact source checkpoint');
522
+ }
523
+ const checkpointCwd = String(forkSourceSessionCheckpoint?.cwd || '').trim();
524
+ if (
525
+ String(forkSourceSessionCheckpoint?.provider || '') !== provider
526
+ || String(forkSourceSessionCheckpoint?.sessionId || '') !== forkSourceSessionId
527
+ || !checkpointCwd
528
+ || path.resolve(checkpointCwd) !== cwd
529
+ ) {
530
+ throw new Error('ACP fork source checkpoint does not match the requested source');
531
+ }
532
+ forkSourceCheckpointState = AcpSessionState.fromCheckpoint(forkSourceSessionCheckpoint, {
533
+ provider,
534
+ sessionId: forkSourceSessionId,
535
+ cwd,
536
+ maxUpdates: this.maxUpdates,
537
+ });
538
+ if (!forkSourceCheckpointState) {
539
+ throw new Error('ACP fork source checkpoint is invalid');
540
+ }
541
+ }
405
542
  const sessionRequestOptions = acpSessionRequestOptions(options, cwd);
406
543
  const binding = {
407
544
  agentId,
@@ -413,7 +550,7 @@ class AcpRuntime extends EventEmitter {
413
550
  launch,
414
551
  restartOptions: { ...options, agentId, provider },
415
552
  approvalMode: options.approvalMode || 'approve',
416
- ownsProcessGroup: options.identityOnly === true && process.platform !== 'win32',
553
+ ownsProcessGroup: process.platform !== 'win32',
417
554
  child: null,
418
555
  connection: null,
419
556
  initializeResponse: null,
@@ -430,23 +567,43 @@ class AcpRuntime extends EventEmitter {
430
567
  elicitationResolvers: new Map(),
431
568
  activeElicitations: new Map(),
432
569
  subagentStates: new Map(),
570
+ subagentControls: new Map(),
571
+ nextSubagentGeneration: 1,
433
572
  interactionOrigins: new Map(),
434
- promptActive: false,
573
+ // One binding owns at most one Turn. All Turn controls and its terminal
574
+ // commit are fenced by this object before they may mutate shared state.
575
+ activeTurn: null,
576
+ nextTurnId: 1,
435
577
  supportsSteer: false,
436
- steerTail: null,
437
578
  historyReplayActive: false,
438
579
  sessionState: null,
439
580
  authTerminal: null,
440
581
  patchDecisions: new Map(),
582
+ patchDecisionInFlight: new Map(),
441
583
  checkpointProof: null,
584
+ sessionMutation: null,
585
+ configMutationTail: null,
442
586
  stderr: '',
587
+ exited: false,
443
588
  updatedAt: new Date().toISOString(),
444
589
  };
445
590
  this.bindings.set(agentId, binding);
446
591
  this.emitRuntime(binding);
447
592
 
448
593
  try {
449
- const child = this.spawn(launch.command, launch.args, {
594
+ const gatedLaunch = binding.ownsProcessGroup
595
+ ? {
596
+ command: '/bin/sh',
597
+ args: [
598
+ '-c',
599
+ 'IFS= read -r farming_acp_start || exit 0; exec "$@"',
600
+ 'farming-acp-start-gate',
601
+ launch.command,
602
+ ...launch.args,
603
+ ],
604
+ }
605
+ : launch;
606
+ const child = this.spawn(gatedLaunch.command, gatedLaunch.args, {
450
607
  cwd: binding.cwd,
451
608
  env: binding.env,
452
609
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -463,13 +620,30 @@ class AcpRuntime extends EventEmitter {
463
620
  this.handleExit(binding, code === 0 ? null : new Error(detail));
464
621
  }
465
622
  });
623
+ if (binding.ownsProcessGroup && typeof options.onProcessStarted === 'function') {
624
+ const processIdentity = await this.describeProcessGroup(child.pid);
625
+ if (!processIdentity) {
626
+ throw new Error(`ACP process ${child.pid || ''} exited before its identity was persisted`);
627
+ }
628
+ await options.onProcessStarted(processIdentity);
629
+ }
630
+ if (binding.ownsProcessGroup) {
631
+ await new Promise((resolve, reject) => {
632
+ child.stdin.write('start\n', error => {
633
+ if (error) reject(error);
634
+ else resolve();
635
+ });
636
+ });
637
+ }
466
638
 
467
639
  const connection = this.createConnection
468
640
  ? await this.createConnection(this.clientHandlers(binding), child, binding)
469
641
  : await this.officialConnection(this.clientHandlers(binding), child);
470
642
  binding.connection = connection;
643
+ this.requireOpenBinding(binding);
471
644
  connection.closed.catch(error => this.handleExit(binding, error));
472
645
  const sdk = await loadAcpSdk();
646
+ this.requireOpenBinding(binding);
473
647
  binding.initializeResponse = await withTimeout(connection.initialize({
474
648
  protocolVersion: sdk.PROTOCOL_VERSION,
475
649
  clientCapabilities: {
@@ -483,20 +657,99 @@ class AcpRuntime extends EventEmitter {
483
657
  },
484
658
  clientInfo: { name: 'farming', title: 'Farming', version: packageJson.version || '0.0.0' },
485
659
  }), this.initializeTimeoutMs, 'ACP initialize');
660
+ this.requireOpenBinding(binding);
486
661
  if (binding.initializeResponse.protocolVersion !== sdk.PROTOCOL_VERSION) {
487
662
  throw new Error(`ACP protocol version mismatch: Agent selected ${binding.initializeResponse.protocolVersion}, Farming supports ${sdk.PROTOCOL_VERSION}`);
488
663
  }
489
664
  binding.supportsSteer = binding.provider === 'codex'
490
665
  && supportsCodexSteer(binding.initializeResponse.agentCapabilities);
491
666
 
492
- const requestedSessionId = String(options.sessionId || '').trim();
493
- const revisionBase = Number.isFinite(Number(options.revisionBase))
494
- ? Math.max(0, Math.floor(Number(options.revisionBase)))
495
- : 0;
496
667
  const sessionRequest = { sessionId: requestedSessionId, ...binding.sessionRequestOptions };
497
668
  let sessionResponse;
498
669
  let historyMode = 'new';
499
- if (requestedSessionId) {
670
+ if (forkSourceSessionId) {
671
+ const capabilities = binding.initializeResponse.agentCapabilities || {};
672
+ if (!capabilities.loadSession || !capabilities.sessionCapabilities?.fork) {
673
+ throw new Error(`${provider} ACP Agent cannot create an owned fork`);
674
+ }
675
+ if (!capabilities.sessionCapabilities?.close) {
676
+ throw new Error(`${provider} ACP Agent cannot release the loaded fork source`);
677
+ }
678
+ binding.sessionId = forkSourceSessionId;
679
+ binding.sessionState = new AcpSessionState({
680
+ provider,
681
+ sessionId: forkSourceSessionId,
682
+ cwd: binding.cwd,
683
+ maxUpdates: this.maxUpdates,
684
+ revisionBase,
685
+ });
686
+ binding.historyReplayActive = true;
687
+ let loadedSource;
688
+ try {
689
+ loadedSource = await withTimeout(
690
+ connection.loadSession({ sessionId: forkSourceSessionId, ...binding.sessionRequestOptions }),
691
+ this.sessionSetupTimeoutMs,
692
+ 'ACP fork source session/load',
693
+ );
694
+ this.requireOpenBinding(binding);
695
+ if (provider === 'qoder') await this.waitForHistoryReplay(binding);
696
+ this.requireOpenBinding(binding);
697
+ } finally {
698
+ binding.historyReplayActive = false;
699
+ }
700
+ binding.sessionState.finishHistoryReplay();
701
+ const forked = await withTimeout(
702
+ connection.unstable_forkSession({
703
+ sessionId: forkSourceSessionId,
704
+ ...binding.sessionRequestOptions,
705
+ }),
706
+ this.sessionSetupTimeoutMs,
707
+ 'ACP owned session/fork',
708
+ );
709
+ const forkedSessionId = String(forked?.sessionId || '').trim();
710
+ if (
711
+ !isSafeProviderSessionId(forkedSessionId)
712
+ || forkedSessionId === forkSourceSessionId
713
+ ) {
714
+ throw new Error('ACP owned session/fork returned an invalid new session id');
715
+ }
716
+ if (typeof options.onForkSessionCreated === 'function') {
717
+ await options.onForkSessionCreated(forkedSessionId);
718
+ }
719
+ this.requireOpenBinding(binding);
720
+ await withTimeout(
721
+ connection.closeSession({ sessionId: forkSourceSessionId }),
722
+ this.requestTimeoutMs,
723
+ 'ACP fork source session/close',
724
+ );
725
+ this.requireOpenBinding(binding);
726
+ const forkUpdates = binding.subagentStates.get(forkedSessionId);
727
+ const restoredForkSource = this.restoreBindingCheckpoint(binding, forkSourceCheckpoint, {
728
+ sessionId: forkedSessionId,
729
+ });
730
+ if (!restoredForkSource) {
731
+ throw new Error('ACP fork source checkpoint could not be restored');
732
+ }
733
+ binding.sessionId = forkedSessionId;
734
+ binding.sessionState = restoredForkSource.sessionState;
735
+ binding.subagentStates = restoredForkSource.subagentStates;
736
+ this.resetSubagentControls(binding);
737
+ binding.patchDecisions = restoredForkSource.patchDecisions;
738
+ delete binding.restartOptions.forkSourceSessionId;
739
+ delete binding.restartOptions.forkSourceCheckpoint;
740
+ delete binding.restartOptions.onForkSessionCreated;
741
+ if (forkUpdates?.updates?.some(item => item?.update?.sessionUpdate === 'available_commands_update')) {
742
+ binding.sessionState.availableCommands = clone(forkUpdates.availableCommands);
743
+ }
744
+ sessionResponse = {
745
+ ...loadedSource,
746
+ ...forked,
747
+ sessionId: forkedSessionId,
748
+ modes: forked?.modes || loadedSource?.modes,
749
+ configOptions: forked?.configOptions || loadedSource?.configOptions,
750
+ };
751
+ historyMode = 'fork';
752
+ } else if (requestedSessionId) {
500
753
  const capabilities = binding.initializeResponse.agentCapabilities || {};
501
754
  let opened = false;
502
755
  let restoredCheckpointState = null;
@@ -507,6 +760,7 @@ class AcpRuntime extends EventEmitter {
507
760
  this.checkpointIdentity(binding, requestedSessionId),
508
761
  { allowDirty: true },
509
762
  );
763
+ this.requireOpenBinding(binding);
510
764
  restoredCheckpoint = this.restoreBindingCheckpoint(binding, saved?.state, {
511
765
  sessionId: requestedSessionId,
512
766
  });
@@ -516,10 +770,12 @@ class AcpRuntime extends EventEmitter {
516
770
  const providerStateMatches = restoredCheckpoint?.complete === true && saved?.exact === true
517
771
  ? await this.checkpointMatchesProviderSession(connection, capabilities, sessionRequest, saved)
518
772
  : false;
773
+ this.requireOpenBinding(binding);
519
774
  if (providerStateMatches) {
520
775
  binding.sessionId = requestedSessionId;
521
776
  binding.sessionState = restoredCheckpointState;
522
777
  binding.subagentStates = restoredCheckpoint.subagentStates;
778
+ this.resetSubagentControls(binding);
523
779
  binding.patchDecisions = restoredCheckpoint.patchDecisions;
524
780
  binding.checkpointProof = restoredCheckpoint.providerProof;
525
781
  try {
@@ -528,9 +784,11 @@ class AcpRuntime extends EventEmitter {
528
784
  this.sessionSetupTimeoutMs,
529
785
  'ACP session/resume'
530
786
  );
787
+ this.requireOpenBinding(binding);
531
788
  historyMode = 'checkpoint';
532
789
  opened = true;
533
790
  } catch (error) {
791
+ this.requireOpenBinding(binding);
534
792
  if (!capabilities.loadSession) throw error;
535
793
  console.warn(`ACP checkpoint resume failed for ${provider}; replaying history:`, error && (error.message || error));
536
794
  }
@@ -554,7 +812,9 @@ class AcpRuntime extends EventEmitter {
554
812
  this.sessionSetupTimeoutMs,
555
813
  'ACP session/load'
556
814
  );
815
+ this.requireOpenBinding(binding);
557
816
  if (provider === 'qoder') await this.waitForHistoryReplay(binding);
817
+ this.requireOpenBinding(binding);
558
818
  } finally {
559
819
  binding.historyReplayActive = false;
560
820
  }
@@ -568,8 +828,11 @@ class AcpRuntime extends EventEmitter {
568
828
  } catch {
569
829
  // Local history images can still be restored from their adapter paths.
570
830
  }
831
+ this.requireOpenBinding(binding);
571
832
  await binding.sessionState.hydrateCodexHistoryAttachments({ imageDataByPath });
833
+ this.requireOpenBinding(binding);
572
834
  }
835
+ this.restorePatchDecisions(binding, restoredCheckpoint?.patchDecisions);
573
836
  historyMode = 'load';
574
837
  opened = true;
575
838
  } else if (!opened && capabilities.sessionCapabilities?.resume) {
@@ -586,6 +849,7 @@ class AcpRuntime extends EventEmitter {
586
849
  this.sessionSetupTimeoutMs,
587
850
  'ACP session/resume'
588
851
  );
852
+ this.requireOpenBinding(binding);
589
853
  historyMode = 'resume';
590
854
  opened = true;
591
855
  } else {
@@ -597,6 +861,7 @@ class AcpRuntime extends EventEmitter {
597
861
  this.sessionSetupTimeoutMs,
598
862
  'ACP session/new'
599
863
  );
864
+ this.requireOpenBinding(binding);
600
865
  const returnedSessionId = String(sessionResponse.sessionId || '').trim();
601
866
  if (!isSafeProviderSessionId(returnedSessionId)) {
602
867
  binding.untrustedSessionId = returnedSessionId;
@@ -605,6 +870,7 @@ class AcpRuntime extends EventEmitter {
605
870
  binding.sessionId = returnedSessionId;
606
871
  binding.sessionState = new AcpSessionState({ provider, sessionId: binding.sessionId, cwd: binding.cwd, maxUpdates: this.maxUpdates });
607
872
  }
873
+ this.requireOpenBinding(binding);
608
874
  binding.modes = sessionResponse?.modes || null;
609
875
  binding.configOptions = sessionResponse?.configOptions || [];
610
876
  binding.sessionState.currentModeId = String(binding.modes?.currentModeId || '');
@@ -619,6 +885,7 @@ class AcpRuntime extends EventEmitter {
619
885
  await this.applySessionConfigOption(binding, fastOption.id, fastEnabled, { emit: false });
620
886
  }
621
887
  }
888
+ this.requireOpenBinding(binding);
622
889
  binding.state = 'idle';
623
890
  binding.updatedAt = new Date().toISOString();
624
891
  this.scheduleCheckpoint(binding, { exact: true });
@@ -670,7 +937,45 @@ class AcpRuntime extends EventEmitter {
670
937
  attachProviderSessionIdentity(runtimeError, identity);
671
938
  }
672
939
  } else {
673
- this.unregisterAgent(agentId);
940
+ let runtimeCleanupVerified = false;
941
+ try {
942
+ if (this.isCurrentBinding(binding)) {
943
+ runtimeCleanupVerified = await this.unregisterAgentAndWait(agentId, binding);
944
+ } else {
945
+ try {
946
+ binding.connection?.close();
947
+ } catch {
948
+ // Process-tree cleanup below remains authoritative.
949
+ }
950
+ await stopBindingProcessAndWait(binding);
951
+ runtimeCleanupVerified = true;
952
+ }
953
+ } catch (cleanupError) {
954
+ Object.defineProperty(runtimeError, 'adapterCleanupError', {
955
+ value: cleanupError,
956
+ enumerable: false,
957
+ configurable: true,
958
+ });
959
+ }
960
+ Object.defineProperties(runtimeError, {
961
+ runtimeCleanupAttempted: {
962
+ value: true,
963
+ enumerable: false,
964
+ configurable: true,
965
+ },
966
+ runtimeCleanupVerified: {
967
+ value: runtimeCleanupVerified === true,
968
+ enumerable: false,
969
+ configurable: true,
970
+ },
971
+ });
972
+ if (!runtimeCleanupVerified && !this.isCurrentBinding(binding)) {
973
+ try {
974
+ binding.connection?.close();
975
+ } catch {
976
+ // The detached binding remains only as an error cleanup handle.
977
+ }
978
+ }
674
979
  }
675
980
  throw runtimeError;
676
981
  }
@@ -738,6 +1043,199 @@ class AcpRuntime extends EventEmitter {
738
1043
  return result;
739
1044
  }
740
1045
 
1046
+ isCurrentBinding(binding) {
1047
+ return Boolean(binding) && this.bindings.get(binding.agentId) === binding;
1048
+ }
1049
+
1050
+ requireCurrentBinding(binding) {
1051
+ if (!this.isCurrentBinding(binding)) {
1052
+ throw new Error('ACP Agent binding is no longer active');
1053
+ }
1054
+ return binding;
1055
+ }
1056
+
1057
+ isOpenBinding(binding) {
1058
+ return this.isCurrentBinding(binding) && binding.exited !== true;
1059
+ }
1060
+
1061
+ requireOpenBinding(binding) {
1062
+ if (this.disposing || this.disposed) throw new Error('ACP runtime is shutting down');
1063
+ this.requireCurrentBinding(binding);
1064
+ if (binding.exited) throw new Error('ACP Agent connection is closed');
1065
+ return binding;
1066
+ }
1067
+
1068
+ isCurrentTurn(binding, turn) {
1069
+ return this.isOpenBinding(binding) && binding.activeTurn === turn;
1070
+ }
1071
+
1072
+ requireCurrentTurn(binding, turn, phases = null) {
1073
+ this.requireOpenBinding(binding);
1074
+ if (
1075
+ binding.activeTurn !== turn
1076
+ || (Array.isArray(phases) && !phases.includes(turn?.phase))
1077
+ ) {
1078
+ throw new Error('No active Codex turn to steer');
1079
+ }
1080
+ return turn;
1081
+ }
1082
+
1083
+ beginTurn(binding) {
1084
+ this.requireOpenBinding(binding);
1085
+ if (binding.activeTurn) throw new Error(`ACP Agent is not ready (${binding.state})`);
1086
+ let resolveCompletion;
1087
+ const completion = new Promise(resolve => {
1088
+ resolveCompletion = resolve;
1089
+ });
1090
+ const turn = {
1091
+ id: binding.nextTurnId++,
1092
+ phase: 'admitting',
1093
+ previousState: binding.state,
1094
+ providerSettled: false,
1095
+ controlTail: Promise.resolve(),
1096
+ cancelPromise: null,
1097
+ completion,
1098
+ resolveCompletion,
1099
+ };
1100
+ binding.activeTurn = turn;
1101
+ return turn;
1102
+ }
1103
+
1104
+ finishTurn(binding, turn, result) {
1105
+ if (binding.activeTurn !== turn) return false;
1106
+ binding.activeTurn = null;
1107
+ turn.phase = 'completed';
1108
+ turn.resolveCompletion(result);
1109
+ return true;
1110
+ }
1111
+
1112
+ cancelAdmittingTurn(binding, turn) {
1113
+ if (binding.activeTurn !== turn || turn.phase !== 'admitting') return false;
1114
+ binding.activeTurn = null;
1115
+ turn.phase = 'cancelled';
1116
+ binding.state = turn.previousState || 'idle';
1117
+ turn.resolveCompletion({ status: 'cancelled-before-submission' });
1118
+ this.emitRuntime(binding);
1119
+ return true;
1120
+ }
1121
+
1122
+ async enqueueTurnControl(turn, operation) {
1123
+ const previous = turn.controlTail || Promise.resolve();
1124
+ const pending = previous.catch(() => {}).then(operation);
1125
+ turn.controlTail = pending;
1126
+ try {
1127
+ return await pending;
1128
+ } finally {
1129
+ if (turn.controlTail === pending) turn.controlTail = Promise.resolve();
1130
+ }
1131
+ }
1132
+
1133
+ ensureSubagentControl(binding, sessionId) {
1134
+ const id = String(sessionId || '');
1135
+ if (!id) return null;
1136
+ let control = binding.subagentControls.get(id);
1137
+ if (!control) {
1138
+ control = {
1139
+ sessionId: id,
1140
+ generation: binding.nextSubagentGeneration++,
1141
+ phase: 'active',
1142
+ error: '',
1143
+ cancelPromise: null,
1144
+ };
1145
+ binding.subagentControls.set(id, control);
1146
+ }
1147
+ return control;
1148
+ }
1149
+
1150
+ resetSubagentControls(binding) {
1151
+ binding.subagentControls.clear();
1152
+ for (const sessionId of binding.subagentStates.keys()) {
1153
+ const control = this.ensureSubagentControl(binding, sessionId);
1154
+ const parentTool = binding.sessionState?.entries.find(entry => (
1155
+ entry?.type === 'tool'
1156
+ && String(entry?._meta?.subagent_session_info?.session_id || '') === sessionId
1157
+ ));
1158
+ if (parentTool) {
1159
+ this.updateSubagentControlFromParent(binding, parentTool);
1160
+ } else {
1161
+ // Restored evidence without a parent Tool cannot prove that a child
1162
+ // from the previous adapter process is still active.
1163
+ control.phase = 'completed';
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ activeSubagentSessionIds(binding) {
1169
+ return [...binding.subagentStates.keys()].filter(sessionId => {
1170
+ const control = this.ensureSubagentControl(binding, sessionId);
1171
+ if (control?.cancelPromise || ['active', 'cancelling'].includes(control?.phase)) return true;
1172
+ const hasInteraction = [...binding.pendingPermissions.values(), ...binding.pendingElicitations.values()]
1173
+ .some(request => String(request?.sessionId || '') === sessionId);
1174
+ if (hasInteraction) return true;
1175
+ const parentTool = binding.sessionState?.entries.find(entry => (
1176
+ entry?.type === 'tool'
1177
+ && String(entry?._meta?.subagent_session_info?.session_id || '') === sessionId
1178
+ ));
1179
+ return ['pending', 'in_progress', 'in-progress', 'running']
1180
+ .includes(String(parentTool?.status || '').toLowerCase());
1181
+ });
1182
+ }
1183
+
1184
+ updateSubagentControlFromParent(binding, update) {
1185
+ const sessionId = String(update?._meta?.subagent_session_info?.session_id || '');
1186
+ if (!sessionId || !binding.subagentStates.has(sessionId)) return;
1187
+ const control = this.ensureSubagentControl(binding, sessionId);
1188
+ const status = String(update?.status || '').toLowerCase();
1189
+ if (['cancelled', 'canceled'].includes(status)) {
1190
+ control.phase = 'cancelled';
1191
+ control.error = '';
1192
+ } else if (['completed', 'complete'].includes(status)) {
1193
+ control.phase = 'completed';
1194
+ control.error = '';
1195
+ } else if (['failed', 'error'].includes(status)) {
1196
+ control.phase = 'error';
1197
+ control.error = String(update?.title || 'Subagent failed');
1198
+ } else if (
1199
+ ['pending', 'in_progress', 'in-progress', 'running'].includes(status)
1200
+ && !['cancelling', 'cancelled', 'completed', 'error'].includes(control.phase)
1201
+ ) {
1202
+ control.phase = 'active';
1203
+ control.error = '';
1204
+ }
1205
+ }
1206
+
1207
+ beginSessionMutation(binding, action) {
1208
+ this.requireOpenBinding(binding);
1209
+ if (
1210
+ binding.sessionMutation
1211
+ || binding.configMutationTail
1212
+ || binding.patchDecisionInFlight.size > 0
1213
+ || binding.activeTurn
1214
+ || !['idle', 'error'].includes(binding.state)
1215
+ ) {
1216
+ throw new Error(`ACP Agent is not ready for ${action} (${binding.state})`);
1217
+ }
1218
+ const mutation = { action };
1219
+ binding.sessionMutation = mutation;
1220
+ return mutation;
1221
+ }
1222
+
1223
+ endSessionMutation(binding, mutation) {
1224
+ if (binding.sessionMutation === mutation) binding.sessionMutation = null;
1225
+ }
1226
+
1227
+ requireConfigMutationReady(binding) {
1228
+ this.requireOpenBinding(binding);
1229
+ if (
1230
+ binding.sessionMutation
1231
+ || binding.patchDecisionInFlight.size > 0
1232
+ || binding.activeTurn
1233
+ || !['idle', 'error'].includes(binding.state)
1234
+ ) {
1235
+ throw new Error(`ACP Agent is not ready for configuration changes (${binding.state})`);
1236
+ }
1237
+ }
1238
+
741
1239
  checkpointIdentity(binding, sessionId = binding?.sessionId) {
742
1240
  if (!binding || !sessionId) return null;
743
1241
  return {
@@ -749,6 +1247,7 @@ class AcpRuntime extends EventEmitter {
749
1247
  }
750
1248
 
751
1249
  scheduleCheckpoint(binding, _options = {}) {
1250
+ if (!this.isOpenBinding(binding)) return;
752
1251
  const identity = this.checkpointIdentity(binding);
753
1252
  if (!this.checkpointStore || !identity || !binding.sessionState) return;
754
1253
  // ACP currently has no conditional resume or provider-owned revision
@@ -759,15 +1258,19 @@ class AcpRuntime extends EventEmitter {
759
1258
  }
760
1259
 
761
1260
  async markCheckpointDirty(binding) {
1261
+ this.requireOpenBinding(binding);
762
1262
  const identity = this.checkpointIdentity(binding);
763
1263
  if (!this.checkpointStore || !identity) return;
764
1264
  await this.checkpointStore.markDirty(identity);
1265
+ this.requireOpenBinding(binding);
765
1266
  }
766
1267
 
767
1268
  async writeCheckpoint(binding, _options = {}) {
1269
+ this.requireOpenBinding(binding);
768
1270
  const identity = this.checkpointIdentity(binding);
769
1271
  if (!this.checkpointStore || !identity || !binding.sessionState) return;
770
1272
  await this.checkpointStore.write(identity, this.bindingCheckpoint(binding), { exact: false });
1273
+ this.requireOpenBinding(binding);
771
1274
  }
772
1275
 
773
1276
  bindingCheckpoint(binding) {
@@ -830,6 +1333,29 @@ class AcpRuntime extends EventEmitter {
830
1333
  };
831
1334
  }
832
1335
 
1336
+ restorePatchDecisions(binding, decisions) {
1337
+ if (!(decisions instanceof Map) || !binding.sessionState) return;
1338
+ for (const [key, decision] of decisions) {
1339
+ const separator = key.indexOf('\n');
1340
+ if (separator <= 0) continue;
1341
+ const toolCallId = key.slice(0, separator);
1342
+ const requestedPath = key.slice(separator + 1);
1343
+ const entry = binding.sessionState.toolEntries.get(toolCallId);
1344
+ if (!entry || binding.sessionState.isInternalEntry(entry)) continue;
1345
+ try {
1346
+ patchBlock(entry, binding.cwd, requestedPath);
1347
+ } catch {
1348
+ continue;
1349
+ }
1350
+ binding.patchDecisions.set(key, decision);
1351
+ entry._meta = { ...(entry._meta || {}) };
1352
+ entry._meta.farming_patch_decisions = {
1353
+ ...(entry._meta.farming_patch_decisions || {}),
1354
+ [requestedPath]: decision,
1355
+ };
1356
+ }
1357
+ }
1358
+
833
1359
  async checkpointMatchesProviderSession(connection, capabilities, request, saved) {
834
1360
  const proof = saved?.state?.providerProof;
835
1361
  if (
@@ -874,6 +1400,7 @@ class AcpRuntime extends EventEmitter {
874
1400
  let lastChangedAt = startedAt;
875
1401
  while (Date.now() - startedAt < this.historyReplayMaxWaitMs) {
876
1402
  await new Promise(resolve => setTimeout(resolve, 50));
1403
+ if (!this.isOpenBinding(binding)) return;
877
1404
  const now = Date.now();
878
1405
  const updateCount = binding.sessionState?.updates.length || 0;
879
1406
  if (updateCount !== lastUpdateCount) {
@@ -888,8 +1415,17 @@ class AcpRuntime extends EventEmitter {
888
1415
  }
889
1416
 
890
1417
  clientHandlers(binding) {
1418
+ const openClientRequest = handler => async request => {
1419
+ this.requireOpenBinding(binding);
1420
+ if (binding.state === 'closed') throw new Error('ACP session is closed');
1421
+ const response = await handler(request);
1422
+ this.requireOpenBinding(binding);
1423
+ if (binding.state === 'closed') throw new Error('ACP session is closed');
1424
+ return response;
1425
+ };
891
1426
  return {
892
1427
  sessionUpdate: notification => {
1428
+ if (!this.isOpenBinding(binding) || binding.state === 'closed') return;
893
1429
  const notificationSessionId = String(notification?.sessionId || '');
894
1430
  const isPrimarySession = !binding.sessionId || !notificationSessionId || notificationSessionId === binding.sessionId;
895
1431
  let targetState = isPrimarySession ? binding.sessionState : binding.subagentStates.get(notificationSessionId);
@@ -902,8 +1438,17 @@ class AcpRuntime extends EventEmitter {
902
1438
  });
903
1439
  binding.subagentStates.set(notificationSessionId, targetState);
904
1440
  }
1441
+ if (!isPrimarySession && targetState) {
1442
+ this.ensureSubagentControl(binding, notificationSessionId);
1443
+ const parentTool = binding.sessionState?.entries.find(entry => (
1444
+ entry?.type === 'tool'
1445
+ && String(entry?._meta?.subagent_session_info?.session_id || '') === notificationSessionId
1446
+ ));
1447
+ if (parentTool) this.updateSubagentControlFromParent(binding, parentTool);
1448
+ }
905
1449
  if (targetState?.apply(notification)) {
906
1450
  const update = notification?.update;
1451
+ if (isPrimarySession) this.updateSubagentControlFromParent(binding, update);
907
1452
  if (isPrimarySession && update?.sessionUpdate === 'current_mode_update' && binding.modes) {
908
1453
  binding.modes = { ...binding.modes, currentModeId: String(update.currentModeId || '') };
909
1454
  }
@@ -918,7 +1463,7 @@ class AcpRuntime extends EventEmitter {
918
1463
  if (parentTool) binding.sessionState.touchEntry(parentTool);
919
1464
  }
920
1465
  binding.updatedAt = new Date().toISOString();
921
- if (isPrimarySession && !binding.historyReplayActive && !binding.promptActive) {
1466
+ if (isPrimarySession && !binding.historyReplayActive && !binding.activeTurn) {
922
1467
  this.scheduleCheckpoint(binding, { exact: true });
923
1468
  }
924
1469
  // A loaded history can contain hundreds of ordered updates. Applying
@@ -929,22 +1474,35 @@ class AcpRuntime extends EventEmitter {
929
1474
  }
930
1475
  },
931
1476
  requestPermission: request => this.requestPermission(binding, request),
932
- readTextFile: request => this.clientFileSystem.readTextFile(binding, request),
933
- writeTextFile: request => this.clientFileSystem.writeTextFile(binding, request),
934
- createTerminal: request => this.clientTerminals.create(binding, request),
935
- terminalOutput: request => this.clientTerminals.output(binding, request),
936
- waitForTerminalExit: request => this.clientTerminals.waitForExit(binding, request),
937
- killTerminal: request => this.clientTerminals.kill(binding, request),
938
- releaseTerminal: request => this.clientTerminals.release(binding, request),
1477
+ readTextFile: openClientRequest(request => this.clientFileSystem.readTextFile(binding, request)),
1478
+ writeTextFile: openClientRequest(request => this.clientFileSystem.writeTextFile(binding, request)),
1479
+ createTerminal: openClientRequest(request => this.clientTerminals.create(binding, request)),
1480
+ terminalOutput: openClientRequest(request => this.clientTerminals.output(binding, request)),
1481
+ waitForTerminalExit: openClientRequest(request => this.clientTerminals.waitForExit(binding, request)),
1482
+ killTerminal: openClientRequest(request => this.clientTerminals.kill(binding, request)),
1483
+ releaseTerminal: openClientRequest(request => this.clientTerminals.release(binding, request)),
939
1484
  unstable_createElicitation: request => this.requestElicitation(binding, request),
940
1485
  unstable_completeElicitation: notification => this.completeElicitation(binding, notification),
941
1486
  };
942
1487
  }
943
1488
 
944
1489
  requestPermission(binding, request) {
1490
+ if (!this.isOpenBinding(binding) || binding.state === 'closed') {
1491
+ return { outcome: { outcome: 'cancelled' } };
1492
+ }
1493
+ const requestSessionId = String(request?.sessionId || '');
1494
+ if (
1495
+ !requestSessionId
1496
+ || (
1497
+ requestSessionId !== binding.sessionId
1498
+ && !binding.subagentStates.has(requestSessionId)
1499
+ )
1500
+ ) {
1501
+ return { outcome: { outcome: 'cancelled' } };
1502
+ }
945
1503
  const automatic = autoPermissionResponse(request, binding.approvalMode);
946
1504
  if (automatic) return automatic;
947
- const requestId = `acp-permission-${++this.permissionSequence}`;
1505
+ const requestId = `acp-permission-${crypto.randomUUID()}`;
948
1506
  binding.interactionOrigins.set(requestId, binding.state);
949
1507
  binding.state = 'waiting-for-permission';
950
1508
  const pending = { ...JSON.parse(JSON.stringify(request)), requestId };
@@ -979,6 +1537,7 @@ class AcpRuntime extends EventEmitter {
979
1537
  }
980
1538
 
981
1539
  requestElicitation(binding, request) {
1540
+ if (!this.isOpenBinding(binding) || binding.state === 'closed') return { action: 'cancel' };
982
1541
  const hasSessionScope = typeof request?.sessionId === 'string' && request.sessionId.length > 0;
983
1542
  const requestSessionId = hasSessionScope ? String(request.sessionId) : '';
984
1543
  const isPrimarySession = hasSessionScope && requestSessionId === binding.sessionId;
@@ -989,7 +1548,7 @@ class AcpRuntime extends EventEmitter {
989
1548
  if (!['form', 'url'].includes(String(request?.mode || ''))) {
990
1549
  return { action: 'cancel' };
991
1550
  }
992
- const requestId = `acp-elicitation-${++this.elicitationSequence}`;
1551
+ const requestId = `acp-elicitation-${crypto.randomUUID()}`;
993
1552
  const cloned = JSON.parse(JSON.stringify(request));
994
1553
  const protocolRequestId = Object.prototype.hasOwnProperty.call(cloned, 'requestId')
995
1554
  ? cloned.requestId
@@ -1033,61 +1592,141 @@ class AcpRuntime extends EventEmitter {
1033
1592
  }
1034
1593
 
1035
1594
  completeElicitation(binding, notification) {
1595
+ if (!this.isOpenBinding(binding) || binding.state === 'closed') return;
1036
1596
  const elicitationId = String(notification?.elicitationId || '');
1037
1597
  if (elicitationId) binding.activeElicitations.delete(elicitationId);
1038
1598
  this.emitRuntime(binding);
1039
1599
  }
1040
1600
 
1041
- async prompt(agentId, prompt) {
1601
+ async submitMessage(agentId, prompt, options = {}) {
1042
1602
  const binding = this.requireBinding(agentId);
1043
- if (!['idle', 'error'].includes(binding.state)) throw new Error(`ACP Agent is not ready (${binding.state})`);
1603
+ while (true) {
1604
+ this.requireOpenBinding(binding);
1605
+ const turn = binding.activeTurn;
1606
+ if (turn?.phase === 'blocked') {
1607
+ throw new Error(`ACP Agent is not ready (${binding.state})`);
1608
+ }
1609
+ if (
1610
+ turn?.phase === 'running'
1611
+ && turn.providerSettled !== true
1612
+ && binding.provider === 'codex'
1613
+ && binding.supportsSteer === true
1614
+ ) {
1615
+ try {
1616
+ const result = await this.steer(agentId, prompt);
1617
+ options.onSubmitted?.();
1618
+ return { steered: true, ...result };
1619
+ } catch (error) {
1620
+ if (!isCodexSteerUnavailableError(error)) throw error;
1621
+ this.requireOpenBinding(binding);
1622
+ }
1623
+ }
1624
+ if (binding.activeTurn === turn && turn) {
1625
+ await turn.completion;
1626
+ continue;
1627
+ }
1628
+ if (binding.activeTurn) continue;
1629
+ return this.prompt(agentId, prompt, options);
1630
+ }
1631
+ }
1632
+
1633
+ async prompt(agentId, prompt, options = {}) {
1634
+ const binding = this.requireBinding(agentId);
1635
+ if (
1636
+ binding.exited
1637
+ || binding.sessionMutation
1638
+ || binding.activeTurn
1639
+ || !['idle', 'error'].includes(binding.state)
1640
+ ) {
1641
+ throw new Error(`ACP Agent is not ready (${binding.state})`);
1642
+ }
1044
1643
  const rawContent = Array.isArray(prompt) ? prompt : [{ type: 'text', text: String(prompt || '') }];
1045
1644
  const content = promptContentForCapabilities(
1046
1645
  rawContent,
1047
1646
  binding.initializeResponse?.agentCapabilities || {},
1048
1647
  );
1049
- await this.markCheckpointDirty(binding);
1648
+ const turn = this.beginTurn(binding);
1649
+ try {
1650
+ const configMutation = binding.configMutationTail;
1651
+ const patchDecisions = [...binding.patchDecisionInFlight.values()].map(item => item.promise);
1652
+ await Promise.allSettled([
1653
+ ...(configMutation ? [configMutation] : []),
1654
+ ...patchDecisions,
1655
+ ]);
1656
+ if (!this.isCurrentTurn(binding, turn) || turn.phase !== 'admitting') {
1657
+ throw new Error('ACP prompt was cancelled before submission');
1658
+ }
1659
+ await this.markCheckpointDirty(binding);
1660
+ if (!this.isCurrentTurn(binding, turn) || turn.phase !== 'admitting') {
1661
+ throw new Error('ACP prompt was cancelled before submission');
1662
+ }
1663
+ } catch (error) {
1664
+ if (this.isCurrentTurn(binding, turn)) {
1665
+ binding.state = turn.previousState;
1666
+ this.finishTurn(binding, turn, { status: 'admission-failed' });
1667
+ }
1668
+ throw error;
1669
+ }
1050
1670
  binding.sessionState.beginPrompt(content);
1051
- binding.promptActive = true;
1671
+ turn.phase = 'running';
1052
1672
  binding.state = 'working';
1053
1673
  binding.error = '';
1054
1674
  binding.stopReason = '';
1055
1675
  this.emitRuntime(binding);
1056
1676
  this.emitSession(binding);
1677
+ let response;
1057
1678
  try {
1058
- const response = await binding.connection.prompt({ sessionId: binding.sessionId, prompt: content });
1679
+ const responsePromise = binding.connection.prompt({ sessionId: binding.sessionId, prompt: content });
1680
+ try {
1681
+ options.onSubmitted?.();
1682
+ } catch {
1683
+ // Submission is already owned by the provider; admission callbacks cannot roll it back.
1684
+ }
1685
+ response = await responsePromise;
1686
+ turn.providerSettled = true;
1687
+ } catch (error) {
1688
+ turn.providerSettled = true;
1689
+ const runtimeError = new Error(acpErrorMessage(error), { cause: error });
1690
+ if (!this.isCurrentTurn(binding, turn)) throw runtimeError;
1691
+ await this.enqueueTurnControl(turn, async () => {
1692
+ if (!this.isCurrentTurn(binding, turn)) return;
1693
+ binding.stopReason = 'error';
1694
+ // JSON-RPC implementations commonly move the actionable provider text
1695
+ // into error.data.details. Classify the normalized message so the
1696
+ // ordered transcript and runtime snapshot cannot disagree.
1697
+ binding.sessionState.recordError(runtimeError.message, acpErrorKind(runtimeError));
1698
+ binding.sessionState.completePrompt('error');
1699
+ binding.state = 'error';
1700
+ binding.error = runtimeError.message;
1701
+ binding.updatedAt = new Date().toISOString();
1702
+ this.finishTurn(binding, turn, { status: 'error', error: runtimeError });
1703
+ this.scheduleCheckpoint(binding, { exact: true });
1704
+ this.emitSession(binding);
1705
+ this.emitRuntime(binding);
1706
+ });
1707
+ throw runtimeError;
1708
+ }
1709
+ return this.enqueueTurnControl(turn, async () => {
1710
+ this.requireCurrentTurn(binding, turn);
1059
1711
  binding.stopReason = String(response?.stopReason || '');
1060
1712
  binding.sessionState.completePrompt(binding.stopReason);
1061
- binding.promptActive = false;
1062
1713
  binding.state = 'idle';
1714
+ binding.error = '';
1063
1715
  binding.updatedAt = new Date().toISOString();
1716
+ this.finishTurn(binding, turn, { status: 'completed', stopReason: binding.stopReason });
1064
1717
  this.scheduleCheckpoint(binding, { exact: true });
1065
1718
  this.emitSession(binding);
1066
1719
  this.emitRuntime(binding);
1067
1720
  return { sessionId: binding.sessionId, stopReason: binding.stopReason };
1068
- } catch (error) {
1069
- const runtimeError = new Error(acpErrorMessage(error), { cause: error });
1070
- binding.stopReason = 'error';
1071
- // JSON-RPC implementations commonly move the actionable provider text
1072
- // into error.data.details. Classify the normalized message so the
1073
- // ordered transcript and runtime snapshot cannot disagree.
1074
- binding.sessionState.recordError(runtimeError.message, acpErrorKind(runtimeError));
1075
- binding.sessionState.completePrompt('error');
1076
- binding.promptActive = false;
1077
- binding.state = 'error';
1078
- binding.error = runtimeError.message;
1079
- this.scheduleCheckpoint(binding, { exact: true });
1080
- this.emitSession(binding);
1081
- this.emitRuntime(binding);
1082
- throw runtimeError;
1083
- }
1721
+ });
1084
1722
  }
1085
1723
 
1086
1724
  canSteer(agentId) {
1087
1725
  const binding = this.bindings.get(agentId);
1088
1726
  return binding?.provider === 'codex'
1089
1727
  && binding.supportsSteer === true
1090
- && binding.promptActive === true
1728
+ && binding.activeTurn?.phase === 'running'
1729
+ && binding.activeTurn?.providerSettled !== true
1091
1730
  && Boolean(binding.sessionId)
1092
1731
  && Boolean(binding.connection);
1093
1732
  }
@@ -1102,10 +1741,15 @@ class AcpRuntime extends EventEmitter {
1102
1741
  rawContent,
1103
1742
  binding.initializeResponse?.agentCapabilities || {},
1104
1743
  );
1744
+ const turn = binding.activeTurn;
1745
+ this.requireCurrentTurn(binding, turn, ['running']);
1105
1746
  const clientMessageId = `farming-steer-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1106
1747
  const operation = async () => {
1107
- if (!binding.promptActive) throw new Error('No active Codex turn to steer');
1748
+ this.requireCurrentTurn(binding, turn, ['running']);
1749
+ if (turn.providerSettled) throw new Error('No active Codex turn to steer');
1108
1750
  await this.markCheckpointDirty(binding);
1751
+ this.requireCurrentTurn(binding, turn, ['running']);
1752
+ if (turn.providerSettled) throw new Error('No active Codex turn to steer');
1109
1753
  const response = await withTimeout(
1110
1754
  binding.connection.request(CODEX_STEER_METHOD, {
1111
1755
  sessionId: binding.sessionId,
@@ -1115,62 +1759,118 @@ class AcpRuntime extends EventEmitter {
1115
1759
  this.requestTimeoutMs,
1116
1760
  'Codex ACP steer',
1117
1761
  );
1762
+ this.requireCurrentTurn(binding, turn, ['running']);
1118
1763
  return {
1119
1764
  sessionId: binding.sessionId,
1120
1765
  turnId: String(response?.turnId || ''),
1121
1766
  clientMessageId,
1122
1767
  };
1123
1768
  };
1124
- const previous = binding.steerTail || Promise.resolve();
1125
- const pending = previous.catch(() => {}).then(operation);
1126
- binding.steerTail = pending;
1127
- try {
1128
- return await pending;
1129
- } finally {
1130
- if (binding.steerTail === pending) binding.steerTail = null;
1131
- }
1769
+ return this.enqueueTurnControl(turn, operation);
1132
1770
  }
1133
1771
 
1134
1772
  async cancel(agentId) {
1135
1773
  const binding = this.requireBinding(agentId);
1774
+ this.requireOpenBinding(binding);
1775
+ if (binding.state === 'closed') throw new Error('ACP session is closed');
1136
1776
  if (!binding.sessionId) return false;
1137
- binding.state = 'interrupting';
1138
- for (const resolve of binding.permissionResolvers.values()) {
1139
- resolve({ outcome: { outcome: 'cancelled' } });
1140
- }
1141
- for (const resolve of binding.elicitationResolvers.values()) resolve({ action: 'cancel' });
1142
- binding.permissionResolvers.clear();
1143
- binding.pendingPermissions.clear();
1144
- binding.elicitationResolvers.clear();
1145
- binding.pendingElicitations.clear();
1146
- binding.activeElicitations.clear();
1147
- binding.interactionOrigins.clear();
1148
- this.emitRuntime(binding);
1149
- try {
1150
- await withTimeout(
1151
- binding.connection.cancel({ sessionId: binding.sessionId }),
1152
- this.cancelTimeoutMs,
1153
- 'ACP session/cancel'
1154
- );
1777
+ const turn = binding.activeTurn;
1778
+ if (turn?.phase === 'admitting') {
1779
+ this.cancelAdmittingTurn(binding, turn);
1155
1780
  return true;
1156
- } catch (error) {
1157
- const runtimeError = new Error(acpErrorMessage(error), { cause: error });
1158
- binding.state = 'error';
1159
- binding.error = runtimeError.message;
1160
- binding.stopReason = 'cancel_error';
1161
- binding.updatedAt = new Date().toISOString();
1162
- this.emitSession(binding);
1781
+ }
1782
+ if (!turn && (
1783
+ binding.sessionMutation
1784
+ || binding.configMutationTail
1785
+ || binding.patchDecisionInFlight.size > 0
1786
+ )) {
1787
+ throw new Error(`ACP Agent is not ready for cancellation (${binding.state})`);
1788
+ }
1789
+ if (turn?.cancelPromise) return turn.cancelPromise;
1790
+
1791
+ const stateBeforeCancellation = binding.state;
1792
+ let cancelAcknowledged = false;
1793
+ const cancelOperation = async () => {
1794
+ const activeSubagentSessionIds = this.activeSubagentSessionIds(binding);
1795
+ if (turn) {
1796
+ this.requireCurrentTurn(binding, turn, ['running']);
1797
+ if (turn.providerSettled) throw new Error('No active turn to cancel');
1798
+ turn.phase = 'cancelling';
1799
+ }
1800
+ binding.state = 'interrupting';
1801
+ for (const resolve of binding.permissionResolvers.values()) {
1802
+ resolve({ outcome: { outcome: 'cancelled' } });
1803
+ }
1804
+ for (const resolve of binding.elicitationResolvers.values()) resolve({ action: 'cancel' });
1805
+ binding.permissionResolvers.clear();
1806
+ binding.pendingPermissions.clear();
1807
+ binding.elicitationResolvers.clear();
1808
+ binding.pendingElicitations.clear();
1809
+ binding.activeElicitations.clear();
1810
+ binding.interactionOrigins.clear();
1163
1811
  this.emitRuntime(binding);
1164
- throw runtimeError;
1812
+ await Promise.all([
1813
+ withTimeout(
1814
+ binding.connection.cancel({ sessionId: binding.sessionId }),
1815
+ this.cancelTimeoutMs,
1816
+ 'ACP session/cancel'
1817
+ ),
1818
+ ...activeSubagentSessionIds.map(sessionId => this.cancelSubagentControl(binding, sessionId)),
1819
+ ]);
1820
+ this.requireOpenBinding(binding);
1821
+ cancelAcknowledged = true;
1822
+ return true;
1823
+ };
1824
+ const cancellation = (async () => {
1825
+ try {
1826
+ if (turn) await this.enqueueTurnControl(turn, cancelOperation);
1827
+ else await cancelOperation();
1828
+ if (!turn) {
1829
+ if (binding.state === 'interrupting') {
1830
+ binding.state = ['idle', 'error'].includes(stateBeforeCancellation)
1831
+ ? stateBeforeCancellation
1832
+ : 'idle';
1833
+ }
1834
+ binding.updatedAt = new Date().toISOString();
1835
+ this.emitRuntime(binding);
1836
+ return true;
1837
+ }
1838
+ await withTimeout(turn.completion, this.cancelTimeoutMs, 'ACP turn cancellation');
1839
+ return true;
1840
+ } catch (error) {
1841
+ const runtimeError = new Error(acpErrorMessage(error), { cause: error });
1842
+ if (!this.isOpenBinding(binding)) throw runtimeError;
1843
+ const markBlocked = () => {
1844
+ if (turn && binding.activeTurn !== turn) return;
1845
+ if (turn) turn.phase = 'blocked';
1846
+ binding.state = 'error';
1847
+ binding.error = runtimeError.message;
1848
+ binding.stopReason = turn && cancelAcknowledged ? 'cancel_timeout' : 'cancel_error';
1849
+ binding.updatedAt = new Date().toISOString();
1850
+ this.emitSession(binding);
1851
+ this.emitRuntime(binding);
1852
+ };
1853
+ if (turn) await this.enqueueTurnControl(turn, markBlocked);
1854
+ else markBlocked();
1855
+ throw runtimeError;
1856
+ }
1857
+ })();
1858
+ if (turn) turn.cancelPromise = cancellation;
1859
+ try {
1860
+ return await cancellation;
1861
+ } finally {
1862
+ if (turn?.cancelPromise === cancellation) turn.cancelPromise = null;
1165
1863
  }
1166
1864
  }
1167
1865
 
1168
- async cancelSubagent(agentId, sessionId) {
1169
- const binding = this.requireBinding(agentId);
1170
- const targetSessionId = String(sessionId || '');
1171
- if (!targetSessionId || targetSessionId === binding.sessionId || !binding.subagentStates.has(targetSessionId)) {
1172
- throw new Error('ACP subagent session not found');
1866
+ cancelSubagentControl(binding, targetSessionId) {
1867
+ const control = this.ensureSubagentControl(binding, targetSessionId);
1868
+ if (control.cancelPromise) return control.cancelPromise;
1869
+ if (['cancelled', 'completed'].includes(control.phase)) {
1870
+ return Promise.resolve({ cancelled: true, sessionId: targetSessionId });
1173
1871
  }
1872
+ control.phase = 'cancelling';
1873
+ control.error = '';
1174
1874
  for (const [requestId, pending] of binding.pendingPermissions.entries()) {
1175
1875
  if (String(pending?.sessionId || '') !== targetSessionId) continue;
1176
1876
  binding.permissionResolvers.get(requestId)?.({ outcome: { outcome: 'cancelled' } });
@@ -1190,28 +1890,67 @@ class AcpRuntime extends EventEmitter {
1190
1890
  }
1191
1891
  binding.state = interactiveRuntimeState(binding, binding.state);
1192
1892
  this.emitRuntime(binding);
1193
- await withTimeout(
1194
- binding.connection.cancel({ sessionId: targetSessionId }),
1195
- this.cancelTimeoutMs,
1196
- 'ACP subagent session/cancel'
1197
- );
1198
- binding.updatedAt = new Date().toISOString();
1199
- this.emitSession(binding);
1200
- this.emitRuntime(binding);
1201
- return { cancelled: true, sessionId: targetSessionId };
1893
+ const cancellation = (async () => {
1894
+ try {
1895
+ await withTimeout(
1896
+ binding.connection.cancel({ sessionId: targetSessionId }),
1897
+ this.cancelTimeoutMs,
1898
+ 'ACP subagent session/cancel'
1899
+ );
1900
+ this.requireOpenBinding(binding);
1901
+ if (binding.subagentControls.get(targetSessionId) !== control) {
1902
+ throw new Error('ACP subagent control is no longer active');
1903
+ }
1904
+ control.phase = 'cancelled';
1905
+ control.error = '';
1906
+ binding.updatedAt = new Date().toISOString();
1907
+ this.emitSession(binding);
1908
+ this.emitRuntime(binding);
1909
+ return { cancelled: true, sessionId: targetSessionId };
1910
+ } catch (error) {
1911
+ if (this.isOpenBinding(binding) && binding.subagentControls.get(targetSessionId) === control) {
1912
+ control.phase = 'error';
1913
+ control.error = acpErrorMessage(error);
1914
+ binding.updatedAt = new Date().toISOString();
1915
+ this.emitSession(binding);
1916
+ this.emitRuntime(binding);
1917
+ }
1918
+ throw error;
1919
+ }
1920
+ })();
1921
+ control.cancelPromise = cancellation;
1922
+ void cancellation.finally(() => {
1923
+ if (binding.subagentControls.get(targetSessionId) === control && control.cancelPromise === cancellation) {
1924
+ control.cancelPromise = null;
1925
+ }
1926
+ }).catch(() => {});
1927
+ return cancellation;
1928
+ }
1929
+
1930
+ async cancelSubagent(agentId, sessionId) {
1931
+ const binding = this.requireBinding(agentId);
1932
+ this.requireOpenBinding(binding);
1933
+ const targetSessionId = String(sessionId || '');
1934
+ if (!targetSessionId || targetSessionId === binding.sessionId || !binding.subagentStates.has(targetSessionId)) {
1935
+ throw new Error('ACP subagent session not found');
1936
+ }
1937
+ return this.cancelSubagentControl(binding, targetSessionId);
1202
1938
  }
1203
1939
 
1204
1940
  async listSessions(agentId, options = {}) {
1205
1941
  const binding = this.requireBinding(agentId);
1942
+ this.requireOpenBinding(binding);
1206
1943
  const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
1207
1944
  if (!capabilities?.list) throw new Error(`${binding.provider} ACP Agent does not support session/list`);
1208
- return withTimeout(binding.connection.listSessions({
1945
+ const response = await withTimeout(binding.connection.listSessions({
1209
1946
  ...(options.cwd ? { cwd: path.resolve(options.cwd) } : {}),
1210
1947
  ...(Array.isArray(options.additionalDirectories)
1211
1948
  ? { additionalDirectories: acpSessionRequestOptions(options, options.cwd || binding.cwd).additionalDirectories }
1212
1949
  : {}),
1213
1950
  ...(options.cursor ? { cursor: String(options.cursor) } : {}),
1214
1951
  }), this.requestTimeoutMs, 'ACP session/list');
1952
+ this.requireOpenBinding(binding);
1953
+ return response;
1215
1954
  }
1216
1955
 
1217
1956
  getSessionRequestOptions(agentId) {
@@ -1221,37 +1960,55 @@ class AcpRuntime extends EventEmitter {
1221
1960
 
1222
1961
  async authenticate(agentId, methodId) {
1223
1962
  const binding = this.requireBinding(agentId);
1963
+ this.requireOpenBinding(binding);
1964
+ if (binding.state === 'closed') throw new Error('ACP session is closed');
1965
+ const mutation = this.beginSessionMutation(binding, 'authentication');
1224
1966
  const method = binding.initializeResponse?.authMethods?.find(item => item.id === methodId);
1225
- if (!method) throw new Error('Unknown ACP authentication method');
1226
- if (method.type === 'terminal' || method?._meta?.['terminal-auth']) {
1227
- return this.startTerminalAuthentication(binding, method);
1967
+ let terminalReservationTransferred = false;
1968
+ try {
1969
+ if (!method) throw new Error('Unknown ACP authentication method');
1970
+ if (method.type === 'terminal' || method?._meta?.['terminal-auth']) {
1971
+ const result = await this.startTerminalAuthentication(binding, method, mutation);
1972
+ terminalReservationTransferred = true;
1973
+ return result;
1974
+ }
1975
+ await withTimeout(
1976
+ binding.connection.authenticate({ methodId }),
1977
+ this.requestTimeoutMs,
1978
+ 'ACP authenticate'
1979
+ );
1980
+ this.requireOpenBinding(binding);
1981
+ binding.error = '';
1982
+ binding.stopReason = '';
1983
+ binding.state = interactiveRuntimeState(binding, 'idle');
1984
+ binding.updatedAt = new Date().toISOString();
1985
+ this.emitRuntime(binding);
1986
+ this.emitSession(binding);
1987
+ return { authenticated: true, methodId };
1988
+ } finally {
1989
+ if (!terminalReservationTransferred) this.endSessionMutation(binding, mutation);
1228
1990
  }
1229
- await withTimeout(
1230
- binding.connection.authenticate({ methodId }),
1231
- this.requestTimeoutMs,
1232
- 'ACP authenticate'
1233
- );
1234
- binding.error = '';
1235
- binding.stopReason = '';
1236
- binding.state = interactiveRuntimeState(binding, 'idle');
1237
- binding.updatedAt = new Date().toISOString();
1238
- this.emitRuntime(binding);
1239
- this.emitSession(binding);
1240
- return { authenticated: true, methodId };
1241
1991
  }
1242
1992
 
1243
1993
  async logout(agentId) {
1244
1994
  const binding = this.requireBinding(agentId);
1245
- if (binding.initializeResponse?.agentCapabilities?.auth?.logout == null) {
1246
- throw new Error(`${binding.provider} ACP Agent does not support logout`);
1995
+ this.requireOpenBinding(binding);
1996
+ const mutation = this.beginSessionMutation(binding, 'logout');
1997
+ try {
1998
+ if (binding.initializeResponse?.agentCapabilities?.auth?.logout == null) {
1999
+ throw new Error(`${binding.provider} ACP Agent does not support logout`);
2000
+ }
2001
+ await withTimeout(binding.connection.logout({}), this.requestTimeoutMs, 'ACP logout');
2002
+ this.requireOpenBinding(binding);
2003
+ binding.error = '';
2004
+ binding.stopReason = '';
2005
+ binding.updatedAt = new Date().toISOString();
2006
+ this.emitRuntime(binding);
2007
+ this.emitSession(binding);
2008
+ return { loggedOut: true };
2009
+ } finally {
2010
+ this.endSessionMutation(binding, mutation);
1247
2011
  }
1248
- await withTimeout(binding.connection.logout({}), this.requestTimeoutMs, 'ACP logout');
1249
- binding.error = '';
1250
- binding.stopReason = '';
1251
- binding.updatedAt = new Date().toISOString();
1252
- this.emitRuntime(binding);
1253
- this.emitSession(binding);
1254
- return { loggedOut: true };
1255
2012
  }
1256
2013
 
1257
2014
  terminalAuthenticationLaunch(binding, method) {
@@ -1271,7 +2028,9 @@ class AcpRuntime extends EventEmitter {
1271
2028
  };
1272
2029
  }
1273
2030
 
1274
- async startTerminalAuthentication(binding, method) {
2031
+ async startTerminalAuthentication(binding, method, mutation) {
2032
+ this.requireOpenBinding(binding);
2033
+ if (binding.sessionMutation !== mutation) throw new Error('ACP authentication reservation is no longer active');
1275
2034
  if (binding.authTerminal?.state === 'running') throw new Error('ACP terminal authentication is already running');
1276
2035
  const launch = this.terminalAuthenticationLaunch(binding, method);
1277
2036
  const created = await this.clientTerminals.create(binding, {
@@ -1282,6 +2041,19 @@ class AcpRuntime extends EventEmitter {
1282
2041
  env: Object.entries(launch.env).map(([name, value]) => ({ name, value: String(value) })),
1283
2042
  outputByteLimit: 2 * 1024 * 1024,
1284
2043
  });
2044
+ try {
2045
+ this.requireOpenBinding(binding);
2046
+ } catch (error) {
2047
+ try {
2048
+ this.clientTerminals.release(binding, {
2049
+ sessionId: binding.sessionId,
2050
+ terminalId: created.terminalId,
2051
+ });
2052
+ } catch {
2053
+ // Binding teardown may already have removed the terminal.
2054
+ }
2055
+ throw error;
2056
+ }
1285
2057
  binding.authTerminal = {
1286
2058
  terminalId: created.terminalId,
1287
2059
  methodId: String(method.id || ''),
@@ -1296,10 +2068,11 @@ class AcpRuntime extends EventEmitter {
1296
2068
  sessionId: binding.sessionId,
1297
2069
  terminalId: created.terminalId,
1298
2070
  })).then(async exit => {
1299
- if (this.bindings.get(binding.agentId) !== binding) return;
2071
+ if (!this.isOpenBinding(binding) || binding.sessionMutation !== mutation) return;
1300
2072
  if (exit.exitCode !== 0) {
1301
2073
  binding.authTerminal.state = 'failed';
1302
2074
  binding.authTerminal.error = `Sign-in command exited ${exit.exitCode ?? exit.signal ?? ''}`.trim();
2075
+ this.endSessionMutation(binding, mutation);
1303
2076
  binding.updatedAt = new Date().toISOString();
1304
2077
  this.emitRuntime(binding);
1305
2078
  this.emitSession(binding);
@@ -1308,18 +2081,20 @@ class AcpRuntime extends EventEmitter {
1308
2081
  binding.authTerminal.state = 'completed';
1309
2082
  binding.updatedAt = new Date().toISOString();
1310
2083
  this.emitSession(binding);
1311
- await this.restartAgentConnection(binding.agentId).catch(error => {
2084
+ await this.restartAgentConnection(binding.agentId, mutation).catch(error => {
1312
2085
  const current = this.bindings.get(binding.agentId);
1313
- if (!current) return;
2086
+ if (current !== binding || current.exited) return;
2087
+ this.endSessionMutation(current, mutation);
1314
2088
  current.state = 'error';
1315
2089
  current.error = acpErrorMessage(error);
1316
2090
  current.updatedAt = new Date().toISOString();
1317
2091
  this.emitRuntime(current);
1318
2092
  });
1319
2093
  }).catch(error => {
1320
- if (this.bindings.get(binding.agentId) !== binding) return;
2094
+ if (!this.isOpenBinding(binding) || binding.sessionMutation !== mutation) return;
1321
2095
  binding.authTerminal.state = 'failed';
1322
2096
  binding.authTerminal.error = acpErrorMessage(error);
2097
+ this.endSessionMutation(binding, mutation);
1323
2098
  binding.updatedAt = new Date().toISOString();
1324
2099
  this.emitRuntime(binding);
1325
2100
  this.emitSession(binding);
@@ -1327,8 +2102,15 @@ class AcpRuntime extends EventEmitter {
1327
2102
  return { authenticated: false, methodId: method.id, terminalId: created.terminalId };
1328
2103
  }
1329
2104
 
1330
- async restartAgentConnection(agentId) {
2105
+ async restartAgentConnection(agentId, mutation = null) {
1331
2106
  const binding = this.requireBinding(agentId);
2107
+ this.requireOpenBinding(binding);
2108
+ if (binding.activeTurn || (binding.sessionMutation && binding.sessionMutation !== mutation)) {
2109
+ throw new Error(`ACP Agent is not ready for restart (${binding.state})`);
2110
+ }
2111
+ if (mutation && binding.sessionMutation !== mutation) {
2112
+ throw new Error('ACP restart reservation is no longer active');
2113
+ }
1332
2114
  const revisionBase = Number(binding.sessionState?.revision || 0);
1333
2115
  const options = {
1334
2116
  ...binding.restartOptions,
@@ -1341,60 +2123,109 @@ class AcpRuntime extends EventEmitter {
1341
2123
  ...(revisionBase > 0 ? { revisionBase } : {}),
1342
2124
  ...(binding.sessionId ? { historyMode: 'checkpoint' } : {}),
1343
2125
  };
1344
- if (binding.sessionState && !binding.promptActive) {
2126
+ if (binding.sessionState && !binding.activeTurn) {
1345
2127
  await this.writeCheckpoint(binding, { exact: true });
1346
2128
  }
1347
- this.unregisterAgent(agentId);
2129
+ this.requireOpenBinding(binding);
2130
+ await this.unregisterAgentAndWait(agentId, binding);
1348
2131
  return this.prepareAgent(options);
1349
2132
  }
1350
2133
 
1351
2134
  async forkSession(agentId, options = {}) {
2135
+ return this.runWithForkReservation(agentId, options, async binding => {
2136
+ const sessionOptions = acpSessionRequestOptions({
2137
+ additionalDirectories: options.additionalDirectories ?? binding.sessionRequestOptions.additionalDirectories,
2138
+ mcpServers: options.mcpServers ?? binding.sessionRequestOptions.mcpServers,
2139
+ }, options.cwd || binding.cwd);
2140
+ const response = await withTimeout(binding.connection.unstable_forkSession({
2141
+ sessionId: options.sessionId || binding.sessionId,
2142
+ ...sessionOptions,
2143
+ }), this.sessionSetupTimeoutMs, 'ACP session/fork');
2144
+ this.requireOpenBinding(binding);
2145
+ const sessionId = String(response?.sessionId || '').trim();
2146
+ if (!isSafeProviderSessionId(sessionId) || sessionId === binding.sessionId) {
2147
+ throw new Error('ACP session/fork returned an invalid new session id');
2148
+ }
2149
+ return { ...response, sessionId };
2150
+ });
2151
+ }
2152
+
2153
+ async runWithForkReservation(agentId, options = {}, operation) {
1352
2154
  const binding = this.requireBinding(agentId);
1353
- const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
1354
- if (!capabilities?.fork) throw new Error(`${binding.provider} ACP Agent does not support session/fork`);
1355
- const sessionOptions = acpSessionRequestOptions({
1356
- additionalDirectories: options.additionalDirectories ?? binding.sessionRequestOptions.additionalDirectories,
1357
- mcpServers: options.mcpServers ?? binding.sessionRequestOptions.mcpServers,
1358
- }, options.cwd || binding.cwd);
1359
- return withTimeout(binding.connection.unstable_forkSession({
1360
- sessionId: options.sessionId || binding.sessionId,
1361
- ...sessionOptions,
1362
- }), this.sessionSetupTimeoutMs, 'ACP session/fork');
2155
+ const mutation = this.beginSessionMutation(binding, 'fork');
2156
+ try {
2157
+ const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
2158
+ if (!capabilities?.fork) throw new Error(`${binding.provider} ACP Agent does not support session/fork`);
2159
+ if (options.requireLoad === true && !binding.initializeResponse?.agentCapabilities?.loadSession) {
2160
+ throw new Error(`${binding.provider} ACP Agent cannot load a forked conversation`);
2161
+ }
2162
+ if (
2163
+ Number.isFinite(options.expectedRevision)
2164
+ && Number(binding.sessionState?.revision || 0) !== Math.floor(options.expectedRevision)
2165
+ ) {
2166
+ throw new Error('ACP conversation changed before it could be forked. Try again from the latest answer.');
2167
+ }
2168
+ if (typeof operation !== 'function') throw new Error('ACP fork operation is required');
2169
+ return await operation(binding);
2170
+ } finally {
2171
+ this.endSessionMutation(binding, mutation);
2172
+ }
1363
2173
  }
1364
2174
 
1365
2175
  async deleteSession(agentId, sessionId) {
1366
2176
  const binding = this.requireBinding(agentId);
1367
- const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
1368
- if (!capabilities?.delete) throw new Error(`${binding.provider} ACP Agent does not support session/delete`);
1369
- await withTimeout(
1370
- binding.connection.deleteSession({ sessionId: String(sessionId || '') }),
1371
- this.requestTimeoutMs,
1372
- 'ACP session/delete'
1373
- );
1374
- return { deleted: true, sessionId: String(sessionId || '') };
2177
+ const mutation = this.beginSessionMutation(binding, 'session deletion');
2178
+ try {
2179
+ const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
2180
+ if (!capabilities?.delete) throw new Error(`${binding.provider} ACP Agent does not support session/delete`);
2181
+ await withTimeout(
2182
+ binding.connection.deleteSession({ sessionId: String(sessionId || '') }),
2183
+ this.requestTimeoutMs,
2184
+ 'ACP session/delete'
2185
+ );
2186
+ this.requireOpenBinding(binding);
2187
+ return { deleted: true, sessionId: String(sessionId || '') };
2188
+ } finally {
2189
+ this.endSessionMutation(binding, mutation);
2190
+ }
1375
2191
  }
1376
2192
 
1377
2193
  async closeSession(agentId) {
1378
2194
  const binding = this.requireBinding(agentId);
1379
- const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
1380
- if (!capabilities?.close) throw new Error(`${binding.provider} ACP Agent does not support session/close`);
1381
- await withTimeout(
1382
- binding.connection.closeSession({ sessionId: binding.sessionId }),
1383
- this.requestTimeoutMs,
1384
- 'ACP session/close'
1385
- );
1386
- binding.state = 'closed';
1387
- this.emitRuntime(binding);
1388
- return { closed: true, sessionId: binding.sessionId };
2195
+ const mutation = this.beginSessionMutation(binding, 'session close');
2196
+ try {
2197
+ const capabilities = binding.initializeResponse?.agentCapabilities?.sessionCapabilities;
2198
+ if (!capabilities?.close) throw new Error(`${binding.provider} ACP Agent does not support session/close`);
2199
+ await withTimeout(
2200
+ binding.connection.closeSession({ sessionId: binding.sessionId }),
2201
+ this.requestTimeoutMs,
2202
+ 'ACP session/close'
2203
+ );
2204
+ this.requireOpenBinding(binding);
2205
+ binding.state = 'closed';
2206
+ this.emitRuntime(binding);
2207
+ return { closed: true, sessionId: binding.sessionId };
2208
+ } finally {
2209
+ this.endSessionMutation(binding, mutation);
2210
+ }
1389
2211
  }
1390
2212
 
1391
2213
  async setSessionMode(agentId, modeId) {
1392
2214
  const binding = this.requireBinding(agentId);
2215
+ this.requireConfigMutationReady(binding);
2216
+ return this.enqueueSessionConfigMutation(binding, () => (
2217
+ this.setSessionModeNow(binding, modeId)
2218
+ ));
2219
+ }
2220
+
2221
+ async setSessionModeNow(binding, modeId) {
2222
+ this.requireOpenBinding(binding);
1393
2223
  await withTimeout(
1394
2224
  binding.connection.setSessionMode({ sessionId: binding.sessionId, modeId: String(modeId || '') }),
1395
2225
  this.requestTimeoutMs,
1396
2226
  'ACP session/set_mode'
1397
2227
  );
2228
+ this.requireOpenBinding(binding);
1398
2229
  binding.sessionState.currentModeId = String(modeId || '');
1399
2230
  if (binding.modes) binding.modes = { ...binding.modes, currentModeId: binding.sessionState.currentModeId };
1400
2231
  this.scheduleCheckpoint(binding, { exact: true });
@@ -1404,12 +2235,14 @@ class AcpRuntime extends EventEmitter {
1404
2235
 
1405
2236
  async setSessionConfigOption(agentId, configId, value) {
1406
2237
  const binding = this.requireBinding(agentId);
2238
+ this.requireConfigMutationReady(binding);
1407
2239
  return this.enqueueSessionConfigMutation(binding, () => (
1408
2240
  this.setSessionConfigOptionNow(binding, configId, value)
1409
2241
  ));
1410
2242
  }
1411
2243
 
1412
2244
  async setSessionConfigOptionNow(binding, configId, value) {
2245
+ this.requireOpenBinding(binding);
1413
2246
  const option = binding.configOptions?.find(candidate => candidate.id === String(configId || ''));
1414
2247
  if (
1415
2248
  binding.provider === 'codex'
@@ -1437,17 +2270,52 @@ class AcpRuntime extends EventEmitter {
1437
2270
 
1438
2271
  async setSessionConfigOptions(agentId, changes) {
1439
2272
  const binding = this.requireBinding(agentId);
2273
+ this.requireConfigMutationReady(binding);
1440
2274
  return this.enqueueSessionConfigMutation(binding, () => (
1441
2275
  this.setSessionConfigOptionsNow(binding, changes)
1442
2276
  ));
1443
2277
  }
1444
2278
 
1445
2279
  async setSessionConfigOptionsNow(binding, changes) {
2280
+ this.requireOpenBinding(binding);
1446
2281
  const normalized = Array.isArray(changes)
1447
2282
  ? changes.filter(change => change && typeof change.configId === 'string' && Object.prototype.hasOwnProperty.call(change, 'value'))
1448
2283
  : [];
1449
2284
  if (normalized.length === 0) throw new Error('ACP config options are required');
1450
2285
 
2286
+ const originalValues = new Map();
2287
+ for (const change of normalized) {
2288
+ if (originalValues.has(change.configId)) continue;
2289
+ const option = binding.configOptions?.find(candidate => candidate.id === change.configId);
2290
+ if (option && Object.prototype.hasOwnProperty.call(option, 'currentValue')) {
2291
+ originalValues.set(change.configId, option.currentValue);
2292
+ }
2293
+ }
2294
+ try {
2295
+ return await this.applySessionConfigOptionsNow(binding, normalized);
2296
+ } catch (error) {
2297
+ const rollbackErrors = [];
2298
+ for (const [configId, value] of [...originalValues.entries()].reverse()) {
2299
+ const current = binding.configOptions?.find(candidate => candidate.id === configId);
2300
+ if (current?.currentValue === value) continue;
2301
+ try {
2302
+ await this.setSessionConfigOptionNow(binding, configId, value);
2303
+ } catch (rollbackError) {
2304
+ rollbackErrors.push(rollbackError);
2305
+ }
2306
+ }
2307
+ if (rollbackErrors.length > 0 && error && typeof error === 'object') {
2308
+ Object.defineProperty(error, 'configRollbackError', {
2309
+ value: new AggregateError(rollbackErrors, 'ACP config rollback failed'),
2310
+ enumerable: false,
2311
+ configurable: true,
2312
+ });
2313
+ }
2314
+ throw error;
2315
+ }
2316
+ }
2317
+
2318
+ async applySessionConfigOptionsNow(binding, normalized) {
1451
2319
  const configById = new Map((binding.configOptions || []).map(option => [option.id, option]));
1452
2320
  const modelChange = normalized.find(change => {
1453
2321
  const option = configById.get(change.configId);
@@ -1497,6 +2365,7 @@ class AcpRuntime extends EventEmitter {
1497
2365
  }
1498
2366
 
1499
2367
  async refreshCodexSessionModel(binding, model, effort) {
2368
+ this.requireOpenBinding(binding);
1500
2369
  await withTimeout(
1501
2370
  binding.connection.request(CODEX_SET_SESSION_MODEL_METHOD, {
1502
2371
  sessionId: binding.sessionId,
@@ -1505,9 +2374,11 @@ class AcpRuntime extends EventEmitter {
1505
2374
  this.requestTimeoutMs,
1506
2375
  'Codex ACP session/set_model capability refresh'
1507
2376
  );
2377
+ this.requireOpenBinding(binding);
1508
2378
  }
1509
2379
 
1510
2380
  async applySessionConfigOption(binding, configId, value, options = {}) {
2381
+ this.requireOpenBinding(binding);
1511
2382
  const normalizedConfigId = String(configId || '');
1512
2383
  const currentOption = binding.configOptions?.find(candidate => candidate.id === normalizedConfigId);
1513
2384
  if (options.force !== true && currentOption?.currentValue === value) {
@@ -1524,6 +2395,7 @@ class AcpRuntime extends EventEmitter {
1524
2395
  this.requestTimeoutMs,
1525
2396
  'ACP session/set_config_option'
1526
2397
  );
2398
+ this.requireOpenBinding(binding);
1527
2399
  binding.configOptions = response?.configOptions || binding.configOptions;
1528
2400
  const confirmed = binding.configOptions?.find(candidate => candidate.id === normalizedConfigId);
1529
2401
  if (confirmed?.currentValue === request.value) break;
@@ -1545,6 +2417,10 @@ class AcpRuntime extends EventEmitter {
1545
2417
  errorKind: binding.error ? acpErrorKind(binding.error) : '',
1546
2418
  stopReason: binding.stopReason,
1547
2419
  supportsSteer: binding.supportsSteer === true,
2420
+ supportsFork: Boolean(
2421
+ binding.initializeResponse?.agentCapabilities?.sessionCapabilities?.fork != null
2422
+ && binding.initializeResponse?.agentCapabilities?.loadSession
2423
+ ),
1548
2424
  protocolVersion: binding.initializeResponse?.protocolVersion || null,
1549
2425
  agentInfo: binding.initializeResponse?.agentInfo || null,
1550
2426
  capabilities: binding.initializeResponse?.agentCapabilities || {},
@@ -1625,33 +2501,82 @@ class AcpRuntime extends EventEmitter {
1625
2501
 
1626
2502
  async decidePatch(agentId, toolCallId, requestedPath, decision) {
1627
2503
  const binding = this.requireBinding(agentId);
1628
- if (binding.promptActive) throw new Error('Wait for the Agent to finish before deciding a patch');
2504
+ this.requireOpenBinding(binding);
2505
+ if (
2506
+ binding.sessionMutation
2507
+ || binding.configMutationTail
2508
+ || binding.activeTurn
2509
+ || !['idle', 'error'].includes(binding.state)
2510
+ ) {
2511
+ throw new Error('Wait for the Agent to finish before deciding a patch');
2512
+ }
1629
2513
  const entry = binding.sessionState?.toolEntries.get(String(toolCallId || ''));
1630
2514
  if (entry && binding.sessionState.isInternalEntry(entry)) throw new Error('ACP tool call not found');
1631
2515
  if (!entry) throw new Error('ACP tool call not found');
1632
2516
  const normalizedDecision = String(decision || '').trim().toLowerCase();
1633
2517
  if (!['keep', 'revert'].includes(normalizedDecision)) throw new Error('ACP patch decision is invalid');
1634
2518
  const key = `${String(toolCallId || '')}\n${String(requestedPath || '')}`;
1635
- if (binding.patchDecisions.has(key)) throw new Error('ACP patch file already has a decision');
1636
- let result = { action: 'kept', path: String(requestedPath || '') };
1637
- if (normalizedDecision === 'revert') {
1638
- result = await rejectPatch({ entry, root: binding.cwd, requestedPath });
1639
- }
1640
- binding.patchDecisions.set(key, result.action);
1641
- entry._meta = { ...(entry._meta || {}) };
1642
- entry._meta.farming_patch_decisions = {
1643
- ...(entry._meta.farming_patch_decisions || {}),
1644
- [String(requestedPath || '')]: result.action,
1645
- };
1646
- binding.sessionState?.touchEntry(entry);
1647
- binding.updatedAt = new Date().toISOString();
1648
- this.scheduleCheckpoint(binding, { exact: true });
1649
- this.emitSession(binding);
1650
- return { ...result, toolCallId: String(toolCallId || '') };
2519
+ const expectedAction = normalizedDecision === 'keep' ? 'kept' : 'reverted';
2520
+ const existing = binding.patchDecisionInFlight.get(key);
2521
+ if (existing) {
2522
+ if (existing.decision !== normalizedDecision) {
2523
+ throw new Error('ACP patch file already has a different decision in progress');
2524
+ }
2525
+ return existing.promise;
2526
+ }
2527
+ const committed = binding.patchDecisions.get(key);
2528
+ if (committed) {
2529
+ if (committed !== expectedAction) throw new Error('ACP patch file already has a different decision');
2530
+ const operation = (async () => {
2531
+ await this.writeCheckpoint(binding, { exact: true });
2532
+ return {
2533
+ action: committed,
2534
+ path: String(requestedPath || ''),
2535
+ toolCallId: String(toolCallId || ''),
2536
+ };
2537
+ })();
2538
+ const pending = { decision: normalizedDecision, promise: operation };
2539
+ binding.patchDecisionInFlight.set(key, pending);
2540
+ try {
2541
+ return await operation;
2542
+ } finally {
2543
+ if (binding.patchDecisionInFlight.get(key) === pending) {
2544
+ binding.patchDecisionInFlight.delete(key);
2545
+ }
2546
+ }
2547
+ }
2548
+ const operation = (async () => {
2549
+ let result = { action: 'kept', path: String(requestedPath || '') };
2550
+ if (normalizedDecision === 'revert') {
2551
+ result = await rejectPatch({ entry, root: binding.cwd, requestedPath });
2552
+ this.requireOpenBinding(binding);
2553
+ }
2554
+ binding.patchDecisions.set(key, result.action);
2555
+ entry._meta = { ...(entry._meta || {}) };
2556
+ entry._meta.farming_patch_decisions = {
2557
+ ...(entry._meta.farming_patch_decisions || {}),
2558
+ [String(requestedPath || '')]: result.action,
2559
+ };
2560
+ binding.sessionState?.touchEntry(entry);
2561
+ binding.updatedAt = new Date().toISOString();
2562
+ await this.writeCheckpoint(binding, { exact: true });
2563
+ this.emitSession(binding);
2564
+ return { ...result, toolCallId: String(toolCallId || '') };
2565
+ })();
2566
+ const pending = { decision: normalizedDecision, promise: operation };
2567
+ binding.patchDecisionInFlight.set(key, pending);
2568
+ try {
2569
+ return await operation;
2570
+ } finally {
2571
+ if (binding.patchDecisionInFlight.get(key) === pending) {
2572
+ binding.patchDecisionInFlight.delete(key);
2573
+ }
2574
+ }
1651
2575
  }
1652
2576
 
1653
2577
  killTerminal(agentId, terminalId) {
1654
2578
  const binding = this.requireBinding(agentId);
2579
+ this.requireOpenBinding(binding);
1655
2580
  this.clientTerminals.kill(binding, {
1656
2581
  sessionId: binding.sessionId,
1657
2582
  terminalId: String(terminalId || ''),
@@ -1661,6 +2586,7 @@ class AcpRuntime extends EventEmitter {
1661
2586
 
1662
2587
  inputTerminal(agentId, terminalId, input) {
1663
2588
  const binding = this.requireBinding(agentId);
2589
+ this.requireOpenBinding(binding);
1664
2590
  return this.clientTerminals.input(binding, {
1665
2591
  sessionId: binding.sessionId,
1666
2592
  terminalId,
@@ -1670,6 +2596,7 @@ class AcpRuntime extends EventEmitter {
1670
2596
 
1671
2597
  resizeTerminal(agentId, terminalId, cols, rows) {
1672
2598
  const binding = this.requireBinding(agentId);
2599
+ this.requireOpenBinding(binding);
1673
2600
  return this.clientTerminals.resize(binding, {
1674
2601
  sessionId: binding.sessionId,
1675
2602
  terminalId,
@@ -1692,17 +2619,26 @@ class AcpRuntime extends EventEmitter {
1692
2619
  .some(request => String(request?.sessionId || '') === id);
1693
2620
  const pendingElicitation = [...binding.pendingElicitations.values()]
1694
2621
  .some(request => String(request?.sessionId || '') === id);
2622
+ const control = this.ensureSubagentControl(binding, id);
1695
2623
  const active = ['pending', 'in_progress', 'in-progress', 'running'].includes(status);
1696
2624
  const failed = ['failed', 'error'].includes(status);
1697
2625
  const stateName = pendingPermission
1698
2626
  ? 'waiting-for-permission'
1699
2627
  : pendingElicitation
1700
2628
  ? 'waiting-for-input'
1701
- : active
1702
- ? 'working'
1703
- : failed
2629
+ : control.phase === 'cancelling'
2630
+ ? 'interrupting'
2631
+ : control.phase === 'error'
1704
2632
  ? 'error'
1705
- : 'idle';
2633
+ : ['cancelled', 'completed'].includes(control.phase)
2634
+ ? 'idle'
2635
+ : active
2636
+ ? 'working'
2637
+ : failed
2638
+ ? 'error'
2639
+ : 'idle';
2640
+ const controlFailed = control.phase === 'error';
2641
+ const cancelled = control.phase === 'cancelled';
1706
2642
  return {
1707
2643
  version: 2,
1708
2644
  protocol: 'acp',
@@ -1713,13 +2649,17 @@ class AcpRuntime extends EventEmitter {
1713
2649
  updatedAt: binding.updatedAt,
1714
2650
  truncated: state.truncated === true,
1715
2651
  state: stateName,
1716
- error: failed ? String(parentTool?.title || 'Subagent failed') : '',
1717
- errorKind: failed ? 'agent' : '',
1718
- stopReason: failed ? 'error' : active ? '' : 'end_turn',
2652
+ error: controlFailed ? control.error : failed ? String(parentTool?.title || 'Subagent failed') : '',
2653
+ errorKind: controlFailed || failed ? 'agent' : '',
2654
+ stopReason: controlFailed || failed ? 'error' : cancelled ? 'cancelled' : active ? '' : 'end_turn',
1719
2655
  ...state.transcriptSlice(options),
1720
2656
  };
1721
2657
  }
1722
2658
 
2659
+ hasBinding(agentId) {
2660
+ return this.bindings.has(agentId);
2661
+ }
2662
+
1723
2663
  requireBinding(agentId) {
1724
2664
  const binding = this.bindings.get(agentId);
1725
2665
  if (!binding) throw new Error('ACP Agent is not registered');
@@ -1727,6 +2667,7 @@ class AcpRuntime extends EventEmitter {
1727
2667
  }
1728
2668
 
1729
2669
  emitRuntime(binding) {
2670
+ if (!this.isCurrentBinding(binding)) return false;
1730
2671
  this.emit('agent-runtime', {
1731
2672
  agentId: binding.agentId,
1732
2673
  provider: binding.provider,
@@ -1736,6 +2677,10 @@ class AcpRuntime extends EventEmitter {
1736
2677
  errorKind: binding.error ? acpErrorKind(binding.error) : '',
1737
2678
  stopReason: binding.stopReason,
1738
2679
  supportsSteer: binding.supportsSteer === true,
2680
+ supportsFork: Boolean(
2681
+ binding.initializeResponse?.agentCapabilities?.sessionCapabilities?.fork != null
2682
+ && binding.initializeResponse?.agentCapabilities?.loadSession
2683
+ ),
1739
2684
  pendingPermission: binding.pendingPermissions.values().next().value || null,
1740
2685
  pendingPermissions: [...binding.pendingPermissions.values()],
1741
2686
  pendingElicitation: binding.pendingElicitations.values().next().value || null,
@@ -1743,23 +2688,40 @@ class AcpRuntime extends EventEmitter {
1743
2688
  activeElicitations: [...binding.activeElicitations.values()],
1744
2689
  updatedAt: binding.updatedAt,
1745
2690
  });
2691
+ return true;
1746
2692
  }
1747
2693
 
1748
2694
  emitSession(binding) {
2695
+ if (!this.isCurrentBinding(binding)) return false;
1749
2696
  this.emit('session', {
1750
2697
  agentId: binding.agentId,
1751
2698
  updatedAt: binding.updatedAt,
1752
2699
  revision: binding.sessionState?.revision || 0,
1753
2700
  });
2701
+ return true;
1754
2702
  }
1755
2703
 
1756
2704
  handleExit(binding, error) {
1757
- if (this.bindings.get(binding.agentId) !== binding) return;
2705
+ if (!this.isCurrentBinding(binding) || binding.exited) return;
2706
+ binding.exited = true;
2707
+ const turn = binding.activeTurn;
2708
+ const promptWasActive = Boolean(turn && turn.phase !== 'admitting');
2709
+ binding.activeTurn = null;
1758
2710
  if (error) {
1759
2711
  binding.state = 'error';
1760
2712
  binding.error = acpErrorMessage(error);
2713
+ binding.stopReason = 'error';
1761
2714
  } else if (binding.state !== 'error') {
1762
2715
  binding.state = 'stopped';
2716
+ binding.stopReason = promptWasActive ? 'stopped' : binding.stopReason;
2717
+ }
2718
+ if (promptWasActive && binding.sessionState) {
2719
+ if (error) binding.sessionState.recordError(binding.error, acpErrorKind(error));
2720
+ binding.sessionState.completePrompt(binding.stopReason);
2721
+ }
2722
+ if (turn) {
2723
+ turn.phase = 'completed';
2724
+ turn.resolveCompletion({ status: 'connection-closed', error: error || null });
1763
2725
  }
1764
2726
  for (const resolve of binding.permissionResolvers.values()) {
1765
2727
  resolve({ outcome: { outcome: 'cancelled' } });
@@ -1772,14 +2734,23 @@ class AcpRuntime extends EventEmitter {
1772
2734
  binding.activeElicitations.clear();
1773
2735
  binding.interactionOrigins.clear();
1774
2736
  binding.subagentStates.clear();
2737
+ binding.subagentControls.clear();
1775
2738
  this.clientTerminals.cleanupAgent(binding.agentId);
2739
+ if (promptWasActive) this.emitSession(binding);
1776
2740
  this.emitRuntime(binding);
1777
2741
  }
1778
2742
 
1779
- detachAgentBinding(binding) {
2743
+ detachAgentBinding(binding, options = {}) {
1780
2744
  if (!binding || this.bindings.get(binding.agentId) !== binding) return false;
1781
- if (binding.sessionState && !binding.promptActive) this.scheduleCheckpoint(binding, { exact: true });
1782
- this.bindings.delete(binding.agentId);
2745
+ if (binding.sessionState && !binding.activeTurn) this.scheduleCheckpoint(binding, { exact: true });
2746
+ if (options.retainForCleanup !== true) this.bindings.delete(binding.agentId);
2747
+ binding.exited = true;
2748
+ const turn = binding.activeTurn;
2749
+ binding.activeTurn = null;
2750
+ if (turn) {
2751
+ turn.phase = 'completed';
2752
+ turn.resolveCompletion({ status: 'binding-detached' });
2753
+ }
1783
2754
  for (const resolve of binding.permissionResolvers.values()) resolve({ outcome: { outcome: 'cancelled' } });
1784
2755
  for (const resolve of binding.elicitationResolvers.values()) resolve({ action: 'cancel' });
1785
2756
  binding.permissionResolvers.clear();
@@ -1789,6 +2760,7 @@ class AcpRuntime extends EventEmitter {
1789
2760
  binding.activeElicitations.clear();
1790
2761
  binding.interactionOrigins.clear();
1791
2762
  binding.subagentStates.clear();
2763
+ binding.subagentControls.clear();
1792
2764
  this.clientTerminals.cleanupAgent(binding.agentId);
1793
2765
  try {
1794
2766
  binding.connection?.close();
@@ -1805,30 +2777,59 @@ class AcpRuntime extends EventEmitter {
1805
2777
  return true;
1806
2778
  }
1807
2779
 
1808
- unregisterAgent(agentId) {
2780
+ unregisterAgent(agentId, expectedBinding = null) {
1809
2781
  const binding = this.bindings.get(agentId);
2782
+ if (expectedBinding && binding !== expectedBinding) return;
1810
2783
  if (!binding || !this.detachAgentBinding(binding)) return;
1811
2784
  signalProcessTree(binding, 'SIGTERM');
1812
2785
  }
1813
2786
 
1814
- async unregisterAgentAndWait(agentId) {
2787
+ async unregisterAgentAndWait(agentId, expectedBinding = null) {
1815
2788
  const binding = this.bindings.get(agentId);
1816
- if (!binding || !this.detachAgentBinding(binding)) return;
1817
- if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_GRACEFUL_EXIT_MS)) return;
1818
- signalProcessTree(binding, 'SIGTERM');
1819
- if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_TERMINATE_MS)) return;
1820
- signalProcessTree(binding, 'SIGKILL');
1821
- if (await waitForProcessTreeExit(binding, IDENTITY_ADAPTER_KILL_MS)) return;
1822
- throw new Error(`ACP identity adapter process tree ${binding.child?.pid || ''} did not exit`);
2789
+ if (expectedBinding && binding !== expectedBinding) return false;
2790
+ if (!binding || !this.detachAgentBinding(binding, { retainForCleanup: true })) return false;
2791
+ await stopBindingProcessAndWait(binding);
2792
+ if (this.bindings.get(agentId) === binding) this.bindings.delete(agentId);
2793
+ return true;
2794
+ }
2795
+
2796
+ dispose() {
2797
+ if (this.disposed) return Promise.resolve();
2798
+ if (this.disposePromise) return this.disposePromise;
2799
+
2800
+ this.disposing = true;
2801
+ const disposePromise = this.performDispose();
2802
+ this.disposePromise = disposePromise;
2803
+ void disposePromise.finally(() => {
2804
+ if (this.disposePromise === disposePromise) this.disposePromise = null;
2805
+ if (!this.disposed) this.disposing = false;
2806
+ }).catch(() => {});
2807
+ return disposePromise;
2808
+ }
2809
+
2810
+ resumeAfterDisposeAbort() {
2811
+ this.disposed = false;
2812
+ this.disposing = false;
1823
2813
  }
1824
2814
 
1825
- async dispose() {
2815
+ async performDispose() {
1826
2816
  for (const binding of this.bindings.values()) {
1827
- if (binding.sessionState && !binding.promptActive) this.scheduleCheckpoint(binding, { exact: true });
2817
+ if (binding.sessionState && !binding.activeTurn) this.scheduleCheckpoint(binding, { exact: true });
1828
2818
  }
1829
2819
  if (this.checkpointStore) await this.checkpointStore.flush();
1830
- for (const agentId of [...this.bindings.keys()]) this.unregisterAgent(agentId);
2820
+ const failures = [];
2821
+ for (const agentId of [...this.bindings.keys()]) {
2822
+ try {
2823
+ await this.unregisterAgentAndWait(agentId);
2824
+ } catch (error) {
2825
+ failures.push(error);
2826
+ }
2827
+ }
2828
+ if (failures.length > 0) {
2829
+ throw new AggregateError(failures, 'One or more ACP Agent process trees did not exit');
2830
+ }
1831
2831
  if (this.checkpointStore) await this.checkpointStore.dispose();
2832
+ this.disposed = true;
1832
2833
  }
1833
2834
  }
1834
2835
 
@@ -1839,8 +2840,10 @@ module.exports = {
1839
2840
  acpErrorKind,
1840
2841
  autoPermissionResponse,
1841
2842
  codexAcpEnvironment,
2843
+ describeAcpProcessGroup,
1842
2844
  promptContentForCapabilities,
1843
2845
  resolveAcpLaunch,
2846
+ stopPersistedAcpProcessGroup,
1844
2847
  supportsCodexSteer,
1845
2848
  isCodexSteerUnavailableError,
1846
2849
  CODEX_STEER_METHOD,