farming-code 2.2.8 → 2.2.11

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 (102) hide show
  1. package/README.md +36 -23
  2. package/README.zh_cn.md +36 -23
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1482 -730
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +45 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +30 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/farming-net-pass.js +285 -0
  22. package/backend/farming-net-registry.js +112 -0
  23. package/backend/farming-net-server.js +298 -0
  24. package/backend/farming-session-store.js +5 -13
  25. package/backend/git-worktree-info.js +181 -0
  26. package/backend/local-session-engine.js +411 -186
  27. package/backend/main-page-session.js +5 -2
  28. package/backend/native-pty-controller-generation.js +106 -0
  29. package/backend/native-pty-host-client.js +275 -7
  30. package/backend/native-pty-host-identity.js +86 -0
  31. package/backend/native-pty-host.js +813 -114
  32. package/backend/native-session-engine.js +100 -28
  33. package/backend/packaged-node-pty.js +22 -2
  34. package/backend/provider-adapters.js +253 -0
  35. package/backend/provider-session-service.js +241 -0
  36. package/backend/runtime-observation.js +81 -0
  37. package/backend/server.js +294 -86
  38. package/backend/session-engine-bridge.js +21 -2
  39. package/backend/session-engine-router.js +1 -1
  40. package/backend/session-engine.js +1 -1
  41. package/backend/session-stream-protocol.js +185 -0
  42. package/backend/storage-layout.js +55 -0
  43. package/backend/terminal-attach-checkpoint.js +74 -0
  44. package/backend/terminal-exit-quiescence.js +39 -0
  45. package/backend/terminal-reducer-flow-control.js +97 -0
  46. package/backend/terminal-screen-state.js +11 -2
  47. package/backend/terminal-screen-worker-pool.js +59 -6
  48. package/backend/terminal-screen-worker-thread.js +97 -57
  49. package/backend/terminal-screen-worker.js +133 -51
  50. package/backend/terminal-state-serialization.js +127 -0
  51. package/backend/terminal-status.js +23 -4
  52. package/backend/usage-monitor.js +81 -4
  53. package/backend/workspace-directory.js +152 -0
  54. package/backend/workspace-file-router.js +182 -76
  55. package/backend/workspace-file-service.js +319 -4
  56. package/backend/workspace-root-registry.js +164 -0
  57. package/dist/assets/App-CHYpgECZ.js +208 -0
  58. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview--Cri80nQ.js} +92 -92
  59. package/dist/assets/FileEditorPane-DGJlEKdf.js +2 -0
  60. package/dist/assets/IconGlyphs-Cc14sepw.js +1 -0
  61. package/dist/assets/ProjectFilesSection-Cz1wG4NT.js +12 -0
  62. package/dist/assets/{ReviewPage-BaXu1ZdX.js → ReviewPage-LonRc6Fz.js} +1 -1
  63. package/dist/assets/code-dark-B1pAtO_P.css +1 -0
  64. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  65. package/dist/assets/{index-BrbljRqn.js → index-miO2r9IZ.js} +3 -3
  66. package/dist/assets/main-DNe0jPw-.css +1 -0
  67. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  68. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +1 -0
  69. package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +4 -0
  70. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  71. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  72. package/dist/index.html +1 -1
  73. package/frontend/farming-net/app.css +625 -0
  74. package/frontend/farming-net/app.js +268 -0
  75. package/frontend/farming-net/index.html +86 -0
  76. package/frontend/reading-anchor.js +198 -0
  77. package/frontend/session-bridge.js +12 -3
  78. package/frontend/session-modal-bridge.js +5 -12
  79. package/frontend/skins/crt/app.js +1976 -793
  80. package/frontend/skins/crt/index.html +313 -23
  81. package/frontend/skins/crt/styles/billing.css +294 -223
  82. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  83. package/frontend/terminal-replay.js +372 -0
  84. package/package.json +10 -3
  85. package/shared/browser-protocol.d.ts +5 -0
  86. package/shared/browser-protocol.js +130 -0
  87. package/dist/assets/App-8dYAM6ql.js +0 -124
  88. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  89. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  90. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  91. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  92. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  93. package/dist/assets/main-D073SnW4.css +0 -1
  94. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  95. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  96. package/dist/assets/qoder-gHCinseV.svg +0 -1
  97. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  98. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  99. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  100. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  101. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  102. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -5,17 +5,18 @@ const { spawn } = require('child_process');
5
5
  const { Readable, Writable } = require('stream');
6
6
  const { createRequire } = require('module');
7
7
  const packageJson = require('../package.json');
8
+ const { AcpCheckpointStore } = require('./acp-checkpoint-store');
8
9
  const { AcpSessionState } = require('./acp-session-state');
10
+ const { readCodexHistoryImageData } = require('./codex-transcript');
9
11
  const { AcpClientFileSystem, AcpClientTerminalManager } = require('./acp/client-services');
10
12
  const { permissionSecurityWarnings } = require('./acp/permission-security');
11
13
  const { rejectPatch } = require('./acp/patch-decisions');
14
+ const { getProviderAdapter, listProviderAdapters } = require('./provider-adapters');
15
+
16
+ const ADAPTER_VERSIONS = Object.freeze(Object.fromEntries(
17
+ listProviderAdapters().filter(adapter => adapter.acp).map(adapter => [adapter.id, adapter.acp.version]),
18
+ ));
12
19
 
13
- const ADAPTER_VERSIONS = Object.freeze({
14
- codex: '1.1.2',
15
- claude: '0.58.1',
16
- opencode: 'native',
17
- qoder: 'native',
18
- });
19
20
  const DEFAULT_INITIALIZE_TIMEOUT_MS = 15_000;
20
21
  const DEFAULT_SESSION_SETUP_TIMEOUT_MS = 120_000;
21
22
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -44,67 +45,38 @@ function adapterEntry(packageName) {
44
45
  return entry;
45
46
  }
46
47
 
47
- function resolveAcpLaunch(provider, options = {}) {
48
- const normalized = String(provider || '').trim().toLowerCase();
49
- if (normalized === 'codex') {
50
- return {
51
- command: process.execPath,
52
- args: [adapterEntry('@agentclientprotocol/codex-acp')],
53
- version: ADAPTER_VERSIONS.codex,
54
- };
55
- }
56
- if (normalized === 'claude') {
48
+ function nodeAdapterLaunch(entry, env = process.env) {
49
+ const runtimeEnv = env && typeof env === 'object' ? env : process.env;
50
+ const nodeBin = runtimeEnv.FARMING_NODE_BIN || process.execPath;
51
+ const ldPath = runtimeEnv.FARMING_NODE_LD || '';
52
+ const libraryPath = runtimeEnv.FARMING_NODE_LIBRARY_PATH || '';
53
+ if (ldPath && libraryPath) {
57
54
  return {
58
- command: process.execPath,
59
- args: [adapterEntry('@agentclientprotocol/claude-agent-acp')],
60
- version: ADAPTER_VERSIONS.claude,
55
+ command: ldPath,
56
+ args: ['--library-path', libraryPath, nodeBin, entry],
61
57
  };
62
58
  }
63
- if (normalized === 'opencode') {
64
- return {
65
- command: options.executable || 'opencode',
66
- args: ['acp', '--cwd', path.resolve(options.cwd || process.cwd())],
67
- version: ADAPTER_VERSIONS.opencode,
68
- };
59
+ return { command: nodeBin, args: [entry] };
60
+ }
61
+
62
+ function resolveAcpLaunch(provider, options = {}) {
63
+ const adapter = getProviderAdapter(provider);
64
+ if (!adapter?.acp) throw new Error(`Unsupported ACP provider: ${provider}`);
65
+ if (adapter.acp.launch) {
66
+ return { ...adapter.acp.launch(options), version: adapter.acp.version };
69
67
  }
70
- if (normalized === 'qoder') {
71
- return {
72
- command: options.executable || 'qodercli',
73
- args: ['--acp'],
74
- version: ADAPTER_VERSIONS.qoder,
75
- };
68
+ if (adapter.acp.packageName) {
69
+ const launch = nodeAdapterLaunch(
70
+ adapterEntry(adapter.acp.packageName),
71
+ options.runtimeEnv || process.env,
72
+ );
73
+ return { ...launch, version: adapter.acp.version };
76
74
  }
77
75
  throw new Error(`Unsupported ACP provider: ${provider}`);
78
76
  }
79
77
 
80
78
  function codexAcpEnvironment(options = {}) {
81
- const env = { ...(options.env || process.env) };
82
- let config = {};
83
- if (env.CODEX_CONFIG) {
84
- try {
85
- const parsed = JSON.parse(env.CODEX_CONFIG);
86
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) config = parsed;
87
- } catch {
88
- // A selected Farming profile below replaces an invalid adapter config.
89
- }
90
- }
91
-
92
- if (options.model && options.model !== 'config') config.model = options.model;
93
- if (options.reasoningEffort && options.reasoningEffort !== 'config') {
94
- config.model_reasoning_effort = options.reasoningEffort;
95
- }
96
- if (options.serviceTier && !['config', 'default'].includes(options.serviceTier)) {
97
- config.service_tier = options.serviceTier;
98
- }
99
- if (Object.keys(config).length > 0) env.CODEX_CONFIG = JSON.stringify(config);
100
-
101
- const initialMode = {
102
- ask: 'read-only',
103
- approve: 'agent',
104
- full: 'agent-full-access',
105
- }[options.approvalMode];
106
- if (initialMode) env.INITIAL_AGENT_MODE = initialMode;
107
- return env;
79
+ return getProviderAdapter('codex').prepareAcpEnvironment(options);
108
80
  }
109
81
 
110
82
  function selectedPermission(option) {
@@ -226,6 +198,10 @@ function interactiveRuntimeState(binding, fallback = '') {
226
198
  return binding.sessionId ? 'idle' : 'connecting';
227
199
  }
228
200
 
201
+ function clone(value) {
202
+ return value == null ? value : JSON.parse(JSON.stringify(value));
203
+ }
204
+
229
205
  class AcpRuntime extends EventEmitter {
230
206
  constructor(options = {}) {
231
207
  super();
@@ -240,6 +216,8 @@ class AcpRuntime extends EventEmitter {
240
216
  this.historyReplayMinWaitMs = options.historyReplayMinWaitMs ?? DEFAULT_HISTORY_REPLAY_MIN_WAIT_MS;
241
217
  this.historyReplayQuietMs = options.historyReplayQuietMs ?? DEFAULT_HISTORY_REPLAY_QUIET_MS;
242
218
  this.historyReplayMaxWaitMs = options.historyReplayMaxWaitMs ?? DEFAULT_HISTORY_REPLAY_MAX_WAIT_MS;
219
+ this.checkpointStore = options.checkpointStore
220
+ || (options.configDir ? new AcpCheckpointStore(options.configDir, options.checkpointOptions) : null);
243
221
  this.bindings = new Map();
244
222
  this.permissionSequence = 0;
245
223
  this.elicitationSequence = 0;
@@ -256,8 +234,9 @@ class AcpRuntime extends EventEmitter {
256
234
  const binding = {
257
235
  agentId,
258
236
  provider,
237
+ providerHomeId: String(options.providerHomeId || 'default'),
259
238
  cwd: path.resolve(options.cwd || process.cwd()),
260
- env: provider === 'codex' ? codexAcpEnvironment(options) : (options.env || process.env),
239
+ env: (getProviderAdapter(provider)?.prepareAcpEnvironment || (value => value.env || process.env))(options),
261
240
  launch,
262
241
  restartOptions: { ...options, agentId, provider },
263
242
  approvalMode: options.approvalMode || 'approve',
@@ -278,9 +257,11 @@ class AcpRuntime extends EventEmitter {
278
257
  subagentStates: new Map(),
279
258
  interactionOrigins: new Map(),
280
259
  promptActive: false,
260
+ historyReplayActive: false,
281
261
  sessionState: null,
282
262
  authTerminal: null,
283
263
  patchDecisions: new Map(),
264
+ checkpointProof: null,
284
265
  stderr: '',
285
266
  updatedAt: new Date().toISOString(),
286
267
  };
@@ -337,25 +318,81 @@ class AcpRuntime extends EventEmitter {
337
318
  let historyMode = 'new';
338
319
  if (requestedSessionId) {
339
320
  const capabilities = binding.initializeResponse.agentCapabilities || {};
340
- if (options.historyMode !== 'resume' && capabilities.loadSession) {
321
+ let opened = false;
322
+ let restoredCheckpointState = null;
323
+ let restoredCheckpoint = null;
324
+ let saved = null;
325
+ if (options.historyMode === 'checkpoint' && this.checkpointStore) {
326
+ saved = await this.checkpointStore.load(
327
+ this.checkpointIdentity(binding, requestedSessionId),
328
+ { allowDirty: true },
329
+ );
330
+ restoredCheckpoint = this.restoreBindingCheckpoint(binding, saved?.state, {
331
+ sessionId: requestedSessionId,
332
+ });
333
+ restoredCheckpointState = restoredCheckpoint?.sessionState || null;
334
+ }
335
+ if (capabilities.sessionCapabilities?.resume && restoredCheckpoint) {
336
+ const providerStateMatches = restoredCheckpoint?.complete === true && saved?.exact === true
337
+ ? await this.checkpointMatchesProviderSession(connection, capabilities, sessionRequest, saved)
338
+ : false;
339
+ if (providerStateMatches) {
340
+ binding.sessionId = requestedSessionId;
341
+ binding.sessionState = restoredCheckpointState;
342
+ binding.subagentStates = restoredCheckpoint.subagentStates;
343
+ binding.patchDecisions = restoredCheckpoint.patchDecisions;
344
+ binding.checkpointProof = restoredCheckpoint.providerProof;
345
+ try {
346
+ sessionResponse = await withTimeout(
347
+ connection.resumeSession(sessionRequest),
348
+ this.sessionSetupTimeoutMs,
349
+ 'ACP session/resume'
350
+ );
351
+ historyMode = 'checkpoint';
352
+ opened = true;
353
+ } catch (error) {
354
+ if (!capabilities.loadSession) throw error;
355
+ console.warn(`ACP checkpoint resume failed for ${provider}; replaying history:`, error && (error.message || error));
356
+ }
357
+ }
358
+ }
359
+ if (!opened && options.historyMode !== 'resume' && capabilities.loadSession) {
360
+ const replayRevisionBase = Math.max(revisionBase, Number(restoredCheckpointState?.revision || 0));
341
361
  binding.sessionId = requestedSessionId;
342
362
  binding.sessionState = new AcpSessionState({
343
363
  provider,
344
364
  sessionId: requestedSessionId,
345
365
  cwd: binding.cwd,
346
366
  maxUpdates: this.maxUpdates,
347
- revisionBase,
348
- resetBeforeRevision: revisionBase,
367
+ revisionBase: replayRevisionBase,
368
+ resetBeforeRevision: replayRevisionBase,
349
369
  });
350
- sessionResponse = await withTimeout(
351
- connection.loadSession(sessionRequest),
352
- this.sessionSetupTimeoutMs,
353
- 'ACP session/load'
354
- );
355
- if (provider === 'qoder') await this.waitForHistoryReplay(binding);
370
+ binding.historyReplayActive = true;
371
+ try {
372
+ sessionResponse = await withTimeout(
373
+ connection.loadSession(sessionRequest),
374
+ this.sessionSetupTimeoutMs,
375
+ 'ACP session/load'
376
+ );
377
+ if (provider === 'qoder') await this.waitForHistoryReplay(binding);
378
+ } finally {
379
+ binding.historyReplayActive = false;
380
+ }
356
381
  binding.sessionState.finishHistoryReplay();
382
+ if (provider === 'codex' && binding.sessionState.hasCodexHistoryImageReferences()) {
383
+ let imageDataByPath = new Map();
384
+ try {
385
+ imageDataByPath = await readCodexHistoryImageData(requestedSessionId, {
386
+ codexHome: binding.env.CODEX_HOME,
387
+ });
388
+ } catch {
389
+ // Local history images can still be restored from their adapter paths.
390
+ }
391
+ await binding.sessionState.hydrateCodexHistoryAttachments({ imageDataByPath });
392
+ }
357
393
  historyMode = 'load';
358
- } else if (capabilities.sessionCapabilities?.resume) {
394
+ opened = true;
395
+ } else if (!opened && capabilities.sessionCapabilities?.resume) {
359
396
  binding.sessionId = requestedSessionId;
360
397
  binding.sessionState = new AcpSessionState({
361
398
  provider,
@@ -370,8 +407,9 @@ class AcpRuntime extends EventEmitter {
370
407
  'ACP session/resume'
371
408
  );
372
409
  historyMode = 'resume';
410
+ opened = true;
373
411
  } else {
374
- throw new Error(`${provider} ACP Agent cannot load or resume session ${requestedSessionId}`);
412
+ if (!opened) throw new Error(`${provider} ACP Agent cannot load or resume session ${requestedSessionId}`);
375
413
  }
376
414
  } else {
377
415
  sessionResponse = await withTimeout(
@@ -399,6 +437,7 @@ class AcpRuntime extends EventEmitter {
399
437
  }
400
438
  binding.state = 'idle';
401
439
  binding.updatedAt = new Date().toISOString();
440
+ this.scheduleCheckpoint(binding, { exact: true });
402
441
  this.emitRuntime(binding);
403
442
  this.emitSession(binding);
404
443
  return {
@@ -417,6 +456,130 @@ class AcpRuntime extends EventEmitter {
417
456
  }
418
457
  }
419
458
 
459
+ checkpointIdentity(binding, sessionId = binding?.sessionId) {
460
+ if (!binding || !sessionId) return null;
461
+ return {
462
+ provider: binding.provider,
463
+ providerHomeId: binding.providerHomeId || 'default',
464
+ sessionId,
465
+ cwd: binding.cwd,
466
+ };
467
+ }
468
+
469
+ scheduleCheckpoint(binding, _options = {}) {
470
+ const identity = this.checkpointIdentity(binding);
471
+ if (!this.checkpointStore || !identity || !binding.sessionState) return;
472
+ // ACP currently has no conditional resume or provider-owned revision
473
+ // token. A timestamp/list check has a TOCTOU window, so runtime snapshots
474
+ // remain dirty and are used only as revision/reset fences. Full history
475
+ // load is the authoritative recovery path until such a proof is available.
476
+ this.checkpointStore.schedule(identity, this.bindingCheckpoint(binding), { exact: false });
477
+ }
478
+
479
+ async markCheckpointDirty(binding) {
480
+ const identity = this.checkpointIdentity(binding);
481
+ if (!this.checkpointStore || !identity) return;
482
+ await this.checkpointStore.markDirty(identity);
483
+ }
484
+
485
+ async writeCheckpoint(binding, _options = {}) {
486
+ const identity = this.checkpointIdentity(binding);
487
+ if (!this.checkpointStore || !identity || !binding.sessionState) return;
488
+ await this.checkpointStore.write(identity, this.bindingCheckpoint(binding), { exact: false });
489
+ }
490
+
491
+ bindingCheckpoint(binding) {
492
+ return {
493
+ exportCheckpoint: () => ({
494
+ version: 2,
495
+ complete: false,
496
+ sessionState: binding.sessionState?.exportCheckpoint() || null,
497
+ subagentStates: [...binding.subagentStates.entries()].map(([sessionId, state]) => ({
498
+ sessionId,
499
+ state: state.exportCheckpoint(),
500
+ })),
501
+ patchDecisions: [...binding.patchDecisions.entries()],
502
+ providerProof: binding.checkpointProof ? clone(binding.checkpointProof) : null,
503
+ }),
504
+ };
505
+ }
506
+
507
+ restoreBindingCheckpoint(binding, checkpoint, options = {}) {
508
+ if (!checkpoint) return null;
509
+ const mainCheckpoint = checkpoint.version === 2
510
+ ? checkpoint.sessionState
511
+ : checkpoint;
512
+ const sessionState = AcpSessionState.fromCheckpoint(mainCheckpoint, {
513
+ provider: binding.provider,
514
+ sessionId: options.sessionId || binding.sessionId,
515
+ cwd: binding.cwd,
516
+ maxUpdates: this.maxUpdates,
517
+ });
518
+ if (!sessionState) return null;
519
+ const subagentStates = new Map();
520
+ if (checkpoint.version === 2 && Array.isArray(checkpoint.subagentStates)) {
521
+ for (const item of checkpoint.subagentStates.slice(0, 32)) {
522
+ const sessionId = String(item?.sessionId || '');
523
+ if (!sessionId) continue;
524
+ const state = AcpSessionState.fromCheckpoint(item?.state, {
525
+ provider: binding.provider,
526
+ sessionId,
527
+ cwd: binding.cwd,
528
+ maxUpdates: this.maxUpdates,
529
+ });
530
+ if (state) subagentStates.set(sessionId, state);
531
+ }
532
+ }
533
+ const patchDecisions = new Map();
534
+ if (checkpoint.version === 2 && Array.isArray(checkpoint.patchDecisions)) {
535
+ for (const item of checkpoint.patchDecisions) {
536
+ if (!Array.isArray(item) || item.length !== 2) continue;
537
+ const key = String(item[0] || '');
538
+ const decision = String(item[1] || '');
539
+ if (key && ['kept', 'reverted'].includes(decision)) patchDecisions.set(key, decision);
540
+ }
541
+ }
542
+ return {
543
+ sessionState,
544
+ subagentStates,
545
+ patchDecisions,
546
+ providerProof: checkpoint.version === 2 ? clone(checkpoint.providerProof) : null,
547
+ complete: checkpoint.version === 2 && checkpoint.complete === true,
548
+ };
549
+ }
550
+
551
+ async checkpointMatchesProviderSession(connection, capabilities, request, saved) {
552
+ const proof = saved?.state?.providerProof;
553
+ if (
554
+ !capabilities?.sessionCapabilities?.list
555
+ || !proof
556
+ || typeof proof.token !== 'string'
557
+ || !proof.token
558
+ ) return false;
559
+ let cursor = '';
560
+ try {
561
+ for (let page = 0; page < 10; page += 1) {
562
+ const response = await withTimeout(connection.listSessions({
563
+ cwd: request.cwd,
564
+ ...(cursor ? { cursor } : {}),
565
+ }), this.requestTimeoutMs, 'ACP session/list checkpoint validation');
566
+ const session = (response?.sessions || []).find(item => item?.sessionId === request.sessionId);
567
+ if (session) {
568
+ const sessionCwd = path.resolve(String(session.cwd || ''));
569
+ const token = String(session?._meta?.checkpointRevision || session?.checkpointRevision || '');
570
+ return sessionCwd === path.resolve(request.cwd)
571
+ && token === proof.token
572
+ && path.resolve(String(proof.cwd || '')) === path.resolve(request.cwd);
573
+ }
574
+ cursor = String(response?.nextCursor || '');
575
+ if (!cursor) return false;
576
+ }
577
+ } catch (error) {
578
+ console.warn('Failed to validate ACP checkpoint against provider session metadata:', error && (error.message || error));
579
+ }
580
+ return false;
581
+ }
582
+
420
583
  async officialConnection(handlers, child) {
421
584
  const sdk = await loadAcpSdk();
422
585
  const stream = sdk.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
@@ -473,7 +636,14 @@ class AcpRuntime extends EventEmitter {
473
636
  if (parentTool) binding.sessionState.touchEntry(parentTool);
474
637
  }
475
638
  binding.updatedAt = new Date().toISOString();
476
- this.emitSession(binding);
639
+ if (isPrimarySession && !binding.historyReplayActive && !binding.promptActive) {
640
+ this.scheduleCheckpoint(binding, { exact: true });
641
+ }
642
+ // A loaded history can contain hundreds of ordered updates. Applying
643
+ // them one by one is necessary, but broadcasting every replay step
644
+ // makes clients repeatedly abort/refetch and remount rich content.
645
+ // prepareAgent emits one complete snapshot after the replay settles.
646
+ if (!binding.historyReplayActive) this.emitSession(binding);
477
647
  }
478
648
  },
479
649
  requestPermission: request => this.requestPermission(binding, request),
@@ -588,6 +758,7 @@ class AcpRuntime extends EventEmitter {
588
758
  const binding = this.requireBinding(agentId);
589
759
  if (!['idle', 'error'].includes(binding.state)) throw new Error(`ACP Agent is not ready (${binding.state})`);
590
760
  const content = Array.isArray(prompt) ? prompt : [{ type: 'text', text: String(prompt || '') }];
761
+ await this.markCheckpointDirty(binding);
591
762
  binding.sessionState.beginPrompt(content);
592
763
  binding.promptActive = true;
593
764
  binding.state = 'working';
@@ -602,6 +773,7 @@ class AcpRuntime extends EventEmitter {
602
773
  binding.promptActive = false;
603
774
  binding.state = 'idle';
604
775
  binding.updatedAt = new Date().toISOString();
776
+ this.scheduleCheckpoint(binding, { exact: true });
605
777
  this.emitSession(binding);
606
778
  this.emitRuntime(binding);
607
779
  return { sessionId: binding.sessionId, stopReason: binding.stopReason };
@@ -616,6 +788,7 @@ class AcpRuntime extends EventEmitter {
616
788
  binding.promptActive = false;
617
789
  binding.state = 'error';
618
790
  binding.error = runtimeError.message;
791
+ this.scheduleCheckpoint(binding, { exact: true });
619
792
  this.emitSession(binding);
620
793
  this.emitRuntime(binding);
621
794
  throw runtimeError;
@@ -808,7 +981,11 @@ class AcpRuntime extends EventEmitter {
808
981
  approvalMode: binding.approvalMode,
809
982
  ...(binding.sessionId ? { sessionId: binding.sessionId } : {}),
810
983
  ...(revisionBase > 0 ? { revisionBase } : {}),
984
+ ...(binding.sessionId ? { historyMode: 'checkpoint' } : {}),
811
985
  };
986
+ if (binding.sessionState && !binding.promptActive) {
987
+ await this.writeCheckpoint(binding, { exact: true });
988
+ }
812
989
  this.unregisterAgent(agentId);
813
990
  return this.prepareAgent(options);
814
991
  }
@@ -862,12 +1039,19 @@ class AcpRuntime extends EventEmitter {
862
1039
  );
863
1040
  binding.sessionState.currentModeId = String(modeId || '');
864
1041
  if (binding.modes) binding.modes = { ...binding.modes, currentModeId: binding.sessionState.currentModeId };
1042
+ this.scheduleCheckpoint(binding, { exact: true });
865
1043
  this.emitSession(binding);
866
1044
  return { sessionId: binding.sessionId, modeId: binding.sessionState.currentModeId };
867
1045
  }
868
1046
 
869
1047
  async setSessionConfigOption(agentId, configId, value) {
870
1048
  const binding = this.requireBinding(agentId);
1049
+ return this.enqueueSessionConfigMutation(binding, () => (
1050
+ this.setSessionConfigOptionNow(binding, configId, value)
1051
+ ));
1052
+ }
1053
+
1054
+ async setSessionConfigOptionNow(binding, configId, value) {
871
1055
  const option = binding.configOptions?.find(candidate => candidate.id === String(configId || ''));
872
1056
  if (
873
1057
  binding.provider === 'codex'
@@ -885,7 +1069,7 @@ class AcpRuntime extends EventEmitter {
885
1069
  ));
886
1070
  if (typeof reasoning?.currentValue === 'string' && reasoning.currentValue) {
887
1071
  await this.refreshCodexSessionModel(binding, String(value ?? ''), reasoning.currentValue);
888
- return this.applySessionConfigOption(binding, configId, value);
1072
+ return this.applySessionConfigOption(binding, configId, value, { force: true });
889
1073
  }
890
1074
  this.emitSession(binding);
891
1075
  return { sessionId: binding.sessionId, configOptions: binding.configOptions };
@@ -895,6 +1079,12 @@ class AcpRuntime extends EventEmitter {
895
1079
 
896
1080
  async setSessionConfigOptions(agentId, changes) {
897
1081
  const binding = this.requireBinding(agentId);
1082
+ return this.enqueueSessionConfigMutation(binding, () => (
1083
+ this.setSessionConfigOptionsNow(binding, changes)
1084
+ ));
1085
+ }
1086
+
1087
+ async setSessionConfigOptionsNow(binding, changes) {
898
1088
  const normalized = Array.isArray(changes)
899
1089
  ? changes.filter(change => change && typeof change.configId === 'string' && Object.prototype.hasOwnProperty.call(change, 'value'))
900
1090
  : [];
@@ -926,17 +1116,28 @@ class AcpRuntime extends EventEmitter {
926
1116
  String(modelChange.value ?? ''),
927
1117
  String(currentReasoning?.currentValue || reasoningChange.value || '')
928
1118
  );
929
- response = await this.applySessionConfigOption(binding, modelChange.configId, modelChange.value);
1119
+ response = await this.applySessionConfigOption(binding, modelChange.configId, modelChange.value, { force: true });
930
1120
  handled.add(modelChange);
931
1121
  handled.add(reasoningChange);
932
1122
  }
933
1123
  for (const change of normalized) {
934
1124
  if (handled.has(change)) continue;
935
- response = await this.setSessionConfigOption(agentId, change.configId, change.value);
1125
+ response = await this.setSessionConfigOptionNow(binding, change.configId, change.value);
936
1126
  }
937
1127
  return response;
938
1128
  }
939
1129
 
1130
+ async enqueueSessionConfigMutation(binding, operation) {
1131
+ const previous = binding.configMutationTail || Promise.resolve();
1132
+ const pending = previous.catch(() => {}).then(operation);
1133
+ binding.configMutationTail = pending;
1134
+ try {
1135
+ return await pending;
1136
+ } finally {
1137
+ if (binding.configMutationTail === pending) binding.configMutationTail = null;
1138
+ }
1139
+ }
1140
+
940
1141
  async refreshCodexSessionModel(binding, model, effort) {
941
1142
  await withTimeout(
942
1143
  binding.connection.request(CODEX_SET_SESSION_MODEL_METHOD, {
@@ -949,16 +1150,31 @@ class AcpRuntime extends EventEmitter {
949
1150
  }
950
1151
 
951
1152
  async applySessionConfigOption(binding, configId, value, options = {}) {
1153
+ const normalizedConfigId = String(configId || '');
1154
+ const currentOption = binding.configOptions?.find(candidate => candidate.id === normalizedConfigId);
1155
+ if (options.force !== true && currentOption?.currentValue === value) {
1156
+ if (options.emit !== false) this.emitSession(binding);
1157
+ return { sessionId: binding.sessionId, configOptions: binding.configOptions };
1158
+ }
952
1159
  const request = typeof value === 'boolean'
953
- ? { sessionId: binding.sessionId, configId: String(configId || ''), type: 'boolean', value }
954
- : { sessionId: binding.sessionId, configId: String(configId || ''), value: String(value ?? '') };
955
- const response = await withTimeout(
956
- binding.connection.setSessionConfigOption(request),
957
- this.requestTimeoutMs,
958
- 'ACP session/set_config_option'
959
- );
960
- binding.configOptions = response?.configOptions || binding.configOptions;
1160
+ ? { sessionId: binding.sessionId, configId: normalizedConfigId, type: 'boolean', value }
1161
+ : { sessionId: binding.sessionId, configId: normalizedConfigId, value: String(value ?? '') };
1162
+ let response;
1163
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1164
+ response = await withTimeout(
1165
+ binding.connection.setSessionConfigOption(request),
1166
+ this.requestTimeoutMs,
1167
+ 'ACP session/set_config_option'
1168
+ );
1169
+ binding.configOptions = response?.configOptions || binding.configOptions;
1170
+ const confirmed = binding.configOptions?.find(candidate => candidate.id === normalizedConfigId);
1171
+ if (confirmed?.currentValue === request.value) break;
1172
+ if (attempt === 1) {
1173
+ throw new Error(`ACP Agent did not confirm config option ${normalizedConfigId}`);
1174
+ }
1175
+ }
961
1176
  binding.sessionState.configOptions = JSON.parse(JSON.stringify(binding.configOptions));
1177
+ this.scheduleCheckpoint(binding, { exact: true });
962
1178
  if (options.emit !== false) this.emitSession(binding);
963
1179
  return { sessionId: binding.sessionId, configOptions: binding.configOptions };
964
1180
  }
@@ -1070,6 +1286,7 @@ class AcpRuntime extends EventEmitter {
1070
1286
  };
1071
1287
  binding.sessionState?.touchEntry(entry);
1072
1288
  binding.updatedAt = new Date().toISOString();
1289
+ this.scheduleCheckpoint(binding, { exact: true });
1073
1290
  this.emitSession(binding);
1074
1291
  return { ...result, toolCallId: String(toolCallId || '') };
1075
1292
  }
@@ -1202,6 +1419,7 @@ class AcpRuntime extends EventEmitter {
1202
1419
  unregisterAgent(agentId) {
1203
1420
  const binding = this.bindings.get(agentId);
1204
1421
  if (!binding) return;
1422
+ if (binding.sessionState && !binding.promptActive) this.scheduleCheckpoint(binding, { exact: true });
1205
1423
  this.bindings.delete(agentId);
1206
1424
  for (const resolve of binding.permissionResolvers.values()) resolve({ outcome: { outcome: 'cancelled' } });
1207
1425
  for (const resolve of binding.elicitationResolvers.values()) resolve({ action: 'cancel' });
@@ -1221,8 +1439,13 @@ class AcpRuntime extends EventEmitter {
1221
1439
  if (binding.child && !binding.child.killed) binding.child.kill('SIGTERM');
1222
1440
  }
1223
1441
 
1224
- dispose() {
1442
+ async dispose() {
1443
+ for (const binding of this.bindings.values()) {
1444
+ if (binding.sessionState && !binding.promptActive) this.scheduleCheckpoint(binding, { exact: true });
1445
+ }
1446
+ if (this.checkpointStore) await this.checkpointStore.flush();
1225
1447
  for (const agentId of [...this.bindings.keys()]) this.unregisterAgent(agentId);
1448
+ if (this.checkpointStore) await this.checkpointStore.dispose();
1226
1449
  }
1227
1450
  }
1228
1451