farming-code 2.2.20 → 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 (49) 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 +332 -102
  22. package/backend/storage-layout.js +10 -0
  23. package/backend/theme-manager.js +23 -5
  24. package/backend/update-service.js +239 -27
  25. package/backend/workspace-file-router.js +9 -3
  26. package/backend/workspace-file-service.js +292 -62
  27. package/dist/acp/codex-acp-1.1.4.js +32 -1
  28. package/dist/assets/App-CmZdNavW.js +210 -0
  29. package/dist/assets/{FileEditorMarkdownPreview-R0jwwLME.js → FileEditorMarkdownPreview-KbnE8VC8.js} +1 -1
  30. package/dist/assets/FileEditorPane-5_HUejjd.js +2 -0
  31. package/dist/assets/IconGlyphs-C2WFcoJS.js +1 -0
  32. package/dist/assets/ProjectFilesSection-C1RAJWOE.js +12 -0
  33. package/dist/assets/{ReviewPage-DBKNOWsJ.js → ReviewPage-L1vh1Ggs.js} +3 -3
  34. package/dist/assets/code-dark-BKtvf0Xc.css +1 -0
  35. package/dist/assets/file-icons-CHWt5nSy.js +1 -0
  36. package/dist/assets/{index-DuMjesL2.js → index-RvUiMpGP.js} +2 -2
  37. package/dist/assets/main-XMXgyJJt.css +1 -0
  38. package/dist/assets/workspace-view-state-BqB4HM7r.js +7 -0
  39. package/dist/index.html +1 -1
  40. package/frontend/skins/crt/app.js +199 -81
  41. package/package.json +9 -3
  42. package/dist/assets/App-BupehIbc.js +0 -210
  43. package/dist/assets/FileEditorPane-CJs6kfZO.js +0 -2
  44. package/dist/assets/IconGlyphs-CZzDr5o2.js +0 -1
  45. package/dist/assets/ProjectFilesSection-DWCV07lA.js +0 -12
  46. package/dist/assets/code-dark-BAt0Q_Op.css +0 -1
  47. package/dist/assets/file-icons-DpbrD5ZP.js +0 -1
  48. package/dist/assets/main-y2aK_56c.css +0 -1
  49. package/dist/assets/workspace-view-state-BpPINu84.js +0 -7
@@ -1,5 +1,6 @@
1
1
  const EventEmitter = require('events');
2
2
  const { execFile } = require('child_process');
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const os = require('os');
5
6
  const path = require('path');
@@ -32,13 +33,17 @@ const {
32
33
  applyProviderHomeEnvironment,
33
34
  getProviderAdapter,
34
35
  isFreshAcpSessionSource,
36
+ providerAcpForkMode,
35
37
  providerCapabilities,
36
38
  providerForProgram,
37
39
  providerSupportsRuntime,
38
40
  } = require('./provider-adapters');
39
41
  const { deriveTerminalStatus } = require('./terminal-status');
40
42
  const { JsonCliRuntime } = require('./json-cli-runtime');
41
- const { AcpRuntime, isCodexSteerUnavailableError } = require('./acp-runtime');
43
+ const {
44
+ AcpRuntime,
45
+ stopPersistedAcpProcessGroup,
46
+ } = require('./acp-runtime');
42
47
  const { chatRuntimeForProvider, isChatMode } = require('./chat-runtime');
43
48
  const { acpToolChanges, acpToolDetail, acpToolReviewChanges, acpTranscriptToolEntry } = require('./acp-transcript');
44
49
  const {
@@ -62,6 +67,15 @@ const { inspectGitWorktree } = require('./git-worktree-info');
62
67
  const { deserializeTerminalState } = require('./terminal-state-serialization');
63
68
  const { compareNativePtyRuntimeEpochs } = require('./native-pty-controller-generation');
64
69
  const { canonicalWorkspacePath } = require('./workspace-root-registry');
70
+ const {
71
+ TERMINAL_OPERATION_STATES,
72
+ activeLifecycleOperation,
73
+ beginLifecycleOperation,
74
+ latestLifecycleOperation,
75
+ lifecycleJournal,
76
+ setLifecycleOperationResult,
77
+ transitionLifecycleOperation,
78
+ } = require('./agent-lifecycle-journal');
65
79
 
66
80
  const SESSION_OUTPUT_LIMIT = 10000;
67
81
  const AGENT_USAGE_RATE_WINDOW_MS = 5 * 60 * 1000;
@@ -80,6 +94,7 @@ const UNCERTAIN_TERMINAL_STOP_TIMEOUT_MS = 5_000;
80
94
  const TERMINAL_STOP_STATE_READ_TIMEOUT_MS = 1_000;
81
95
  const TERMINAL_STOP_POLL_MS = 50;
82
96
  const CODEX_TERMINAL_START_READY_TIMEOUT_MS = 30_000;
97
+ const WORKTREE_DELETE_START_DRAIN_TIMEOUT_MS = 30_000;
83
98
  const CODEX_TERMINAL_START_READY_POLL_MS = 50;
84
99
  const CODEX_TERMINAL_START_OUTPUT_LIMIT = 64 * 1024;
85
100
  const SHELL_PROMPT_ENV_KEYS = [
@@ -92,8 +107,92 @@ const SHELL_PROMPT_ENV_KEYS = [
92
107
  'RPS1',
93
108
  'PROMPT_COMMAND',
94
109
  ];
110
+ const CREATE_ROLLBACK_FIELDS = [
111
+ 'runtimeAgentId',
112
+ 'command',
113
+ 'forkCommand',
114
+ 'cwd',
115
+ 'projectWorkspace',
116
+ 'mainWorkspace',
117
+ 'source',
118
+ 'providerHomePath',
119
+ 'providerSessionTemporary',
120
+ 'providerSessionSource',
121
+ 'providerSessionResolvedAt',
122
+ 'providerSessionTitle',
123
+ 'providerSessionWorkspace',
124
+ 'terminalInputReceived',
125
+ 'structuredRuntimeProcess',
126
+ 'legacyAcpProcessExitAcknowledgedAt',
127
+ 'acpAdditionalDirectories',
128
+ 'acpMcpServers',
129
+ 'agentRuntimeMode',
130
+ 'acpState',
131
+ 'acpError',
132
+ 'acpStopReason',
133
+ 'acpPendingPermission',
134
+ 'acpPendingPermissions',
135
+ 'acpPendingElicitation',
136
+ 'acpPendingElicitations',
137
+ 'acpActiveElicitations',
138
+ 'acpSessionUpdatedAt',
139
+ 'acpSessionRevision',
140
+ 'jsonCliState',
141
+ 'jsonCliError',
142
+ 'jsonCliTranscriptUpdatedAt',
143
+ 'engine',
144
+ 'category',
145
+ 'task',
146
+ 'workflowTemplate',
147
+ 'wantsMain',
148
+ 'pinned',
149
+ 'projectOrder',
150
+ 'pinnedOrder',
151
+ 'attentionSeq',
152
+ 'readAttentionSeq',
153
+ 'attentionUpdatedAt',
154
+ 'readAttentionAt',
155
+ 'attentionReason',
156
+ 'attentionOutputEpoch',
157
+ 'attentionOutputSeq',
158
+ 'readOutputEpoch',
159
+ 'readOutputSeq',
160
+ 'archived',
161
+ 'archivedAt',
162
+ 'visibleOnMainPage',
163
+ 'customTitle',
164
+ 'title',
165
+ 'startedAt',
166
+ ];
95
167
  const execFileAsync = promisify(execFile);
96
168
 
169
+ function createRollbackState(record) {
170
+ if (!record || typeof record !== 'object') return null;
171
+ const state = {};
172
+ for (const field of CREATE_ROLLBACK_FIELDS) {
173
+ const value = Object.prototype.hasOwnProperty.call(record, field)
174
+ ? record[field]
175
+ : null;
176
+ state[field] = value === undefined ? null : JSON.parse(JSON.stringify(value));
177
+ }
178
+ state.runtimeAgentId = String(record.runtimeAgentId || '');
179
+ state.visibleOnMainPage = record.visibleOnMainPage === true;
180
+ state.archived = record.archived === true;
181
+ state.customTitle = typeof record.customTitle === 'string' ? record.customTitle : '';
182
+ return state;
183
+ }
184
+
185
+ function createFailurePatch(operation, fallbackRuntimeAgentId = '') {
186
+ const previousState = operation?.request?.previousState;
187
+ if (previousState && typeof previousState === 'object') {
188
+ return JSON.parse(JSON.stringify(previousState));
189
+ }
190
+ return {
191
+ visibleOnMainPage: Boolean(fallbackRuntimeAgentId),
192
+ runtimeAgentId: String(fallbackRuntimeAgentId || ''),
193
+ };
194
+ }
195
+
97
196
  function withBoundedWait(promise, timeoutMs, label) {
98
197
  let timer;
99
198
  const timeout = new Promise((_, reject) => {
@@ -395,6 +494,12 @@ function agentAttentionUnread(agent) {
395
494
  return finiteNonNegativeInteger(agent && agent.attentionSeq) > finiteNonNegativeInteger(agent && agent.readAttentionSeq);
396
495
  }
397
496
 
497
+ function setAgentRecordId(agent, agentRecordId) {
498
+ if (!agent || typeof agentRecordId !== 'string' || !agentRecordId) return;
499
+ agent.agentRecordId = agentRecordId;
500
+ agent.persistentSessionId = agentRecordId;
501
+ }
502
+
398
503
  function hasAgentOutputAfterAttentionBaseline(agent) {
399
504
  if (!agent || agent.attentionRequiresNewOutput !== true) return true;
400
505
  const baselineSeq = finiteNumberOrNull(agent.attentionBaselineOutputSeq);
@@ -414,6 +519,15 @@ function hasAgentOutputAfterAttentionBaseline(agent) {
414
519
 
415
520
  function shouldRestoreAgentFromMetadata(record, mainPageSessionKeys) {
416
521
  if (!record || record.archived === true) return false;
522
+ const latestOperation = latestLifecycleOperation(record);
523
+ if (latestOperation?.type === 'delete' && latestOperation.state === 'succeeded') return false;
524
+ if (
525
+ latestOperation?.type === 'create'
526
+ && latestOperation.state === 'succeeded'
527
+ && record.visibleOnMainPage === true
528
+ ) {
529
+ return true;
530
+ }
417
531
  if (record.wantsMain === true) return true;
418
532
  if (
419
533
  record.providerSessionTemporary === true
@@ -431,6 +545,43 @@ function shouldRestoreAgentFromMetadata(record, mainPageSessionKeys) {
431
545
  return record.visibleOnMainPage === true;
432
546
  }
433
547
 
548
+ function lifecycleOperationBlocksRuntimeStart(record) {
549
+ const operation = activeLifecycleOperation(record);
550
+ return operation && ['create', 'delete', 'archive', 'runtime-switch'].includes(operation.type)
551
+ ? operation
552
+ : null;
553
+ }
554
+
555
+ function requiresLegacyAcpExitAcknowledgement(record) {
556
+ if (
557
+ runtimeKind(record) !== 'acp'
558
+ || record?.structuredRuntimeProcess
559
+ || record?.legacyAcpProcessExitAcknowledgedAt
560
+ ) {
561
+ return false;
562
+ }
563
+ if (record?.requiresProcessExitAcknowledgement === true) return true;
564
+ const operation = activeLifecycleOperation(record);
565
+ return Boolean(
566
+ operation
567
+ && ['delete', 'archive'].includes(operation.type)
568
+ && operation.request?.structuredProcessProofRequired === true,
569
+ );
570
+ }
571
+
572
+ function publicActiveLifecycleOperation(agent) {
573
+ const operation = activeLifecycleOperation(agent);
574
+ if (!operation) return null;
575
+ return {
576
+ id: operation.id,
577
+ type: operation.type,
578
+ state: operation.state,
579
+ error: operation.error || '',
580
+ startedAt: operation.startedAt || null,
581
+ updatedAt: operation.updatedAt || null,
582
+ };
583
+ }
584
+
434
585
  function recoveredEngineSessionId(entry, metadata = {}) {
435
586
  return entry && (entry.sessionId || entry.agentId || metadata.agentId) || '';
436
587
  }
@@ -440,6 +591,33 @@ function agentDisplayName(command) {
440
591
  return getProviderAdapter(providerForProgram(program))?.displayName || program;
441
592
  }
442
593
 
594
+ function stableJsonValue(value) {
595
+ if (Array.isArray(value)) return value.map(stableJsonValue);
596
+ if (!value || typeof value !== 'object') return value;
597
+ return Object.keys(value).sort().reduce((result, key) => {
598
+ const child = value[key];
599
+ if (!['function', 'symbol', 'undefined'].includes(typeof child)) {
600
+ result[key] = stableJsonValue(child);
601
+ }
602
+ return result;
603
+ }, {});
604
+ }
605
+
606
+ function createOperationSignature(command, customWorkspace, options = {}) {
607
+ const semanticOptions = { ...options };
608
+ [
609
+ 'createRequestId',
610
+ 'lifecycleToken',
611
+ 'skipRecoveryWait',
612
+ 'startAdmissionToken',
613
+ ].forEach(field => delete semanticOptions[field]);
614
+ return crypto.createHash('sha256').update(JSON.stringify(stableJsonValue({
615
+ command: String(command || '').trim(),
616
+ workspace: String(customWorkspace || ''),
617
+ options: semanticOptions,
618
+ }))).digest('hex');
619
+ }
620
+
443
621
  function titleComparisonKey(title) {
444
622
  return String(title || '')
445
623
  .trim()
@@ -606,9 +784,13 @@ class AgentManager extends EventEmitter {
606
784
  this.codexTerminalStartQueues = new Map();
607
785
  this.codexTerminalStartOutput = new Map();
608
786
  this.agentWorktreeResolveGeneration = new Map();
609
- this.permissionRestartInFlight = new Map();
610
- this.runtimeRestartInFlight = new Map();
611
- this.codexSessionUnarchiveInFlight = new Map();
787
+ this.agentLifecycleOperations = new Map();
788
+ this.agentStartAdmissions = new Map();
789
+ this.createRequestAdmissions = new Map();
790
+ this.projectWorkspaceDeleteAdmissions = new Map();
791
+ this.activeInputOperations = new Set();
792
+ this.verifiedStoppedAgentIds = new Set();
793
+ this.codexSessionMutationQueues = new Map();
612
794
  // Standard ACP session inputs may contain MCP credentials. Keep the live
613
795
  // copy outside browser-facing Agent records; crash recovery persists it
614
796
  // only through the private Farming session store.
@@ -635,7 +817,16 @@ class AgentManager extends EventEmitter {
635
817
  : deletePrecreatedProviderSession;
636
818
  this.archiveCodexSession = options.archiveCodexSession || archiveCodexSession;
637
819
  this.unarchiveCodexSession = options.unarchiveCodexSession || unarchiveCodexSession;
820
+ this.stopPersistedAcpProcessGroup = options.stopPersistedAcpProcessGroup
821
+ || stopPersistedAcpProcessGroup;
822
+ // Upgrade compatibility is the product default: records created before
823
+ // ACP process identities existed must still resume their provider Session.
824
+ // Strict callers may opt out explicitly for cleanup-safety diagnostics.
825
+ this.allowUnprovenLegacyAcpRecovery = options.allowUnprovenLegacyAcpRecovery !== false;
638
826
  this.heartbeatInterval = null;
827
+ this.disposing = false;
828
+ this.disposeFrozen = false;
829
+ this.disposePromise = null;
639
830
  this.disposed = false;
640
831
  this.systemMonitor = new SystemMonitor();
641
832
  this.startTime = Date.now();
@@ -657,6 +848,8 @@ class AgentManager extends EventEmitter {
657
848
  },
658
849
  });
659
850
  this.recoveryPromise = Promise.resolve();
851
+ this.recoveryComplete = !(this.configManager && this.configManager.farmingDir);
852
+ this.recoveryError = null;
660
853
  this.taskHistory = (this.configManager && this.configManager.getTaskHistory)
661
854
  ? [...this.configManager.getTaskHistory()]
662
855
  : [];
@@ -666,9 +859,14 @@ class AgentManager extends EventEmitter {
666
859
  this.bindJsonCliRuntimeEvents();
667
860
  this.bindAcpRuntimeEvents();
668
861
  if (this.configManager && this.configManager.farmingDir) {
669
- this.recoveryPromise = this.recoverEngineSessions().catch((error) => {
670
- console.warn('Failed to recover engine sessions:', error && (error.message || error));
671
- });
862
+ this.recoveryPromise = this.recoverEngineSessions()
863
+ .then(() => {
864
+ this.recoveryComplete = true;
865
+ })
866
+ .catch((error) => {
867
+ this.recoveryError = error;
868
+ console.warn('Failed to recover engine sessions:', error && (error.message || error));
869
+ });
672
870
  }
673
871
  }
674
872
 
@@ -699,7 +897,7 @@ class AgentManager extends EventEmitter {
699
897
 
700
898
  bindAcpRuntimeEvents() {
701
899
  if (!this.acpRuntime || typeof this.acpRuntime.on !== 'function') return;
702
- this.acpRuntime.on('agent-runtime', ({ agentId, state, error, sessionId, stopReason, supportsSteer, pendingPermission, pendingPermissions, pendingElicitation, pendingElicitations, activeElicitations, updatedAt }) => {
900
+ this.acpRuntime.on('agent-runtime', ({ agentId, state, error, sessionId, stopReason, supportsSteer, supportsFork, pendingPermission, pendingPermissions, pendingElicitation, pendingElicitations, activeElicitations, updatedAt }) => {
703
901
  const agent = this.agents.get(agentId);
704
902
  const runtime = runtimeBindingOf(agent, 'acp');
705
903
  if (!runtime) return;
@@ -707,6 +905,7 @@ class AgentManager extends EventEmitter {
707
905
  runtime.error = error || '';
708
906
  runtime.stopReason = stopReason || '';
709
907
  runtime.supportsSteer = supportsSteer === true;
908
+ runtime.supportsFork = supportsFork === true;
710
909
  runtime.pendingPermission = pendingPermission || null;
711
910
  runtime.pendingPermissions = Array.isArray(pendingPermissions) ? pendingPermissions : [];
712
911
  runtime.pendingElicitation = pendingElicitation || null;
@@ -1153,6 +1352,9 @@ class AgentManager extends EventEmitter {
1153
1352
  const persistedByRuntimeAgentId = new Map(persistedRecords
1154
1353
  .filter(record => record && record.runtimeAgentId)
1155
1354
  .map(record => [record.runtimeAgentId, record]));
1355
+ const recoveredRuntimeAgentIds = new Set((recovered || [])
1356
+ .map(entry => recoveredEngineSessionId(entry, entry?.metadata || {}))
1357
+ .filter(Boolean));
1156
1358
  let changed = false;
1157
1359
 
1158
1360
  for (const entry of recovered || []) {
@@ -1161,7 +1363,13 @@ class AgentManager extends EventEmitter {
1161
1363
  const agentId = recoveredEngineSessionId(entry, engineMetadata);
1162
1364
  const persisted = persistedByRuntimeAgentId.get(agentId);
1163
1365
  const desiredMetadata = persisted || engineMetadata;
1164
- if (!shouldRestoreAgentFromMetadata(desiredMetadata, mainPageSessionKeys)) {
1366
+ const persistedLifecycleOperation = activeLifecycleOperation(desiredMetadata);
1367
+ const hasRecoverableLifecycleOperation = persistedLifecycleOperation
1368
+ && ['create', 'delete', 'archive'].includes(persistedLifecycleOperation.type);
1369
+ if (
1370
+ !hasRecoverableLifecycleOperation
1371
+ && !shouldRestoreAgentFromMetadata(desiredMetadata, mainPageSessionKeys)
1372
+ ) {
1165
1373
  await this.killRecoveredEngineSession(entry, engineMetadata, agentId);
1166
1374
  continue;
1167
1375
  }
@@ -1204,6 +1412,7 @@ class AgentManager extends EventEmitter {
1204
1412
  customTitle: Object.prototype.hasOwnProperty.call(persisted, 'customTitle')
1205
1413
  ? persisted.customTitle
1206
1414
  : engineMetadata.customTitle,
1415
+ lifecycleJournal: lifecycleJournal(persisted),
1207
1416
  ...legacyRuntimeMetadata(persisted),
1208
1417
  pinned: persisted.pinned === true,
1209
1418
  projectOrder: finiteOrder(persisted.projectOrder) ?? finiteOrder(engineMetadata.projectOrder),
@@ -1214,20 +1423,72 @@ class AgentManager extends EventEmitter {
1214
1423
  const agentRecord = this.recoveredAgentRecord(agentId, entry.engineName || metadata.engineName || 'native', metadata, state);
1215
1424
  ensureAgentOrders(agentRecord, Array.from(this.agents.values()));
1216
1425
  agentRecord.lastObservedTurnActive = this.isAgentAttentionTurnActive(agentRecord);
1217
- this.ensurePersistentAgentSession(agentRecord, {
1218
- visibleOnMainPage: true,
1219
- archived: false,
1220
- });
1221
1426
  this.agents.set(agentId, agentRecord);
1427
+ const recoveredLifecycleOperation = activeLifecycleOperation(agentRecord);
1428
+ if (
1429
+ recoveredLifecycleOperation?.type === 'create'
1430
+ && recoveredLifecycleOperation.state === 'pending'
1431
+ ) {
1432
+ try {
1433
+ this.transitionPersistentAgentOperation(
1434
+ agentRecord,
1435
+ recoveredLifecycleOperation.id,
1436
+ 'succeeded',
1437
+ '',
1438
+ { visibleOnMainPage: true, archived: false },
1439
+ );
1440
+ } catch (error) {
1441
+ this.markRecoveredAgentLifecycleBlocked(agentRecord, recoveredLifecycleOperation, error);
1442
+ }
1443
+ } else if (
1444
+ recoveredLifecycleOperation?.type === 'delete'
1445
+ || recoveredLifecycleOperation?.type === 'archive'
1446
+ ) {
1447
+ const result = recoveredLifecycleOperation.type === 'delete'
1448
+ ? await this.killAgent(agentId, {
1449
+ reason: 'delete-recovery',
1450
+ skipRecoveryWait: true,
1451
+ })
1452
+ : await this.archiveAgent(agentId, {
1453
+ reason: 'archive-recovery',
1454
+ skipRecoveryWait: true,
1455
+ });
1456
+ if (result?.error) {
1457
+ console.warn(
1458
+ `Failed to resume Agent ${recoveredLifecycleOperation.type} ${agentId}: ${result.error}`,
1459
+ );
1460
+ }
1461
+ changed = true;
1462
+ continue;
1463
+ } else if (recoveredLifecycleOperation) {
1464
+ this.markRecoveredAgentLifecycleBlocked(agentRecord, recoveredLifecycleOperation);
1465
+ } else {
1466
+ this.ensurePersistentAgentSession(agentRecord, {
1467
+ visibleOnMainPage: true,
1468
+ archived: false,
1469
+ });
1470
+ }
1471
+ const recoveredUpdate = this.reconcilePersistedAgentUpdate(agentRecord);
1472
+ if (recoveredUpdate?.error) {
1473
+ console.warn(`Failed to reconcile Agent update ${agentId}: ${recoveredUpdate.error}`);
1474
+ }
1222
1475
  void this.refreshAgentWorktree(agentId);
1223
1476
  this.lastActivity.set(agentId, state.lastActivityAt || metadata.lastActivityAt || Date.now());
1224
1477
  if (agentRecord.wantsMain && !this.mainAgentId) {
1225
1478
  this.mainAgentId = agentId;
1226
1479
  }
1480
+ this.rememberMainPageProviderSession(agentRecord);
1227
1481
  this.providerSessionService.activate(agentId);
1228
1482
  changed = true;
1229
1483
  }
1230
1484
 
1485
+ if (this.reconcileMissingTerminalLifecycleOperations(
1486
+ persistedRecords,
1487
+ recoveredRuntimeAgentIds,
1488
+ )) {
1489
+ changed = true;
1490
+ }
1491
+
1231
1492
  if (changed) {
1232
1493
  this.emit('update');
1233
1494
  }
@@ -1240,6 +1501,83 @@ class AgentManager extends EventEmitter {
1240
1501
  }
1241
1502
 
1242
1503
  await this.recoverAcpSessions();
1504
+ if (this.reconcileDetachedPersistedAgentUpdates()) {
1505
+ this.emit('update');
1506
+ }
1507
+ }
1508
+
1509
+ markRecoveredAgentLifecycleBlocked(agent, operation, cause = null) {
1510
+ const reason = cause
1511
+ ? `Agent ${operation.type} operation ${operation.id} recovery could not be committed: ${cause.message || cause}`
1512
+ : `Agent ${operation.type} operation ${operation.id} must be resolved before restart`;
1513
+ agent.status = 'error';
1514
+ agent.engineStatus = 'lifecycle-blocked';
1515
+ const runtime = runtimeBindingOf(agent);
1516
+ if (runtime && Object.prototype.hasOwnProperty.call(runtime, 'state')) {
1517
+ runtime.state = 'error';
1518
+ runtime.error = reason;
1519
+ }
1520
+ }
1521
+
1522
+ reconcileMissingTerminalLifecycleOperations(records, recoveredRuntimeAgentIds) {
1523
+ let changed = false;
1524
+ for (const record of Array.isArray(records) ? records : []) {
1525
+ const agentId = String(record?.runtimeAgentId || '').trim();
1526
+ const operation = activeLifecycleOperation(record);
1527
+ if (
1528
+ !agentId
1529
+ || recoveredRuntimeAgentIds.has(agentId)
1530
+ || runtimeKind(record) !== 'terminal'
1531
+ || !operation
1532
+ || !['create', 'delete', 'archive'].includes(operation.type)
1533
+ ) {
1534
+ continue;
1535
+ }
1536
+
1537
+ const recoveredAgent = this.recoveredAgentRecord(
1538
+ agentId,
1539
+ record.engine || 'native',
1540
+ { ...record, persistentSessionId: record.id || record.persistentSessionId || '' },
1541
+ { status: 'exited' },
1542
+ );
1543
+ setAgentRecordId(recoveredAgent, record.id || record.persistentSessionId || '');
1544
+ try {
1545
+ if (operation.type === 'create') {
1546
+ this.transitionPersistentAgentOperation(
1547
+ recoveredAgent,
1548
+ operation.id,
1549
+ 'failed',
1550
+ 'Create runtime was not present in the authoritative native-host recovery set',
1551
+ createFailurePatch(
1552
+ operation,
1553
+ operation.request?.previousRuntimeAgentId,
1554
+ ),
1555
+ );
1556
+ } else {
1557
+ this.transitionPersistentAgentOperation(
1558
+ recoveredAgent,
1559
+ operation.id,
1560
+ 'succeeded',
1561
+ '',
1562
+ {
1563
+ visibleOnMainPage: false,
1564
+ archived: true,
1565
+ archivedAt: Date.now(),
1566
+ runtimeAgentId: '',
1567
+ },
1568
+ );
1569
+ this.removeMainPageProviderSessionsForAgents([recoveredAgent]);
1570
+ if (operation.type === 'archive') this.scheduleCodexSessionArchive(recoveredAgent);
1571
+ }
1572
+ changed = true;
1573
+ } catch (error) {
1574
+ console.warn(
1575
+ `Failed to reconcile missing Terminal Agent ${agentId} ${operation.type}:`,
1576
+ error && (error.message || error),
1577
+ );
1578
+ }
1579
+ }
1580
+ return changed;
1243
1581
  }
1244
1582
 
1245
1583
  async restoreTerminalSessionsAfterRuntimeRotation(records, rotations) {
@@ -1485,11 +1823,17 @@ class AgentManager extends EventEmitter {
1485
1823
 
1486
1824
  async recoverAcpSessions() {
1487
1825
  if (!this.acpRuntime || !this.configManager || typeof this.configManager.listAgentSessionRecords !== 'function') return;
1826
+ await this.reconcilePersistedAcpLifecycleOperations(
1827
+ this.configManager.listAgentSessionRecords(),
1828
+ );
1488
1829
  const mainPageOrder = new Map(this.getMainPageSessionKeys().map((key, index) => [key, index]));
1489
1830
  const mainPageSessionKeys = new Set(mainPageOrder.keys());
1490
1831
  const records = this.configManager.listAgentSessionRecords()
1491
1832
  .filter(record => (
1492
- shouldRestoreAgentFromMetadata(record, mainPageSessionKeys)
1833
+ (
1834
+ shouldRestoreAgentFromMetadata(record, mainPageSessionKeys)
1835
+ || lifecycleOperationBlocksRuntimeStart(record)
1836
+ )
1493
1837
  && runtimeKind(record) === 'acp'
1494
1838
  ))
1495
1839
  .sort((left, right) => {
@@ -1506,14 +1850,29 @@ class AgentManager extends EventEmitter {
1506
1850
  const agentId = String(record.runtimeAgentId || '').trim();
1507
1851
  const provider = String(record.providerSessionProvider || record.provider || '').trim();
1508
1852
  const sessionId = String(record.providerSessionId || record.codexAppServerThreadId || '').trim();
1509
- if (!agentId || !sessionId || !providerSupportsRuntime(provider, 'acp')) continue;
1853
+ const blockedOperation = lifecycleOperationBlocksRuntimeStart(record);
1854
+ if (
1855
+ !agentId
1856
+ || !providerSupportsRuntime(provider, 'acp')
1857
+ || (!sessionId && !blockedOperation)
1858
+ ) {
1859
+ continue;
1860
+ }
1510
1861
  const agent = this.agents.get(agentId);
1511
1862
  if (!agent) {
1512
1863
  const recoveredAgent = this.recoveredAgentRecord(agentId, record.engine || 'native', record, { status: 'running' });
1513
1864
  ensureAgentOrders(recoveredAgent, Array.from(this.agents.values()));
1514
- recoveredAgent.persistentSessionId = record.id || '';
1865
+ setAgentRecordId(recoveredAgent, record.id || '');
1515
1866
  recoveredAgent.engineStarted = false;
1516
- runtimeBindingOf(recoveredAgent, 'acp').state = 'connecting';
1867
+ if (blockedOperation) {
1868
+ recoveredAgent.status = 'error';
1869
+ recoveredAgent.engineStatus = 'lifecycle-blocked';
1870
+ const runtime = runtimeBindingOf(recoveredAgent, 'acp');
1871
+ runtime.state = 'error';
1872
+ runtime.error = `Agent ${blockedOperation.type} operation ${blockedOperation.id} must be resolved before restart`;
1873
+ } else {
1874
+ runtimeBindingOf(recoveredAgent, 'acp').state = 'connecting';
1875
+ }
1517
1876
  this.agents.set(agentId, recoveredAgent);
1518
1877
  void this.refreshAgentWorktree(agentId);
1519
1878
  this.lastActivity.set(agentId, Date.now());
@@ -1521,13 +1880,55 @@ class AgentManager extends EventEmitter {
1521
1880
  }
1522
1881
  this.emit('update');
1523
1882
 
1883
+ for (const record of records) {
1884
+ const agentId = String(record.runtimeAgentId || '').trim();
1885
+ const agent = this.agents.get(agentId);
1886
+ if (!agent) continue;
1887
+ const recoveredUpdate = this.reconcilePersistedAgentUpdate(agent);
1888
+ if (recoveredUpdate?.error) {
1889
+ console.warn(`Failed to reconcile Agent update ${agentId}: ${recoveredUpdate.error}`);
1890
+ }
1891
+ }
1892
+
1524
1893
  for (const record of records) {
1525
1894
  const agentId = String(record.runtimeAgentId || '').trim();
1526
1895
  const provider = String(record.providerSessionProvider || record.provider || '').trim();
1527
1896
  const sessionId = String(record.providerSessionId || record.codexAppServerThreadId || '').trim();
1528
1897
  const agent = this.agents.get(agentId);
1529
1898
  if (!agent || !sessionId || !providerSupportsRuntime(provider, 'acp')) continue;
1899
+ if (lifecycleOperationBlocksRuntimeStart(record)) continue;
1530
1900
  try {
1901
+ if (
1902
+ !record.structuredRuntimeProcess
1903
+ && !this.allowUnprovenLegacyAcpRecovery
1904
+ && !record.legacyAcpProcessExitAcknowledgedAt
1905
+ ) {
1906
+ const cleanupError = new Error(
1907
+ 'Legacy ACP process exit cannot be proven after restart; automatic recovery is blocked',
1908
+ );
1909
+ cleanupError.code = 'ACP_PROCESS_CLEANUP_UNCERTAIN';
1910
+ throw cleanupError;
1911
+ }
1912
+ if (record.structuredRuntimeProcess) {
1913
+ let cleanup;
1914
+ try {
1915
+ cleanup = await this.stopPersistedAcpProcessGroup(record.structuredRuntimeProcess);
1916
+ } catch (cause) {
1917
+ const cleanupError = new Error(
1918
+ `Persisted ACP process exit proof failed: ${cause.message || cause}`,
1919
+ { cause },
1920
+ );
1921
+ cleanupError.code = 'ACP_PROCESS_CLEANUP_UNCERTAIN';
1922
+ throw cleanupError;
1923
+ }
1924
+ if (cleanup.stopped !== true) {
1925
+ const cleanupError = new Error('Persisted ACP process identity could not be safely stopped');
1926
+ cleanupError.code = 'ACP_PROCESS_CLEANUP_UNCERTAIN';
1927
+ throw cleanupError;
1928
+ }
1929
+ agent.structuredRuntimeProcess = null;
1930
+ this.ensurePersistentAgentSession(agent);
1931
+ }
1531
1932
  const executableName = getProviderAdapter(provider).executable;
1532
1933
  const executable = resolveAgentExecutable(executableName) || executableName;
1533
1934
  const approvalMode = agent.launchPermissionMode || (
@@ -1552,6 +1953,13 @@ class AgentManager extends EventEmitter {
1552
1953
  serviceTier: 'config',
1553
1954
  additionalDirectories: Array.isArray(record.acpAdditionalDirectories) ? record.acpAdditionalDirectories : [],
1554
1955
  mcpServers: Array.isArray(record.acpMcpServers) ? record.acpMcpServers : [],
1956
+ onProcessStarted: async processIdentity => {
1957
+ agent.structuredRuntimeProcess = {
1958
+ kind: 'acp-process-group',
1959
+ ...processIdentity,
1960
+ };
1961
+ this.ensurePersistentAgentSession(agent);
1962
+ },
1555
1963
  });
1556
1964
  agent.providerSessionId = prepared.sessionId;
1557
1965
  agent.providerSessionKey = mainPageAgentSessionKey(
@@ -1575,13 +1983,19 @@ class AgentManager extends EventEmitter {
1575
1983
  agent.status = 'running';
1576
1984
  agent.engineStatus = 'running';
1577
1985
  agent.engineStarted = false;
1986
+ agent.requiresProcessExitAcknowledgement = false;
1578
1987
  this.ensurePersistentAgentSession(agent);
1988
+ this.rememberMainPageProviderSession(agent);
1579
1989
  } catch (error) {
1580
1990
  const runtime = runtimeBindingOf(agent, 'acp');
1581
1991
  runtime.state = 'error';
1582
1992
  runtime.error = `ACP recovery failed: ${error && (error.message || error)}`;
1583
- agent.status = 'stopped';
1584
- agent.engineStatus = 'stopped';
1993
+ const cleanupUncertain = error?.code === 'ACP_PROCESS_CLEANUP_UNCERTAIN';
1994
+ agent.status = cleanupUncertain ? 'error' : 'stopped';
1995
+ agent.engineStatus = cleanupUncertain ? 'cleanup-uncertain' : 'stopped';
1996
+ agent.requiresProcessExitAcknowledgement = cleanupUncertain
1997
+ && !record.structuredRuntimeProcess
1998
+ && !record.legacyAcpProcessExitAcknowledgedAt;
1585
1999
  agent.engineStarted = false;
1586
2000
  agent.exitedAt = Date.now();
1587
2001
  this.ensurePersistentAgentSession(agent);
@@ -1590,6 +2004,158 @@ class AgentManager extends EventEmitter {
1590
2004
  this.emit('update');
1591
2005
  }
1592
2006
 
2007
+ async reconcilePersistedAcpLifecycleOperations(records) {
2008
+ for (const record of Array.isArray(records) ? records : []) {
2009
+ const operation = activeLifecycleOperation(record);
2010
+ if (
2011
+ runtimeKind(record) !== 'acp'
2012
+ || !operation
2013
+ || !['create', 'delete', 'archive'].includes(operation.type)
2014
+ ) {
2015
+ continue;
2016
+ }
2017
+
2018
+ const processProofRequired = operation.request?.structuredProcessProofRequired === true
2019
+ || Boolean(record.structuredRuntimeProcess);
2020
+ if (processProofRequired && !record.legacyAcpProcessExitAcknowledgedAt) {
2021
+ try {
2022
+ const cleanup = await this.stopPersistedAcpProcessGroup(record.structuredRuntimeProcess);
2023
+ if (
2024
+ cleanup.stopped !== true
2025
+ && !(
2026
+ cleanup.missingProof === true
2027
+ && operation.request?.structuredProcessStartGated === true
2028
+ )
2029
+ ) {
2030
+ continue;
2031
+ }
2032
+ } catch (error) {
2033
+ console.warn(
2034
+ `Could not prove persisted ACP process exit for ${record.runtimeAgentId || operation.id}:`,
2035
+ error && (error.message || error),
2036
+ );
2037
+ continue;
2038
+ }
2039
+ }
2040
+
2041
+ const agentId = String(
2042
+ operation.type === 'create'
2043
+ ? operation.request?.agentId || record.runtimeAgentId || ''
2044
+ : record.runtimeAgentId || '',
2045
+ ).trim();
2046
+ if (!agentId) continue;
2047
+ const staged = this.recoveredAgentRecord(
2048
+ agentId,
2049
+ record.engine || 'native',
2050
+ { ...record, persistentSessionId: record.id || '' },
2051
+ { status: 'exited' },
2052
+ );
2053
+ setAgentRecordId(staged, record.id || '');
2054
+ staged.structuredRuntimeProcess = null;
2055
+ try {
2056
+ if (operation.type === 'create') {
2057
+ this.transitionPersistentAgentOperation(
2058
+ staged,
2059
+ operation.id,
2060
+ 'failed',
2061
+ 'Create ACP process was stopped during restart recovery',
2062
+ createFailurePatch(operation, operation.request?.previousRuntimeAgentId),
2063
+ );
2064
+ } else {
2065
+ this.transitionPersistentAgentOperation(
2066
+ staged,
2067
+ operation.id,
2068
+ 'succeeded',
2069
+ '',
2070
+ {
2071
+ visibleOnMainPage: false,
2072
+ archived: true,
2073
+ archivedAt: Date.now(),
2074
+ runtimeAgentId: '',
2075
+ structuredRuntimeProcess: null,
2076
+ },
2077
+ );
2078
+ this.removeMainPageProviderSessionsForAgents([staged]);
2079
+ if (operation.type === 'archive') this.scheduleCodexSessionArchive(staged);
2080
+ }
2081
+ } catch (error) {
2082
+ console.warn(
2083
+ `Failed to reconcile ACP Agent ${agentId} ${operation.type}:`,
2084
+ error && (error.message || error),
2085
+ );
2086
+ }
2087
+ }
2088
+ }
2089
+
2090
+ reconcileDetachedPersistedAgentUpdates() {
2091
+ if (!this.configManager || typeof this.configManager.listAgentSessionRecords !== 'function') {
2092
+ return false;
2093
+ }
2094
+ let changed = false;
2095
+ for (const record of this.configManager.listAgentSessionRecords()) {
2096
+ const operation = activeLifecycleOperation(record);
2097
+ const agentId = String(record?.runtimeAgentId || '').trim();
2098
+ if (
2099
+ operation?.type !== 'update'
2100
+ || !agentId
2101
+ || this.agents.has(agentId)
2102
+ ) {
2103
+ continue;
2104
+ }
2105
+
2106
+ const staged = this.recoveredAgentRecord(
2107
+ agentId,
2108
+ record.engine || 'native',
2109
+ record,
2110
+ { status: 'stopped' },
2111
+ );
2112
+ setAgentRecordId(staged, record.id || '');
2113
+ const request = operation.request || {};
2114
+ if (Object.prototype.hasOwnProperty.call(request, 'customTitle')) {
2115
+ staged.customTitle = String(request.customTitle || '').trim().slice(0, 80);
2116
+ }
2117
+ if (Object.prototype.hasOwnProperty.call(request, 'task')) {
2118
+ staged.task = String(request.task || '').trim().slice(0, 240);
2119
+ }
2120
+ if (typeof request.pinned === 'boolean') {
2121
+ staged.pinned = request.pinned;
2122
+ if (!staged.pinned) staged.pinnedOrder = null;
2123
+ }
2124
+ if (request.archived === false) {
2125
+ staged.archived = false;
2126
+ staged.archivedAt = null;
2127
+ }
2128
+ if (typeof request.readAttentionSeq === 'number') {
2129
+ staged.readAttentionSeq = Math.min(
2130
+ finiteNonNegativeInteger(staged.attentionSeq),
2131
+ Math.max(
2132
+ finiteNonNegativeInteger(staged.readAttentionSeq),
2133
+ finiteNonNegativeInteger(request.readAttentionSeq),
2134
+ ),
2135
+ );
2136
+ staged.unread = agentAttentionUnread(staged);
2137
+ }
2138
+ if (
2139
+ typeof request.readOutputEpoch === 'string'
2140
+ && typeof request.readOutputSeq === 'number'
2141
+ ) {
2142
+ staged.readOutputEpoch = request.readOutputEpoch;
2143
+ staged.readOutputSeq = finiteNonNegativeInteger(request.readOutputSeq);
2144
+ }
2145
+ transitionLifecycleOperation(staged, operation.id, 'succeeded');
2146
+ try {
2147
+ this.ensurePersistentAgentSession(staged);
2148
+ changed = true;
2149
+ } catch (error) {
2150
+ console.warn(
2151
+ `Failed to reconcile detached Agent update ${operation.id}:`,
2152
+ error && (error.message || error),
2153
+ );
2154
+ }
2155
+ }
2156
+ return changed;
2157
+ }
2158
+
1593
2159
  async killRecoveredEngineSession(entry, metadata, agentId) {
1594
2160
  if (!this.engineBridge || typeof this.engineBridge.killSession !== 'function') return;
1595
2161
  const engineName = entry.engineName || metadata.engineName || 'native';
@@ -1602,6 +2168,12 @@ class AgentManager extends EventEmitter {
1602
2168
 
1603
2169
  async whenRecovered() {
1604
2170
  await this.recoveryPromise;
2171
+ if (this.recoveryError) {
2172
+ throw new Error(
2173
+ `Agent lifecycle recovery failed: ${this.recoveryError.message || this.recoveryError}`,
2174
+ { cause: this.recoveryError },
2175
+ );
2176
+ }
1605
2177
  }
1606
2178
 
1607
2179
  recoveredAgentRecord(agentId, engineName, metadata, state) {
@@ -1609,6 +2181,7 @@ class AgentManager extends EventEmitter {
1609
2181
  const providerSessionProvider = metadata.providerSessionProvider || metadata.provider || '';
1610
2182
  const providerSessionId = metadata.providerSessionId || metadata.codexAppServerThreadId || '';
1611
2183
  const runtimeBinding = runtimeBindingFor(runtimeKind(metadata), metadata);
2184
+ const agentRecordId = metadata.agentRecordId || metadata.persistentSessionId || metadata.id || '';
1612
2185
  return {
1613
2186
  id: agentId,
1614
2187
  command: metadata.forkCommand || metadata.command || '',
@@ -1646,6 +2219,16 @@ class AgentManager extends EventEmitter {
1646
2219
  providerSessionTitle: metadata.providerSessionTitle || '',
1647
2220
  providerSessionWorkspace: metadata.providerSessionWorkspace || '',
1648
2221
  terminalInputReceived: metadata.terminalInputReceived === true,
2222
+ structuredRuntimeProcess: metadata.structuredRuntimeProcess
2223
+ && typeof metadata.structuredRuntimeProcess === 'object'
2224
+ ? JSON.parse(JSON.stringify(metadata.structuredRuntimeProcess))
2225
+ : null,
2226
+ legacyAcpProcessExitAcknowledgedAt:
2227
+ typeof metadata.legacyAcpProcessExitAcknowledgedAt === 'number'
2228
+ ? metadata.legacyAcpProcessExitAcknowledgedAt
2229
+ : null,
2230
+ requiresProcessExitAcknowledgement:
2231
+ requiresLegacyAcpExitAcknowledgement(metadata),
1649
2232
  // Legacy App Server records normalize to ACP at the runtime-binding
1650
2233
  // boundary. Codex thread ids are valid ACP session ids, so the existing
1651
2234
  // conversation remains recoverable without restarting App Server.
@@ -1655,7 +2238,9 @@ class AgentManager extends EventEmitter {
1655
2238
  restartedFromAgentIds: Array.isArray(metadata.restartedFromAgentIds)
1656
2239
  ? metadata.restartedFromAgentIds.filter(id => typeof id === 'string' && id)
1657
2240
  : [],
1658
- persistentSessionId: metadata.persistentSessionId || '',
2241
+ agentRecordId,
2242
+ persistentSessionId: agentRecordId,
2243
+ lifecycleJournal: lifecycleJournal(metadata),
1659
2244
  customTitle: metadata.customTitle || '',
1660
2245
  terminalBusy: typeof state.terminalBusy === 'boolean' ? state.terminalBusy : null,
1661
2246
  shellCwd: state.shellCwd || metadata.cwd || '',
@@ -1750,24 +2335,25 @@ class AgentManager extends EventEmitter {
1750
2335
  if (!agent || !this.configManager || typeof this.configManager.ensureAgentSessionRecord !== 'function') {
1751
2336
  return '';
1752
2337
  }
1753
- const previousPersistentSessionId = agent.persistentSessionId || '';
2338
+ this.assertPersistentAgentRuntimeOwner(agent);
2339
+ const previousAgentRecordId = agent.agentRecordId || agent.persistentSessionId || '';
1754
2340
  const sessionOptions = agent.providerSessionKey
1755
2341
  ? this.acpSessionOptionsByKey.get(agent.providerSessionKey)
1756
2342
  : null;
1757
- const persistentSessionId = this.configManager.ensureAgentSessionRecord(agent, {
2343
+ const agentRecordId = this.configManager.ensureAgentSessionRecord(agent, {
1758
2344
  ...(sessionOptions ? {
1759
2345
  acpAdditionalDirectories: [...sessionOptions.additionalDirectories],
1760
2346
  acpMcpServers: JSON.parse(JSON.stringify(sessionOptions.mcpServers)),
1761
2347
  } : {}),
1762
2348
  ...patch,
1763
2349
  });
1764
- if (persistentSessionId) {
1765
- agent.persistentSessionId = persistentSessionId;
2350
+ if (agentRecordId) {
2351
+ setAgentRecordId(agent, agentRecordId);
1766
2352
  }
1767
2353
  if (
1768
- persistentSessionId
1769
- && previousPersistentSessionId
1770
- && persistentSessionId !== previousPersistentSessionId
2354
+ agentRecordId
2355
+ && previousAgentRecordId
2356
+ && agentRecordId !== previousAgentRecordId
1771
2357
  && agent.providerSessionKey
1772
2358
  && typeof this.configManager.getAgentSessionRecordForProviderSessionKey === 'function'
1773
2359
  ) {
@@ -1794,7 +2380,214 @@ class AgentManager extends EventEmitter {
1794
2380
  agent.unread = agentAttentionUnread(agent);
1795
2381
  }
1796
2382
  }
1797
- return persistentSessionId;
2383
+ return agentRecordId;
2384
+ }
2385
+
2386
+ assertPersistentAgentRuntimeOwner(agent) {
2387
+ if (
2388
+ !agent?.providerSessionKey
2389
+ || typeof this.configManager?.getAgentSessionRecordForProviderSessionKey !== 'function'
2390
+ ) {
2391
+ return;
2392
+ }
2393
+ const canonical = this.configManager.getAgentSessionRecordForProviderSessionKey(
2394
+ agent.providerSessionKey,
2395
+ );
2396
+ const currentOwner = String(canonical?.runtimeAgentId || '').trim();
2397
+ const requestedOwner = String(agent.id || '').trim();
2398
+ if (!currentOwner || currentOwner === requestedOwner) return;
2399
+
2400
+ const ownerAgent = this.agents.get(currentOwner);
2401
+ if (!ownerAgent && !this.recoveryComplete) {
2402
+ throw new Error(
2403
+ `Agent session ${agent.providerSessionKey} ownership cannot be changed before recovery completes`,
2404
+ );
2405
+ }
2406
+ const ownerIsStopped = !ownerAgent
2407
+ || this.verifiedStoppedAgentIds.has(currentOwner)
2408
+ || ['dead', 'stopped'].includes(String(ownerAgent.status || ''))
2409
+ || ownerAgent.engineStatus === 'exited';
2410
+ if (ownerIsStopped) return;
2411
+
2412
+ throw new Error(
2413
+ `Agent session ${agent.providerSessionKey} is owned by Runtime ${currentOwner}, not ${requestedOwner}`,
2414
+ );
2415
+ }
2416
+
2417
+ beginPersistentAgentOperation(agent, type, requestKey, request = {}) {
2418
+ const previousJournal = agent.lifecycleJournal
2419
+ ? JSON.parse(JSON.stringify(agent.lifecycleJournal))
2420
+ : null;
2421
+ const result = beginLifecycleOperation(agent, type, requestKey, request);
2422
+ if (result.conflict) {
2423
+ return {
2424
+ error: `Agent operation ${result.conflict.id} (${result.conflict.type}) has not reached a terminal state`,
2425
+ conflict: result.conflict,
2426
+ };
2427
+ }
2428
+ if (result.joined && result.operation.state === 'blocked') {
2429
+ transitionLifecycleOperation(agent, result.operation.id, 'pending');
2430
+ }
2431
+ try {
2432
+ const persistentSessionId = this.ensurePersistentAgentSession(agent);
2433
+ if (
2434
+ typeof this.configManager?.ensureAgentSessionRecord === 'function'
2435
+ && !persistentSessionId
2436
+ ) {
2437
+ throw new Error('Agent session store did not return a persistent id');
2438
+ }
2439
+ } catch (error) {
2440
+ if (previousJournal) agent.lifecycleJournal = previousJournal;
2441
+ else delete agent.lifecycleJournal;
2442
+ return { error: `Failed to persist Agent ${type} intent: ${error.message || error}` };
2443
+ }
2444
+ return {
2445
+ operation: activeLifecycleOperation(agent) || result.operation,
2446
+ joined: result.joined,
2447
+ };
2448
+ }
2449
+
2450
+ transitionPersistentAgentOperation(agent, operationId, state, error = '', patch = {}) {
2451
+ const previousJournal = agent.lifecycleJournal
2452
+ ? JSON.parse(JSON.stringify(agent.lifecycleJournal))
2453
+ : null;
2454
+ const operation = transitionLifecycleOperation(agent, operationId, state, error);
2455
+ if (!operation) throw new Error(`Agent operation ${operationId} was not found`);
2456
+ try {
2457
+ const persistentSessionId = this.ensurePersistentAgentSession(agent, patch);
2458
+ if (
2459
+ typeof this.configManager?.ensureAgentSessionRecord === 'function'
2460
+ && !persistentSessionId
2461
+ ) {
2462
+ throw new Error('Agent session store did not return a persistent id');
2463
+ }
2464
+ } catch (persistError) {
2465
+ if (previousJournal) agent.lifecycleJournal = previousJournal;
2466
+ else delete agent.lifecycleJournal;
2467
+ throw persistError;
2468
+ }
2469
+ return operation;
2470
+ }
2471
+
2472
+ beginPersistentAgentUpdate(agent, requestKey, request) {
2473
+ const latest = latestLifecycleOperation(agent);
2474
+ if (
2475
+ latest?.type === 'update'
2476
+ && latest.state === 'succeeded'
2477
+ && latest.requestKey === requestKey
2478
+ ) {
2479
+ return { operation: latest, deduplicated: true };
2480
+ }
2481
+ return this.beginPersistentAgentOperation(agent, 'update', requestKey, request);
2482
+ }
2483
+
2484
+ reconcilePersistedAgentUpdate(agent) {
2485
+ const operation = activeLifecycleOperation(agent);
2486
+ if (operation?.type !== 'update') return null;
2487
+ const request = operation.request || {};
2488
+ if (Object.prototype.hasOwnProperty.call(request, 'customTitle')) {
2489
+ return this.renameAgent(agent.id, request.customTitle);
2490
+ }
2491
+ if (Object.prototype.hasOwnProperty.call(request, 'task')) {
2492
+ return this.setAgentTask(agent.id, request.task);
2493
+ }
2494
+ return this.updateAgentFlags(agent.id, request);
2495
+ }
2496
+
2497
+ async replayPersistentCreateRequest(createRequestId, requestSignature = '') {
2498
+ if (!this.configManager || typeof this.configManager.listAgentSessionRecords !== 'function') {
2499
+ return null;
2500
+ }
2501
+ const requestKey = `create-request:${String(createRequestId || '').trim().slice(0, 160)}`;
2502
+ if (requestKey === 'create-request:') return null;
2503
+ const matches = this.configManager.listAgentSessionRecords()
2504
+ .flatMap(record => {
2505
+ const entries = lifecycleJournal(record).entries;
2506
+ return entries.map((operation, index) => ({ record, entries, operation, index }));
2507
+ })
2508
+ .filter(item => item.operation.type === 'create' && item.operation.requestKey === requestKey)
2509
+ .sort((left, right) => (
2510
+ (Number(right.operation.updatedAt) || 0) - (Number(left.operation.updatedAt) || 0)
2511
+ ));
2512
+ const match = matches[0];
2513
+ if (!match) return null;
2514
+ if (
2515
+ match.operation.request?.signature
2516
+ && match.operation.request.signature !== requestSignature
2517
+ ) {
2518
+ return {
2519
+ error: `Create request ${createRequestId} was already used for different Agent parameters`,
2520
+ };
2521
+ }
2522
+
2523
+ const agentId = String(
2524
+ match.operation.request?.agentId
2525
+ || match.record.runtimeAgentId
2526
+ || '',
2527
+ ).trim();
2528
+ const inFlight = agentId ? this.agentLifecycleOperations.get(agentId) : null;
2529
+ if (inFlight && !TERMINAL_OPERATION_STATES.has(match.operation.state)) {
2530
+ await inFlight.promise.catch(() => {});
2531
+ return this.replayPersistentCreateRequest(createRequestId, requestSignature);
2532
+ }
2533
+
2534
+ const removedLater = match.entries.slice(match.index + 1).some(operation => (
2535
+ ['delete', 'archive'].includes(operation.type)
2536
+ && operation.state === 'succeeded'
2537
+ ));
2538
+ if (removedLater) {
2539
+ return {
2540
+ error: `Create request ${createRequestId} already completed, but its Agent was later removed`,
2541
+ };
2542
+ }
2543
+ if (match.operation.state === 'succeeded') {
2544
+ if (agentId && this.agents.has(agentId)) {
2545
+ return {
2546
+ agentId,
2547
+ deduplicated: true,
2548
+ createResult: match.operation.result,
2549
+ };
2550
+ }
2551
+ return {
2552
+ error: `Create request ${createRequestId} already succeeded, but its Runtime is not available`,
2553
+ };
2554
+ }
2555
+ if (TERMINAL_OPERATION_STATES.has(match.operation.state)) {
2556
+ return {
2557
+ error: match.operation.error
2558
+ || `Create request ${createRequestId} already finished with state ${match.operation.state}`,
2559
+ };
2560
+ }
2561
+ return {
2562
+ agentId,
2563
+ error: match.operation.error
2564
+ || `Create request ${createRequestId} is awaiting lifecycle recovery`,
2565
+ };
2566
+ }
2567
+
2568
+ recordCreateRequestResult(agentId, createRequestId, result) {
2569
+ const agent = this.agents.get(agentId);
2570
+ if (!agent) return { error: 'Agent not found' };
2571
+ const requestKey = `create-request:${String(createRequestId || '').trim().slice(0, 160)}`;
2572
+ const operation = lifecycleJournal(agent).entries.find(candidate => (
2573
+ candidate.type === 'create'
2574
+ && candidate.requestKey === requestKey
2575
+ && candidate.state === 'succeeded'
2576
+ ));
2577
+ if (!operation) return { error: 'Create operation was not found' };
2578
+ const staged = {
2579
+ ...agent,
2580
+ lifecycleJournal: lifecycleJournal(agent),
2581
+ };
2582
+ setLifecycleOperationResult(staged, operation.id, result);
2583
+ try {
2584
+ this.ensurePersistentAgentSession(staged);
2585
+ } catch (error) {
2586
+ return { error: `Failed to persist Create result: ${error.message || error}` };
2587
+ }
2588
+ agent.lifecycleJournal = staged.lifecycleJournal;
2589
+ setAgentRecordId(agent, staged.agentRecordId || staged.persistentSessionId || '');
2590
+ return { agentId, operationId: operation.id, result };
1798
2591
  }
1799
2592
 
1800
2593
  rememberMainPageProviderSession(agent) {
@@ -1803,6 +2596,7 @@ class AgentManager extends EventEmitter {
1803
2596
  if (!this.configManager) {
1804
2597
  return;
1805
2598
  }
2599
+ this.assertPersistentAgentRuntimeOwner(agent);
1806
2600
 
1807
2601
  const sessionKey = mainPageAgentSessionKey(agent.providerSessionProvider, agent.providerSessionId, agent.providerHomeId || '');
1808
2602
  if (!sessionKey) return;
@@ -1813,7 +2607,7 @@ class AgentManager extends EventEmitter {
1813
2607
  }
1814
2608
  if (typeof this.configManager.rememberAgentSessionRecord === 'function') {
1815
2609
  const persistentSessionId = this.configManager.rememberAgentSessionRecord(agent);
1816
- if (persistentSessionId) agent.persistentSessionId = persistentSessionId;
2610
+ setAgentRecordId(agent, persistentSessionId);
1817
2611
  return;
1818
2612
  }
1819
2613
  this.setMainPageSessionKeys([
@@ -2313,17 +3107,99 @@ class AgentManager extends EventEmitter {
2313
3107
  }, interval);
2314
3108
  }
2315
3109
 
2316
- async dispose(options = {}) {
2317
- if (this.disposed) return;
2318
- this.disposed = true;
2319
- if (this.heartbeatInterval) {
2320
- clearInterval(this.heartbeatInterval);
2321
- this.heartbeatInterval = null;
3110
+ dispose(options = {}) {
3111
+ if (this.disposed) return Promise.resolve();
3112
+ if (this.disposePromise) return this.disposePromise;
3113
+
3114
+ this.disposing = true;
3115
+ const disposePromise = this.performDispose(options);
3116
+ this.disposePromise = disposePromise;
3117
+ void disposePromise.finally(() => {
3118
+ if (this.disposePromise === disposePromise) this.disposePromise = null;
3119
+ if (!this.disposed && !this.disposeFrozen) this.disposing = false;
3120
+ }).catch(() => {});
3121
+ return disposePromise;
3122
+ }
3123
+
3124
+ async performDispose(options = {}) {
3125
+ await this.recoveryPromise;
3126
+ await this.drainAcceptedAgentOperations();
3127
+
3128
+ const jsonBindingIds = new Set(this.jsonCliRuntime?.bindings?.keys?.() || []);
3129
+ const acpBindingIds = new Set(this.acpRuntime?.bindings?.keys?.() || []);
3130
+ const runtimeCleanupFailures = [];
3131
+ let jsonCleanupFailed = false;
3132
+ let acpCleanupFailed = false;
3133
+ if (this.jsonCliRuntime && typeof this.jsonCliRuntime.dispose === 'function') {
3134
+ try {
3135
+ await this.jsonCliRuntime.dispose();
3136
+ } catch (error) {
3137
+ jsonCleanupFailed = true;
3138
+ runtimeCleanupFailures.push(error);
3139
+ }
3140
+ }
3141
+ if (this.acpRuntime && typeof this.acpRuntime.dispose === 'function') {
3142
+ try {
3143
+ await this.acpRuntime.dispose();
3144
+ } catch (error) {
3145
+ acpCleanupFailed = true;
3146
+ runtimeCleanupFailures.push(error);
3147
+ }
3148
+ }
3149
+
3150
+ let agentStateChanged = false;
3151
+ const forgetStoppedStructuredAgents = (agentIds, runtime, kind) => {
3152
+ for (const agentId of agentIds) {
3153
+ if (runtime?.bindings?.has?.(agentId)) continue;
3154
+ const agent = this.agents.get(agentId);
3155
+ if (!agent || runtimeKind(agent) !== kind) continue;
3156
+ this.forgetStoppedAgentRecord(agentId, { emitUpdate: false });
3157
+ agentStateChanged = true;
3158
+ }
3159
+ };
3160
+ forgetStoppedStructuredAgents(jsonBindingIds, this.jsonCliRuntime, 'json');
3161
+ forgetStoppedStructuredAgents(acpBindingIds, this.acpRuntime, 'acp');
3162
+ const markUncertainStructuredAgents = (agentIds, runtime, kind, failed) => {
3163
+ if (!failed) return;
3164
+ for (const agentId of agentIds) {
3165
+ if (!runtime?.bindings?.has?.(agentId)) continue;
3166
+ if (runtimeKind(this.agents.get(agentId)) !== kind) continue;
3167
+ this.markStructuredAgentCleanupUncertain(
3168
+ agentId,
3169
+ kind,
3170
+ `${kind.toUpperCase()} runtime cleanup could not prove process exit`,
3171
+ { emitUpdate: false },
3172
+ );
3173
+ agentStateChanged = true;
3174
+ }
3175
+ };
3176
+ markUncertainStructuredAgents(jsonBindingIds, this.jsonCliRuntime, 'json', jsonCleanupFailed);
3177
+ markUncertainStructuredAgents(acpBindingIds, this.acpRuntime, 'acp', acpCleanupFailed);
3178
+ if (agentStateChanged) this.emit('update');
3179
+
3180
+ if (runtimeCleanupFailures.length > 0) {
3181
+ this.jsonCliRuntime?.resumeAfterDisposeAbort?.();
3182
+ this.acpRuntime?.resumeAfterDisposeAbort?.();
3183
+ throw new AggregateError(runtimeCleanupFailures, 'Agent runtime cleanup could not be verified');
3184
+ }
3185
+
3186
+ this.disposeFrozen = true;
3187
+ if (this.engineBridge && typeof this.engineBridge.dispose === 'function') {
3188
+ await this.engineBridge.dispose({
3189
+ preserveHost: options.preserveTerminalHost === true,
3190
+ });
3191
+ }
3192
+
3193
+ if (this.heartbeatInterval) {
3194
+ clearInterval(this.heartbeatInterval);
3195
+ this.heartbeatInterval = null;
2322
3196
  }
2323
3197
  this.providerSessionService.dispose();
2324
3198
  this.agentWorktreeResolveGeneration.clear();
2325
- this.permissionRestartInFlight.clear();
2326
- this.runtimeRestartInFlight.clear();
3199
+ this.agentLifecycleOperations.clear();
3200
+ this.agentStartAdmissions.clear();
3201
+ this.activeInputOperations.clear();
3202
+ this.verifiedStoppedAgentIds.clear();
2327
3203
  this.permissionRestartSuppressedAgentIds.clear();
2328
3204
  this.pendingResizeByAgent.clear();
2329
3205
  this.resizeDrains.clear();
@@ -2332,14 +3208,22 @@ class AgentManager extends EventEmitter {
2332
3208
  this.codexTerminalStartQueues.clear();
2333
3209
  this.codexTerminalStartOutput.clear();
2334
3210
  this.acpSessionOptionsByKey.clear();
2335
- if (this.jsonCliRuntime) {
2336
- for (const agentId of this.agents.keys()) this.jsonCliRuntime.unregisterAgent(agentId);
2337
- }
2338
- if (this.acpRuntime && typeof this.acpRuntime.dispose === 'function') await this.acpRuntime.dispose();
2339
- if (this.engineBridge && typeof this.engineBridge.dispose === 'function') {
2340
- await this.engineBridge.dispose({
2341
- preserveHost: options.preserveTerminalHost === true,
2342
- });
3211
+ this.disposed = true;
3212
+ }
3213
+
3214
+ async drainAcceptedAgentOperations() {
3215
+ while (true) {
3216
+ const pending = new Set();
3217
+ for (const entry of new Set(this.agentLifecycleOperations.values())) {
3218
+ if (entry?.promise) pending.add(entry.promise);
3219
+ }
3220
+ for (const entry of this.agentStartAdmissions.values()) {
3221
+ if (entry?.promise) pending.add(entry.promise);
3222
+ }
3223
+ for (const operation of this.activeInputOperations) pending.add(operation);
3224
+ for (const operation of this.resizeDrains.values()) pending.add(operation);
3225
+ if (pending.size === 0) return;
3226
+ await Promise.allSettled([...pending]);
2343
3227
  }
2344
3228
  }
2345
3229
 
@@ -2471,9 +3355,134 @@ class AgentManager extends EventEmitter {
2471
3355
  }
2472
3356
  }
2473
3357
 
2474
- async startAgent(command, customWorkspace, callback, options = {}) {
2475
- if (options.wantsMain !== false && options.skipRecoveryWait !== true) {
2476
- await this.whenRecovered();
3358
+ startAgent(command, customWorkspace, callback, options = {}) {
3359
+ const lifecycleEntry = options.lifecycleToken
3360
+ ? [...new Set(this.agentLifecycleOperations.values())]
3361
+ .find(entry => entry.token === options.lifecycleToken)
3362
+ : null;
3363
+ if (this.disposing && !lifecycleEntry) {
3364
+ const error = 'Farming is shutting down; new Agents are not accepted';
3365
+ if (callback) callback(null, error);
3366
+ return Promise.resolve(null);
3367
+ }
3368
+
3369
+ const createRequestId = typeof options.createRequestId === 'string'
3370
+ ? options.createRequestId.trim().slice(0, 160)
3371
+ : '';
3372
+ const createRequestSignature = createOperationSignature(command, customWorkspace, options);
3373
+ const existingCreateRequest = createRequestId
3374
+ ? this.createRequestAdmissions.get(createRequestId)
3375
+ : null;
3376
+ if (existingCreateRequest) {
3377
+ if (existingCreateRequest.signature !== createRequestSignature) {
3378
+ const error = `Create request ${createRequestId} is already in progress with different Agent parameters`;
3379
+ if (callback) callback(null, error);
3380
+ return Promise.resolve(null);
3381
+ }
3382
+ return existingCreateRequest.promise.then(outcome => {
3383
+ if (callback) {
3384
+ callback(
3385
+ outcome.agentId || null,
3386
+ outcome.error || null,
3387
+ {
3388
+ ...(outcome.metadata || {}),
3389
+ deduplicated: true,
3390
+ },
3391
+ );
3392
+ }
3393
+ return outcome.agentId || null;
3394
+ });
3395
+ }
3396
+
3397
+ let resolveAdmission;
3398
+ const token = Symbol('agent-start-admission');
3399
+ const promise = new Promise(resolve => {
3400
+ resolveAdmission = resolve;
3401
+ });
3402
+ const requestedProjectWorkspace = options.wantsMain === true
3403
+ ? ''
3404
+ : (typeof options.projectWorkspace === 'string' && options.projectWorkspace.trim()
3405
+ ? options.projectWorkspace
3406
+ : customWorkspace);
3407
+ const admission = {
3408
+ token,
3409
+ promise,
3410
+ workspaceKey: requestedProjectWorkspace
3411
+ ? canonicalWorkspacePath(this.expandWorkspacePath(requestedProjectWorkspace))
3412
+ : '',
3413
+ };
3414
+ this.agentStartAdmissions.set(token, admission);
3415
+ let callbackOutcome = null;
3416
+ const reportStart = (agentId, error, metadata = {}) => {
3417
+ callbackOutcome = {
3418
+ agentId: agentId || null,
3419
+ error: error || null,
3420
+ metadata,
3421
+ };
3422
+ if (callback) callback(agentId, error, metadata);
3423
+ };
3424
+ const startPromise = this.startAgentAdmitted(command, customWorkspace, reportStart, {
3425
+ ...options,
3426
+ startAdmissionToken: token,
3427
+ });
3428
+ const admittedPromise = Promise.resolve(startPromise).finally(() => {
3429
+ if (this.agentStartAdmissions.get(token) === admission) {
3430
+ this.agentStartAdmissions.delete(token);
3431
+ }
3432
+ resolveAdmission();
3433
+ });
3434
+ if (!createRequestId) return admittedPromise;
3435
+
3436
+ const outcomePromise = admittedPromise.then(agentId => (
3437
+ callbackOutcome || {
3438
+ agentId: agentId || null,
3439
+ error: agentId ? null : 'Failed to start Agent',
3440
+ }
3441
+ ));
3442
+ const requestAdmission = {
3443
+ signature: createRequestSignature,
3444
+ promise: outcomePromise,
3445
+ };
3446
+ this.createRequestAdmissions.set(createRequestId, requestAdmission);
3447
+ void outcomePromise.finally(() => {
3448
+ if (this.createRequestAdmissions.get(createRequestId) === requestAdmission) {
3449
+ this.createRequestAdmissions.delete(createRequestId);
3450
+ }
3451
+ }).catch(() => {});
3452
+ return outcomePromise.then(outcome => outcome.agentId || null);
3453
+ }
3454
+
3455
+ async startAgentAdmitted(command, customWorkspace, callback, options = {}) {
3456
+ const createRequestId = typeof options.createRequestId === 'string'
3457
+ ? options.createRequestId.trim().slice(0, 160)
3458
+ : '';
3459
+ if (options.skipRecoveryWait !== true) {
3460
+ try {
3461
+ await this.whenRecovered();
3462
+ } catch (error) {
3463
+ if (callback) callback(null, error.message || String(error));
3464
+ return null;
3465
+ }
3466
+ }
3467
+ const createRequestSignature = createOperationSignature(command, customWorkspace, options);
3468
+ if (createRequestId) {
3469
+ const replay = await this.replayPersistentCreateRequest(
3470
+ createRequestId,
3471
+ createRequestSignature,
3472
+ );
3473
+ if (replay) {
3474
+ if (callback) {
3475
+ callback(
3476
+ replay.agentId || null,
3477
+ replay.error || null,
3478
+ {
3479
+ deduplicated: replay.deduplicated === true,
3480
+ createResult: replay.createResult || null,
3481
+ },
3482
+ );
3483
+ }
3484
+ return replay.agentId || null;
3485
+ }
2477
3486
  }
2478
3487
 
2479
3488
  const wantsMain = options.wantsMain === true || (options.wantsMain !== false && !this.mainAgentId);
@@ -2721,6 +3730,18 @@ class AgentManager extends EventEmitter {
2721
3730
  }
2722
3731
  }
2723
3732
  }
3733
+
3734
+ const projectWorkspaceKey = canonicalWorkspacePath(projectWorkspace);
3735
+ const startAdmission = this.agentStartAdmissions.get(options.startAdmissionToken);
3736
+ if (startAdmission) startAdmission.workspaceKey = projectWorkspaceKey;
3737
+ const deletingProjectWorkspace = projectWorkspaceKey
3738
+ ? [...this.projectWorkspaceDeleteAdmissions.keys()]
3739
+ .find(candidate => isSameOrDescendantPath(candidate, projectWorkspaceKey))
3740
+ : '';
3741
+ if (deletingProjectWorkspace) {
3742
+ if (callback) callback(null, `Project worktree is being deleted: ${projectWorkspace}`);
3743
+ return null;
3744
+ }
2724
3745
 
2725
3746
  if (!fs.existsSync(workspace)) {
2726
3747
  console.log('Workspace does not exist:', workspace);
@@ -2786,6 +3807,18 @@ class AgentManager extends EventEmitter {
2786
3807
  const requestedAgentRuntimeMode = ['json', 'acp', 'chat'].includes(options.agentRuntimeMode)
2787
3808
  ? options.agentRuntimeMode
2788
3809
  : 'terminal';
3810
+ if (
3811
+ requestedAgentRuntimeMode === 'json'
3812
+ && options.allowLegacyJsonRuntime !== true
3813
+ ) {
3814
+ if (callback) {
3815
+ callback(
3816
+ null,
3817
+ 'JSON CLI Chat is a legacy compatibility reader and cannot create a new Agent. Use Chat (ACP) or Terminal.',
3818
+ );
3819
+ }
3820
+ return null;
3821
+ }
2789
3822
  // A fresh structured runtime does not need a provider CLI resume id yet:
2790
3823
  // ACP/JSON creates the provider session and writes the resulting id back
2791
3824
  // after connecting. Fresh Terminal sessions are handled separately below.
@@ -2836,14 +3869,23 @@ class AgentManager extends EventEmitter {
2836
3869
  providerSessionProvider: useAcp || useJsonCli ? structuredRuntimeProvider : (providerSessionPlan.provider || ''),
2837
3870
  providerHomeId: resolvedProviderHomeId,
2838
3871
  providerHomePath,
2839
- providerSessionId: providerSessionPlan.id || '',
2840
- providerSessionKey: mainPageAgentSessionKey(providerSessionPlan.provider, providerSessionPlan.id, providerHome ? providerHome.id : providerHomeId),
2841
- providerSessionTemporary: providerSessionPlan.temporary === true,
3872
+ providerSessionId: acpGeneratedFreshSession ? '' : (providerSessionPlan.id || ''),
3873
+ providerSessionKey: acpGeneratedFreshSession
3874
+ ? ''
3875
+ : mainPageAgentSessionKey(
3876
+ providerSessionPlan.provider,
3877
+ providerSessionPlan.id,
3878
+ providerHome ? providerHome.id : providerHomeId,
3879
+ ),
3880
+ providerSessionTemporary: acpGeneratedFreshSession || providerSessionPlan.temporary === true,
2842
3881
  providerSessionSource: providerSessionPlan.source || '',
2843
- providerSessionResolvedAt: providerSessionPlan.temporary === true ? null : Date.now(),
3882
+ providerSessionResolvedAt: acpGeneratedFreshSession || providerSessionPlan.temporary === true
3883
+ ? null
3884
+ : Date.now(),
2844
3885
  providerSessionTitle: typeof options.providerSessionTitle === 'string' ? options.providerSessionTitle.trim().slice(0, 160) : '',
2845
3886
  providerSessionWorkspace: '',
2846
3887
  terminalInputReceived: false,
3888
+ structuredRuntimeProcess: null,
2847
3889
  runtimeBinding: useAcp
2848
3890
  ? runtimeBindingFor('acp', { state: 'connecting' })
2849
3891
  : (useJsonCli
@@ -2852,7 +3894,9 @@ class AgentManager extends EventEmitter {
2852
3894
  events: Array.isArray(options.jsonCliEvents) ? options.jsonCliEvents : [],
2853
3895
  })
2854
3896
  : runtimeBindingFor('terminal')),
2855
- forkedFromProviderSessionId: providerSessionPlan.forkedFromProviderSessionId || '',
3897
+ forkedFromProviderSessionId: typeof options.forkedFromProviderSessionId === 'string'
3898
+ ? options.forkedFromProviderSessionId
3899
+ : (providerSessionPlan.forkedFromProviderSessionId || ''),
2856
3900
  restartedFromAgentId: typeof options.restartedFromAgentId === 'string' ? options.restartedFromAgentId : '',
2857
3901
  restartedFromAgentIds: Array.isArray(options.restartedFromAgentIds)
2858
3902
  ? Array.from(new Set(options.restartedFromAgentIds.filter(id => typeof id === 'string' && id)))
@@ -2860,7 +3904,12 @@ class AgentManager extends EventEmitter {
2860
3904
  runtimeSwitchVerifiedSessionId: typeof options.runtimeSwitchVerifiedSessionId === 'string'
2861
3905
  ? options.runtimeSwitchVerifiedSessionId
2862
3906
  : '',
2863
- persistentSessionId: typeof options.persistentSessionId === 'string' ? options.persistentSessionId : '',
3907
+ agentRecordId: typeof options.agentRecordId === 'string'
3908
+ ? options.agentRecordId
3909
+ : (typeof options.persistentSessionId === 'string' ? options.persistentSessionId : ''),
3910
+ persistentSessionId: typeof options.agentRecordId === 'string'
3911
+ ? options.agentRecordId
3912
+ : (typeof options.persistentSessionId === 'string' ? options.persistentSessionId : ''),
2864
3913
  customTitle: typeof options.customTitle === 'string' ? options.customTitle.trim().slice(0, 80) : '',
2865
3914
  terminalBusy: null,
2866
3915
  shellCwd: '',
@@ -2898,6 +3947,90 @@ class AgentManager extends EventEmitter {
2898
3947
  attentionSuppressUntil: 0
2899
3948
  };
2900
3949
 
3950
+ let previousPersistentRuntimeAgentId = '';
3951
+ let previousPersistentRecord = null;
3952
+ if (
3953
+ agentRecord.providerSessionKey
3954
+ && typeof this.configManager?.getAgentSessionRecordForProviderSessionKey === 'function'
3955
+ ) {
3956
+ const existingRecord = this.configManager.getAgentSessionRecordForProviderSessionKey(
3957
+ agentRecord.providerSessionKey,
3958
+ );
3959
+ if (existingRecord) {
3960
+ previousPersistentRecord = existingRecord;
3961
+ agentRecord.lifecycleJournal = lifecycleJournal(existingRecord);
3962
+ previousPersistentRuntimeAgentId = String(existingRecord.runtimeAgentId || '').trim();
3963
+ }
3964
+ }
3965
+
3966
+ let finishStartLifecycle = () => {};
3967
+ if (
3968
+ options.lifecycleToken
3969
+ && !this.adoptAgentLifecycleOperation(agentId, options.lifecycleToken)
3970
+ ) {
3971
+ if (callback) callback(null, 'Agent lifecycle operation is no longer active');
3972
+ return null;
3973
+ }
3974
+ if (!options.lifecycleToken) {
3975
+ finishStartLifecycle = this.beginAgentStartLifecycleOperation(agentId, options.startAdmissionToken);
3976
+ if (!finishStartLifecycle) {
3977
+ if (callback) callback(null, 'Agent lifecycle operation already in progress');
3978
+ return null;
3979
+ }
3980
+ }
3981
+
3982
+ const createAdmission = this.beginPersistentAgentOperation(
3983
+ agentRecord,
3984
+ 'create',
3985
+ options.createRequestId
3986
+ ? `create-request:${String(options.createRequestId).trim().slice(0, 160)}`
3987
+ : `create:${agentId}`,
3988
+ {
3989
+ agentId,
3990
+ previousRuntimeAgentId: (
3991
+ typeof options.restoreRuntimeAgentIdOnFailure === 'string'
3992
+ && options.restoreRuntimeAgentIdOnFailure
3993
+ )
3994
+ ? options.restoreRuntimeAgentIdOnFailure
3995
+ : previousPersistentRuntimeAgentId,
3996
+ previousState: createRollbackState(previousPersistentRecord),
3997
+ signature: createOperationSignature(command, customWorkspace, options),
3998
+ command: agentRecord.command,
3999
+ cwd: agentRecord.cwd,
4000
+ runtimeKind: runtimeKind(agentRecord),
4001
+ structuredProcessProofRequired: useAcp,
4002
+ structuredProcessStartGated: useAcp && process.platform !== 'win32',
4003
+ },
4004
+ );
4005
+ if (createAdmission.error) {
4006
+ finishStartLifecycle();
4007
+ if (callback) callback(null, createAdmission.error);
4008
+ return null;
4009
+ }
4010
+ const createOperationId = createAdmission.operation.id;
4011
+ const rollbackCreatePatch = () => createFailurePatch(
4012
+ createAdmission.operation,
4013
+ options.restoreRuntimeAgentIdOnFailure,
4014
+ );
4015
+ const restoreCreateSessionOptions = () => {
4016
+ const previousState = createAdmission.operation.request?.previousState;
4017
+ const sessionKey = previousPersistentRecord?.providerSessionKey
4018
+ || agentRecord.providerSessionKey;
4019
+ if (!sessionKey) return;
4020
+ if (
4021
+ previousState
4022
+ && Array.isArray(previousState.acpAdditionalDirectories)
4023
+ && Array.isArray(previousState.acpMcpServers)
4024
+ ) {
4025
+ this.acpSessionOptionsByKey.set(sessionKey, {
4026
+ additionalDirectories: [...previousState.acpAdditionalDirectories],
4027
+ mcpServers: JSON.parse(JSON.stringify(previousState.acpMcpServers)),
4028
+ });
4029
+ } else {
4030
+ this.acpSessionOptionsByKey.delete(sessionKey);
4031
+ }
4032
+ };
4033
+
2901
4034
  let precreatedProviderSession = null;
2902
4035
  if (
2903
4036
  providerSessionPlan.precreate === true
@@ -2907,6 +4040,19 @@ class AgentManager extends EventEmitter {
2907
4040
  const adapter = getProviderAdapter(providerSessionPlan.provider);
2908
4041
  if (typeof adapter?.terminalResumeArgs !== 'function') {
2909
4042
  const message = `${providerSessionPlan.provider} cannot resume a pre-created Terminal session`;
4043
+ try {
4044
+ this.transitionPersistentAgentOperation(
4045
+ agentRecord,
4046
+ createOperationId,
4047
+ 'failed',
4048
+ message,
4049
+ rollbackCreatePatch(),
4050
+ );
4051
+ restoreCreateSessionOptions();
4052
+ } catch (persistError) {
4053
+ console.error('Failed to persist Create failure:', persistError);
4054
+ }
4055
+ finishStartLifecycle();
2910
4056
  if (callback) callback(null, message);
2911
4057
  return null;
2912
4058
  }
@@ -3015,6 +4161,23 @@ class AgentManager extends EventEmitter {
3015
4161
  message = `${baseMessage}; ${identityRetainedReason}`;
3016
4162
  }
3017
4163
  console.error('Failed to create provider session identity:', error);
4164
+ try {
4165
+ this.transitionPersistentAgentOperation(
4166
+ agentRecord,
4167
+ createOperationId,
4168
+ identityRetainedReason || identityRollbackError ? 'blocked' : 'failed',
4169
+ message,
4170
+ identityRetainedReason || identityRollbackError
4171
+ ? { visibleOnMainPage: true, archived: false }
4172
+ : rollbackCreatePatch(),
4173
+ );
4174
+ if (!(identityRetainedReason || identityRollbackError)) {
4175
+ restoreCreateSessionOptions();
4176
+ }
4177
+ } catch (persistError) {
4178
+ message = `${message}; failed to persist Create failure: ${persistError.message || persistError}`;
4179
+ }
4180
+ finishStartLifecycle();
3018
4181
  if (callback) callback(null, message);
3019
4182
  return null;
3020
4183
  }
@@ -3027,15 +4190,15 @@ class AgentManager extends EventEmitter {
3027
4190
  ));
3028
4191
  console.log('Starting agent:', program, logArgs, 'workspace:', workspace, spawnProgram !== program ? `resolved: ${spawnProgram}` : '');
3029
4192
 
3030
- ensureAgentOrders(agentRecord, Array.from(this.agents.values()));
3031
- this.agents.set(agentId, agentRecord);
3032
- void this.refreshAgentWorktree(agentId);
3033
-
3034
- this.lastActivity.set(agentId, Date.now());
3035
-
3036
- this.emit('update');
4193
+ let structuredRuntimeRegistered = false;
3037
4194
 
3038
4195
  try {
4196
+ ensureAgentOrders(agentRecord, Array.from(this.agents.values()));
4197
+ this.agents.set(agentId, agentRecord);
4198
+ void this.refreshAgentWorktree(agentId);
4199
+ this.lastActivity.set(agentId, Date.now());
4200
+ this.emit('update');
4201
+
3039
4202
  if (useJsonCli) {
3040
4203
  const jsonRuntime = runtimeBindingOf(agentRecord, 'json');
3041
4204
  this.jsonCliRuntime.registerAgent({
@@ -3049,6 +4212,7 @@ class AgentManager extends EventEmitter {
3049
4212
  autoApprove: options.dangerouslySkipPermissions === true,
3050
4213
  initialEvents: jsonRuntime.events,
3051
4214
  });
4215
+ structuredRuntimeRegistered = true;
3052
4216
  }
3053
4217
 
3054
4218
  if (useAcp) {
@@ -3088,7 +4252,50 @@ class AgentManager extends EventEmitter {
3088
4252
  serviceTier: codexServiceTier,
3089
4253
  additionalDirectories,
3090
4254
  mcpServers,
4255
+ forkSourceSessionId: options.acpForkSourceSessionId || '',
4256
+ forkSourceCheckpoint: options.acpForkSourceCheckpoint || null,
4257
+ onForkSessionCreated: async sessionId => {
4258
+ const exactSessionId = String(sessionId || '').trim();
4259
+ if (!isSafeProviderSessionId(exactSessionId)) {
4260
+ throw new Error('ACP fork startup returned an unsafe provider session id');
4261
+ }
4262
+ agentRecord.providerSessionId = exactSessionId;
4263
+ agentRecord.providerSessionKey = mainPageAgentSessionKey(
4264
+ structuredRuntimeProvider,
4265
+ exactSessionId,
4266
+ agentRecord.providerHomeId || 'default'
4267
+ );
4268
+ agentRecord.providerSessionTemporary = false;
4269
+ agentRecord.providerSessionSource = 'acp-fork';
4270
+ agentRecord.providerSessionResolvedAt = Date.now();
4271
+ let normalizedSessionOptions = { additionalDirectories, mcpServers };
4272
+ try {
4273
+ normalizedSessionOptions = this.acpRuntime.getSessionRequestOptions(agentId);
4274
+ } catch {
4275
+ // The live binding already validated the scope. Retain the caller
4276
+ // copy only for custom runtimes that do not expose it.
4277
+ }
4278
+ this.acpSessionOptionsByKey.set(agentRecord.providerSessionKey, {
4279
+ additionalDirectories: [...normalizedSessionOptions.additionalDirectories],
4280
+ mcpServers: JSON.parse(JSON.stringify(normalizedSessionOptions.mcpServers)),
4281
+ });
4282
+ if (typeof options.onAcpForkSessionCreated === 'function') {
4283
+ await options.onAcpForkSessionCreated(exactSessionId);
4284
+ }
4285
+ this.ensurePersistentAgentSession(agentRecord);
4286
+ },
4287
+ onProcessStarted: async processIdentity => {
4288
+ agentRecord.structuredRuntimeProcess = {
4289
+ kind: 'acp-process-group',
4290
+ ...processIdentity,
4291
+ };
4292
+ this.ensurePersistentAgentSession(agentRecord);
4293
+ },
3091
4294
  });
4295
+ if (typeof options.onAcpSessionPrepared === 'function') {
4296
+ await options.onAcpSessionPrepared(prepared);
4297
+ }
4298
+ structuredRuntimeRegistered = true;
3092
4299
  agentRecord.providerSessionId = prepared.sessionId;
3093
4300
  agentRecord.providerSessionKey = mainPageAgentSessionKey(
3094
4301
  structuredRuntimeProvider,
@@ -3143,13 +4350,19 @@ class AgentManager extends EventEmitter {
3143
4350
  await startTerminal();
3144
4351
  }
3145
4352
  }
3146
- agentRecord.persistentSessionId = this.ensurePersistentAgentSession(agentRecord, {
4353
+ this.transitionPersistentAgentOperation(agentRecord, createOperationId, 'succeeded', '', {
3147
4354
  visibleOnMainPage: true,
3148
4355
  archived: false,
3149
4356
  ...(options.customTitleExplicit === true
3150
4357
  ? { customTitle: agentRecord.customTitle }
3151
4358
  : {}),
3152
4359
  });
4360
+ if (
4361
+ previousPersistentRuntimeAgentId
4362
+ && previousPersistentRuntimeAgentId !== agentId
4363
+ ) {
4364
+ this.forgetStoppedAgentRecord(previousPersistentRuntimeAgentId, { emitUpdate: false });
4365
+ }
3153
4366
 
3154
4367
  const agent = this.agents.get(agentId);
3155
4368
  if (agent && agent.status === 'pending') {
@@ -3163,13 +4376,37 @@ class AgentManager extends EventEmitter {
3163
4376
  }
3164
4377
 
3165
4378
  this.providerSessionService.activate(agentId);
4379
+ finishStartLifecycle();
3166
4380
  if (callback) callback(agentId);
3167
4381
  this.emit('update');
3168
4382
  return agentId;
3169
4383
  } catch (error) {
3170
4384
  console.error('Failed to start agent:', error);
3171
4385
  let runtimeCleanupError = null;
3172
- if (!useJsonCli && !useAcp) {
4386
+ if (useJsonCli && structuredRuntimeRegistered) {
4387
+ try {
4388
+ const stopped = await this.jsonCliRuntime.unregisterAgentAndWait(agentId);
4389
+ if (stopped !== true) {
4390
+ throw new Error('JSON runtime binding disappeared before exit was verified', { cause: error });
4391
+ }
4392
+ } catch (cleanupError) {
4393
+ runtimeCleanupError = cleanupError;
4394
+ }
4395
+ } else if (
4396
+ useAcp
4397
+ && error?.runtimeCleanupVerified !== true
4398
+ && (structuredRuntimeRegistered || error?.runtimeCleanupAttempted === true)
4399
+ ) {
4400
+ try {
4401
+ const stopped = await this.acpRuntime.unregisterAgentAndWait(agentId);
4402
+ if (stopped !== true) {
4403
+ throw error?.adapterCleanupError
4404
+ || new Error('ACP runtime binding disappeared before exit was verified', { cause: error });
4405
+ }
4406
+ } catch (cleanupError) {
4407
+ runtimeCleanupError = cleanupError;
4408
+ }
4409
+ } else if (!useJsonCli && !useAcp) {
3173
4410
  try {
3174
4411
  await this.stopUncertainTerminalSession(resolution.engine, agentId);
3175
4412
  } catch (engineCleanupError) {
@@ -3180,6 +4417,12 @@ class AgentManager extends EventEmitter {
3180
4417
  );
3181
4418
  }
3182
4419
  }
4420
+ if (runtimeCleanupError) {
4421
+ console.error(
4422
+ 'Failed to stop partially started Agent runtime:',
4423
+ runtimeCleanupError.message || runtimeCleanupError
4424
+ );
4425
+ }
3183
4426
  let rollbackError = null;
3184
4427
  if (precreatedProviderSession && !runtimeCleanupError) {
3185
4428
  try {
@@ -3195,19 +4438,55 @@ class AgentManager extends EventEmitter {
3195
4438
  } else if (precreatedProviderSession) {
3196
4439
  this.acpSessionOptionsByKey.delete(precreatedProviderSession.sessionKey);
3197
4440
  }
3198
- if (options.restoreRuntimeAgentIdOnFailure && agentRecord.persistentSessionId) {
3199
- const failedAgentId = agentRecord.id;
4441
+ const startupError = error && (error.message || String(error));
4442
+ const cleanupSuffix = rollbackError
4443
+ ? `; provider session rollback failed: ${rollbackError.message || rollbackError}`
4444
+ : '';
4445
+ const runtimeCleanupSuffix = runtimeCleanupError
4446
+ ? `; runtime cleanup could not be verified and Agent ${agentId} was retained for retry: ${runtimeCleanupError.message || runtimeCleanupError}`
4447
+ : '';
4448
+ if (runtimeCleanupError) {
4449
+ agentRecord.status = 'error';
4450
+ agentRecord.engineStatus = 'cleanup-uncertain';
4451
+ const runtime = runtimeBindingOf(agentRecord);
4452
+ if (runtime && Object.prototype.hasOwnProperty.call(runtime, 'state')) {
4453
+ runtime.state = 'error';
4454
+ runtime.error = runtimeCleanupError.message || String(runtimeCleanupError);
4455
+ }
3200
4456
  try {
3201
- agentRecord.id = options.restoreRuntimeAgentIdOnFailure;
3202
- this.ensurePersistentAgentSession(agentRecord);
3203
- } catch (rollbackError) {
3204
- console.error(
3205
- 'Failed to roll back persisted Agent runtime id after restart failure:',
3206
- rollbackError && (rollbackError.message || rollbackError)
4457
+ this.transitionPersistentAgentOperation(
4458
+ agentRecord,
4459
+ createOperationId,
4460
+ 'blocked',
4461
+ runtimeCleanupError.message || String(runtimeCleanupError),
4462
+ );
4463
+ } catch (persistError) {
4464
+ runtimeCleanupError = new Error(
4465
+ `${runtimeCleanupError.message || runtimeCleanupError}; failed to persist blocked Create: ${persistError.message || persistError}`,
3207
4466
  );
3208
- } finally {
3209
- agentRecord.id = failedAgentId;
3210
4467
  }
4468
+ finishStartLifecycle();
4469
+ this.emit('update');
4470
+ if (callback) callback(agentId, `${startupError}${runtimeCleanupSuffix}${cleanupSuffix}`);
4471
+ return null;
4472
+ }
4473
+ try {
4474
+ this.transitionPersistentAgentOperation(
4475
+ agentRecord,
4476
+ createOperationId,
4477
+ 'failed',
4478
+ startupError,
4479
+ rollbackCreatePatch(),
4480
+ );
4481
+ restoreCreateSessionOptions();
4482
+ } catch (persistError) {
4483
+ agentRecord.status = 'error';
4484
+ agentRecord.engineStatus = 'metadata-commit-uncertain';
4485
+ const message = `${startupError}; failed to persist Create failure: ${persistError.message || persistError}`;
4486
+ finishStartLifecycle();
4487
+ this.emit('update');
4488
+ if (callback) callback(agentId, message);
4489
+ return null;
3211
4490
  }
3212
4491
  this.agents.delete(agentId);
3213
4492
  this.lastActivity.delete(agentId);
@@ -3222,26 +4501,38 @@ class AgentManager extends EventEmitter {
3222
4501
  this.mainAgentId = null;
3223
4502
  }
3224
4503
 
4504
+ finishStartLifecycle();
3225
4505
  this.emit('update');
3226
- const startupError = error && (error.message || String(error));
3227
- const cleanupSuffix = rollbackError
3228
- ? `; provider session rollback failed: ${rollbackError.message || rollbackError}`
3229
- : '';
3230
- const runtimeCleanupSuffix = runtimeCleanupError
3231
- ? `; Terminal rollback failed and the provider session was retained: ${runtimeCleanupError.message || runtimeCleanupError}`
3232
- : '';
3233
4506
  if (callback) callback(null, `${startupError}${runtimeCleanupSuffix}${cleanupSuffix}`);
3234
4507
  return null;
3235
4508
  }
3236
4509
  }
3237
4510
 
3238
- async enqueueInputOperation(agentId, operation) {
4511
+ trackInputOperation(operation) {
4512
+ this.activeInputOperations.add(operation);
4513
+ void operation.finally(() => {
4514
+ this.activeInputOperations.delete(operation);
4515
+ }).catch(() => {});
4516
+ return operation;
4517
+ }
4518
+
4519
+ assertAgentOperationAdmission() {
4520
+ if (this.disposing) {
4521
+ throw new Error('Farming is shutting down; Agent operations are not accepted');
4522
+ }
4523
+ }
4524
+
4525
+ async enqueueInputOperation(agentId, operation, options = {}) {
4526
+ if (this.disposing && options.admitted !== true) {
4527
+ throw new Error('Farming is shutting down; Agent input is not accepted');
4528
+ }
3239
4529
  const previous = this.inputQueues.get(agentId) || Promise.resolve();
3240
4530
  const next = previous
3241
4531
  .catch(() => {})
3242
4532
  .then(operation);
3243
4533
 
3244
4534
  this.inputQueues.set(agentId, next);
4535
+ this.trackInputOperation(next);
3245
4536
  try {
3246
4537
  return await next;
3247
4538
  } finally {
@@ -3252,6 +4543,9 @@ class AgentManager extends EventEmitter {
3252
4543
  }
3253
4544
 
3254
4545
  async enqueueInputOperationUntilReleased(agentId, operation) {
4546
+ if (this.disposing) {
4547
+ throw new Error('Farming is shutting down; Agent input is not accepted');
4548
+ }
3255
4549
  const previous = this.inputQueues.get(agentId) || Promise.resolve();
3256
4550
  let released = false;
3257
4551
  let resolveReleased;
@@ -3266,6 +4560,7 @@ class AgentManager extends EventEmitter {
3266
4560
 
3267
4561
  const ready = previous.catch(() => {});
3268
4562
  const completion = ready.then(() => operation(release));
4563
+ this.trackInputOperation(completion);
3269
4564
  completion.catch(() => release());
3270
4565
  const boundary = ready.then(() => releasedPromise);
3271
4566
  this.inputQueues.set(agentId, boundary);
@@ -3282,12 +4577,12 @@ class AgentManager extends EventEmitter {
3282
4577
  }
3283
4578
 
3284
4579
  async sendComposerMessage(agentId, message) {
3285
- if (this.acpRuntime.canSteer?.(agentId)) {
3286
- try {
3287
- return await this.sendAcpSteerNow(agentId, message);
3288
- } catch (error) {
3289
- if (!isCodexSteerUnavailableError(error)) throw error;
3290
- }
4580
+ const agent = this.agents.get(agentId);
4581
+ if (agent && isAcpAgent(agent)) {
4582
+ return this.enqueueInputOperationUntilReleased(
4583
+ agentId,
4584
+ releaseInput => this.sendComposerMessageNow(agentId, message, { releaseInput }),
4585
+ );
3291
4586
  }
3292
4587
  return this.enqueueInputOperation(agentId, () => this.sendComposerMessageNow(agentId, message));
3293
4588
  }
@@ -3383,7 +4678,7 @@ class AgentManager extends EventEmitter {
3383
4678
  return applied;
3384
4679
  }
3385
4680
 
3386
- async sendComposerMessageNow(agentId, message) {
4681
+ async sendComposerMessageNow(agentId, message, options = {}) {
3387
4682
  const agent = this.agents.get(agentId);
3388
4683
  if (!agent) throw new Error('Agent not found');
3389
4684
  const prompt = normalizedComposerPrompt(message);
@@ -3404,10 +4699,15 @@ class AgentManager extends EventEmitter {
3404
4699
  }
3405
4700
 
3406
4701
  if (isAcpAgent(agent)) {
3407
- const result = await this.acpRuntime.prompt(agentId, prompt);
3408
- const runtime = runtimeBindingOf(agent, 'acp');
3409
- runtime.state = 'idle';
3410
- runtime.stopReason = result.stopReason || '';
4702
+ this.requireLiveAcpAgent(agentId);
4703
+ const result = await this.acpRuntime.submitMessage(agentId, prompt, {
4704
+ onSubmitted: options.releaseInput,
4705
+ });
4706
+ if (result.steered !== true) {
4707
+ const runtime = runtimeBindingOf(agent, 'acp');
4708
+ runtime.state = 'idle';
4709
+ runtime.stopReason = result.stopReason || '';
4710
+ }
3411
4711
  this.ensurePersistentAgentSession(agent);
3412
4712
  return { kind: 'acp', ...result };
3413
4713
  }
@@ -3416,16 +4716,6 @@ class AgentManager extends EventEmitter {
3416
4716
  return { kind: 'terminal' };
3417
4717
  }
3418
4718
 
3419
- async sendAcpSteerNow(agentId, message) {
3420
- const agent = this.agents.get(agentId);
3421
- if (!agent) throw new Error('Agent not found');
3422
- if (!isAcpAgent(agent)) throw new Error('Agent is not using ACP Chat');
3423
- const prompt = normalizedComposerPrompt(message);
3424
- const result = await this.acpRuntime.steer(agentId, prompt);
3425
- this.ensurePersistentAgentSession(agent);
3426
- return { kind: 'acp', steered: true, ...result };
3427
- }
3428
-
3429
4719
  getJsonCliTranscript(agentId, options = {}) {
3430
4720
  const agent = this.agents.get(agentId);
3431
4721
  if (!agent) throw new Error('Agent not found');
@@ -3434,16 +4724,12 @@ class AgentManager extends EventEmitter {
3434
4724
  }
3435
4725
 
3436
4726
  getAcpSession(agentId, options = {}) {
3437
- const agent = this.agents.get(agentId);
3438
- if (!agent) throw new Error('Agent not found');
3439
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4727
+ this.requireLiveAcpAgent(agentId);
3440
4728
  return this.acpRuntime.getSession(agentId, options);
3441
4729
  }
3442
4730
 
3443
4731
  getAcpTranscript(agentId, options = {}) {
3444
- const agent = this.agents.get(agentId);
3445
- if (!agent) throw new Error('Agent not found');
3446
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4732
+ this.requireLiveAcpAgent(agentId);
3447
4733
  const transcript = this.acpRuntime.getTranscriptSession(agentId, options);
3448
4734
  return {
3449
4735
  ...transcript,
@@ -3452,9 +4738,7 @@ class AgentManager extends EventEmitter {
3452
4738
  }
3453
4739
 
3454
4740
  getAcpToolDetail(agentId, toolCallId) {
3455
- const agent = this.agents.get(agentId);
3456
- if (!agent) throw new Error('Agent not found');
3457
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4741
+ this.requireLiveAcpAgent(agentId);
3458
4742
  const entry = this.acpRuntime.getToolEntry(agentId, toolCallId);
3459
4743
  if (!entry) throw new Error('ACP tool call not found');
3460
4744
  const subagentSessionId = String(entry?._meta?.subagent_session_info?.session_id || '');
@@ -3473,34 +4757,37 @@ class AgentManager extends EventEmitter {
3473
4757
  }
3474
4758
 
3475
4759
  killAcpTerminal(agentId, terminalId) {
4760
+ this.assertAgentOperationAdmission();
3476
4761
  this.getAcpSession(agentId);
3477
4762
  return this.acpRuntime.killTerminal(agentId, terminalId);
3478
4763
  }
3479
4764
 
3480
4765
  inputAcpTerminal(agentId, terminalId, input) {
4766
+ this.assertAgentOperationAdmission();
3481
4767
  this.getAcpSession(agentId);
3482
4768
  return this.acpRuntime.inputTerminal(agentId, terminalId, input);
3483
4769
  }
3484
4770
 
3485
4771
  resizeAcpTerminal(agentId, terminalId, cols, rows) {
4772
+ this.assertAgentOperationAdmission();
3486
4773
  this.getAcpSession(agentId);
3487
4774
  return this.acpRuntime.resizeTerminal(agentId, terminalId, cols, rows);
3488
4775
  }
3489
4776
 
3490
4777
  cancelAcpSubagent(agentId, sessionId) {
4778
+ this.assertAgentOperationAdmission();
3491
4779
  this.getAcpSession(agentId);
3492
4780
  return this.acpRuntime.cancelSubagent(agentId, sessionId);
3493
4781
  }
3494
4782
 
3495
4783
  decideAcpPatch(agentId, toolCallId, requestedPath, decision) {
4784
+ this.assertAgentOperationAdmission();
3496
4785
  this.getAcpSession(agentId);
3497
4786
  return this.acpRuntime.decidePatch(agentId, toolCallId, requestedPath, decision);
3498
4787
  }
3499
4788
 
3500
4789
  getAcpReviewChanges(agentId, toolCallIds) {
3501
- const agent = this.agents.get(agentId);
3502
- if (!agent) throw new Error('Agent not found');
3503
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4790
+ this.requireLiveAcpAgent(agentId);
3504
4791
  if (!Array.isArray(toolCallIds) || toolCallIds.length === 0 || toolCallIds.length > 256) {
3505
4792
  throw new Error('ACP review tool calls are invalid');
3506
4793
  }
@@ -3515,62 +4802,84 @@ class AgentManager extends EventEmitter {
3515
4802
  }
3516
4803
 
3517
4804
  listAcpSessions(agentId, options = {}) {
3518
- const agent = this.agents.get(agentId);
3519
- if (!agent) throw new Error('Agent not found');
3520
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4805
+ this.requireLiveAcpAgent(agentId);
3521
4806
  return this.acpRuntime.listSessions(agentId, options);
3522
4807
  }
3523
4808
 
3524
4809
  respondToAcpPermission(agentId, requestId, optionId, cancelled = false) {
3525
- const agent = this.agents.get(agentId);
3526
- if (!agent) throw new Error('Agent not found');
3527
- if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
4810
+ this.assertAgentOperationAdmission();
4811
+ this.requireLiveAcpAgent(agentId);
3528
4812
  return this.acpRuntime.respondPermission(agentId, requestId, optionId, cancelled);
3529
4813
  }
3530
4814
 
3531
4815
  respondToAcpElicitation(agentId, requestId, action, content) {
4816
+ this.assertAgentOperationAdmission();
4817
+ this.requireLiveAcpAgent(agentId);
4818
+ return this.acpRuntime.respondElicitation(agentId, requestId, action, content);
4819
+ }
4820
+
4821
+ requireLiveAcpAgent(agentId) {
3532
4822
  const agent = this.agents.get(agentId);
3533
4823
  if (!agent) throw new Error('Agent not found');
3534
4824
  if (!isAcpAgent(agent)) throw new Error('Agent is not using the ACP runtime');
3535
- return this.acpRuntime.respondElicitation(agentId, requestId, action, content);
4825
+ if (typeof this.acpRuntime.hasBinding === 'function' && !this.acpRuntime.hasBinding(agentId)) {
4826
+ const runtime = runtimeBindingOf(agent, 'acp');
4827
+ const message = runtime?.error || (
4828
+ runtime?.state === 'connecting'
4829
+ ? 'ACP Agent is still connecting'
4830
+ : 'ACP Agent runtime is unavailable'
4831
+ );
4832
+ const error = new Error(message);
4833
+ error.code = 'ACP_RUNTIME_UNAVAILABLE';
4834
+ throw error;
4835
+ }
4836
+ return agent;
3536
4837
  }
3537
4838
 
3538
4839
  authenticateAcpAgent(agentId, methodId) {
4840
+ this.assertAgentOperationAdmission();
3539
4841
  this.getAcpSession(agentId);
3540
4842
  return this.acpRuntime.authenticate(agentId, methodId);
3541
4843
  }
3542
4844
 
3543
4845
  logoutAcpAgent(agentId) {
4846
+ this.assertAgentOperationAdmission();
3544
4847
  this.getAcpSession(agentId);
3545
4848
  return this.acpRuntime.logout(agentId);
3546
4849
  }
3547
4850
 
3548
4851
  forkAcpSession(agentId, options = {}) {
4852
+ this.assertAgentOperationAdmission();
3549
4853
  this.getAcpSession(agentId);
3550
4854
  return this.acpRuntime.forkSession(agentId, options);
3551
4855
  }
3552
4856
 
3553
4857
  deleteAcpSession(agentId, sessionId) {
4858
+ this.assertAgentOperationAdmission();
3554
4859
  this.getAcpSession(agentId);
3555
4860
  return this.acpRuntime.deleteSession(agentId, sessionId);
3556
4861
  }
3557
4862
 
3558
4863
  closeAcpSession(agentId) {
4864
+ this.assertAgentOperationAdmission();
3559
4865
  this.getAcpSession(agentId);
3560
4866
  return this.acpRuntime.closeSession(agentId);
3561
4867
  }
3562
4868
 
3563
4869
  setAcpSessionMode(agentId, modeId) {
4870
+ this.assertAgentOperationAdmission();
3564
4871
  this.getAcpSession(agentId);
3565
4872
  return this.acpRuntime.setSessionMode(agentId, modeId);
3566
4873
  }
3567
4874
 
3568
4875
  setAcpSessionConfigOption(agentId, configId, value) {
4876
+ this.assertAgentOperationAdmission();
3569
4877
  this.getAcpSession(agentId);
3570
4878
  return this.acpRuntime.setSessionConfigOption(agentId, configId, value);
3571
4879
  }
3572
4880
 
3573
4881
  setAcpSessionConfigOptions(agentId, changes) {
4882
+ this.assertAgentOperationAdmission();
3574
4883
  this.getAcpSession(agentId);
3575
4884
  return this.acpRuntime.setSessionConfigOptions(agentId, changes);
3576
4885
  }
@@ -3623,6 +4932,7 @@ class AgentManager extends EventEmitter {
3623
4932
  }
3624
4933
 
3625
4934
  async interruptAgent(agentId, options = {}) {
4935
+ this.assertAgentOperationAdmission();
3626
4936
  const agent = this.agents.get(agentId);
3627
4937
  if (!agent) return;
3628
4938
  try {
@@ -3671,6 +4981,7 @@ class AgentManager extends EventEmitter {
3671
4981
  }
3672
4982
 
3673
4983
  requestAgentSessionResize(agentId, cols, rows) {
4984
+ if (this.disposing) return false;
3674
4985
  this.pendingResizeByAgent.set(agentId, { cols, rows });
3675
4986
  if (this.resizeDrains.has(agentId)) return true;
3676
4987
 
@@ -3757,49 +5068,160 @@ class AgentManager extends EventEmitter {
3757
5068
  }
3758
5069
 
3759
5070
  renameAgent(agentId, title) {
5071
+ if (this.disposing) {
5072
+ return { error: 'Farming is shutting down; Agent updates are not accepted' };
5073
+ }
5074
+ const lifecycleOperation = this.agentLifecycleOperations.get(agentId);
5075
+ if (lifecycleOperation) {
5076
+ return { error: `Agent lifecycle change already in progress: ${lifecycleOperation.label}` };
5077
+ }
3760
5078
  const agent = this.agents.get(agentId);
3761
5079
  if (!agent) {
3762
5080
  return { error: 'Agent not found' };
3763
5081
  }
3764
5082
 
3765
5083
  const customTitle = String(title || '').trim().slice(0, 80);
5084
+ const admission = this.beginPersistentAgentUpdate(
5085
+ agent,
5086
+ `rename:${customTitle}`,
5087
+ { customTitle },
5088
+ );
5089
+ if (admission.error) return { error: `Failed to rename Agent: ${admission.error}` };
5090
+ if (admission.deduplicated) {
5091
+ return { agentId, customTitle, operationId: admission.operation.id, deduplicated: true };
5092
+ }
5093
+ const staged = {
5094
+ ...agent,
5095
+ customTitle,
5096
+ lifecycleJournal: lifecycleJournal(agent),
5097
+ };
5098
+ transitionLifecycleOperation(staged, admission.operation.id, 'succeeded');
5099
+ try {
5100
+ this.ensurePersistentAgentSession(staged, { customTitle });
5101
+ } catch (error) {
5102
+ return {
5103
+ error: `Failed to rename Agent: ${error.message || error}`,
5104
+ operationId: admission.operation.id,
5105
+ retryable: true,
5106
+ };
5107
+ }
3766
5108
  agent.customTitle = customTitle;
3767
- this.ensurePersistentAgentSession(agent, { customTitle });
5109
+ agent.lifecycleJournal = staged.lifecycleJournal;
5110
+ setAgentRecordId(agent, staged.agentRecordId || staged.persistentSessionId || '');
3768
5111
  this.updateEngineProviderSessionMetadata(agent);
3769
5112
  this.emit('update');
3770
- return { agentId, customTitle };
5113
+ return { agentId, customTitle, operationId: admission.operation.id };
3771
5114
  }
3772
5115
 
3773
5116
  setAgentTask(agentId, task) {
5117
+ if (this.disposing) {
5118
+ return { error: 'Farming is shutting down; Agent updates are not accepted' };
5119
+ }
5120
+ const lifecycleOperation = this.agentLifecycleOperations.get(agentId);
5121
+ if (lifecycleOperation) {
5122
+ return { error: `Agent lifecycle change already in progress: ${lifecycleOperation.label}` };
5123
+ }
3774
5124
  const agent = this.agents.get(agentId);
3775
5125
  if (!agent) {
3776
5126
  return { error: 'Agent not found' };
3777
5127
  }
3778
5128
 
3779
5129
  const nextTask = String(task || '').trim().slice(0, 240);
5130
+ const admission = this.beginPersistentAgentUpdate(
5131
+ agent,
5132
+ `task:${nextTask}`,
5133
+ { task: nextTask },
5134
+ );
5135
+ if (admission.error) return { error: `Failed to update Agent task: ${admission.error}` };
5136
+ if (admission.deduplicated) {
5137
+ return { agentId, task: nextTask, operationId: admission.operation.id, deduplicated: true };
5138
+ }
5139
+ const staged = {
5140
+ ...agent,
5141
+ task: nextTask,
5142
+ lifecycleJournal: lifecycleJournal(agent),
5143
+ };
5144
+ transitionLifecycleOperation(staged, admission.operation.id, 'succeeded');
5145
+ try {
5146
+ this.ensurePersistentAgentSession(staged, { task: nextTask });
5147
+ } catch (error) {
5148
+ return {
5149
+ error: `Failed to update Agent task: ${error.message || error}`,
5150
+ operationId: admission.operation.id,
5151
+ retryable: true,
5152
+ };
5153
+ }
3780
5154
  agent.task = nextTask;
5155
+ agent.lifecycleJournal = staged.lifecycleJournal;
5156
+ setAgentRecordId(agent, staged.agentRecordId || staged.persistentSessionId || '');
3781
5157
  this.emit('update');
3782
- return { agentId, task: nextTask };
5158
+ return { agentId, task: nextTask, operationId: admission.operation.id };
3783
5159
  }
3784
5160
 
3785
5161
  updateAgentFlags(agentId, flags) {
5162
+ if (this.disposing) {
5163
+ return { error: 'Farming is shutting down; Agent updates are not accepted' };
5164
+ }
5165
+ const lifecycleOperation = this.agentLifecycleOperations.get(agentId);
5166
+ if (lifecycleOperation) {
5167
+ return { error: `Agent lifecycle change already in progress: ${lifecycleOperation.label}` };
5168
+ }
3786
5169
  const agent = this.agents.get(agentId);
3787
5170
  if (!agent) {
3788
5171
  return { error: 'Agent not found' };
3789
5172
  }
3790
-
3791
- const updates = {};
3792
- let structuralUpdateChanged = false;
3793
- let readUpdateChanged = false;
3794
- if (typeof flags.pinned === 'boolean') {
3795
- const wasPinned = agent.pinned === true;
3796
- agent.pinned = flags.pinned;
3797
- structuralUpdateChanged = structuralUpdateChanged || wasPinned !== agent.pinned;
3798
- updates.pinned = agent.pinned;
3799
- if (!wasPinned && agent.pinned) {
3800
- agent.pinnedOrder = nextPinnedOrder(Array.from(this.agents.values()));
5173
+ if (flags.archived === true) {
5174
+ if (agent.id === this.mainAgentId) {
5175
+ return { error: 'Main Agent cannot be archived' };
5176
+ }
5177
+ return { error: 'Use archiveAgent to archive live agents' };
5178
+ }
5179
+
5180
+ const persistedFlags = {};
5181
+ [
5182
+ 'pinned',
5183
+ 'unread',
5184
+ 'archived',
5185
+ 'readAttentionSeq',
5186
+ 'readOutputEpoch',
5187
+ 'readOutputSeq',
5188
+ ].forEach(field => {
5189
+ if (Object.prototype.hasOwnProperty.call(flags, field)) persistedFlags[field] = flags[field];
5190
+ });
5191
+ const requestKey = `flags:${JSON.stringify(persistedFlags)}`;
5192
+ const activeUpdate = activeLifecycleOperation(agent);
5193
+ const needsLifecycleJournal = typeof persistedFlags.pinned === 'boolean'
5194
+ || persistedFlags.archived === false
5195
+ || (
5196
+ activeUpdate?.type === 'update'
5197
+ && activeUpdate.requestKey === requestKey
5198
+ );
5199
+ const admission = needsLifecycleJournal
5200
+ ? this.beginPersistentAgentOperation(
5201
+ agent,
5202
+ 'update',
5203
+ requestKey,
5204
+ persistedFlags,
5205
+ )
5206
+ : null;
5207
+ if (admission?.error) return { error: `Failed to update Agent: ${admission.error}` };
5208
+
5209
+ const staged = {
5210
+ ...agent,
5211
+ lifecycleJournal: lifecycleJournal(agent),
5212
+ };
5213
+ const updates = {};
5214
+ let structuralUpdateChanged = false;
5215
+ let readUpdateChanged = false;
5216
+ if (typeof flags.pinned === 'boolean') {
5217
+ const wasPinned = staged.pinned === true;
5218
+ staged.pinned = flags.pinned;
5219
+ structuralUpdateChanged = structuralUpdateChanged || wasPinned !== staged.pinned;
5220
+ updates.pinned = staged.pinned;
5221
+ if (!wasPinned && staged.pinned) {
5222
+ staged.pinnedOrder = nextPinnedOrder(Array.from(this.agents.values()));
3801
5223
  }
3802
- updates.pinnedOrder = finiteOrder(agent.pinnedOrder);
5224
+ updates.pinnedOrder = finiteOrder(staged.pinnedOrder);
3803
5225
  }
3804
5226
 
3805
5227
  if (
@@ -3807,53 +5229,97 @@ class AgentManager extends EventEmitter {
3807
5229
  && typeof flags.readOutputSeq === 'number'
3808
5230
  && Number.isFinite(flags.readOutputSeq)
3809
5231
  ) {
3810
- const result = this.markAgentReadOutputCut(agentId, flags.readOutputEpoch, flags.readOutputSeq);
3811
- if (result.error) return result;
3812
- updates.readOutputEpoch = result.readOutputEpoch;
3813
- updates.readOutputSeq = result.readOutputSeq;
3814
- readUpdateChanged = readUpdateChanged || result.changed;
5232
+ const currentRuntimeEpoch = typeof staged.runtimeEpoch === 'string' ? staged.runtimeEpoch : '';
5233
+ const currentOutputSeq = finiteNumberOrNull(staged.lastOutputSeq);
5234
+ if (
5235
+ currentRuntimeEpoch
5236
+ && flags.readOutputEpoch === currentRuntimeEpoch
5237
+ && currentOutputSeq !== null
5238
+ ) {
5239
+ const nextOutputSeq = Math.min(currentOutputSeq, Math.max(0, Math.floor(flags.readOutputSeq)));
5240
+ const previousOutputSeq = staged.readOutputEpoch === currentRuntimeEpoch
5241
+ ? finiteNumberOrNull(staged.readOutputSeq)
5242
+ : null;
5243
+ const readOutputSeq = previousOutputSeq === null
5244
+ ? nextOutputSeq
5245
+ : Math.max(previousOutputSeq, nextOutputSeq);
5246
+ readUpdateChanged = readUpdateChanged
5247
+ || staged.readOutputEpoch !== currentRuntimeEpoch
5248
+ || previousOutputSeq !== readOutputSeq;
5249
+ staged.readOutputEpoch = currentRuntimeEpoch;
5250
+ staged.readOutputSeq = readOutputSeq;
5251
+ }
5252
+ updates.readOutputEpoch = typeof staged.readOutputEpoch === 'string' ? staged.readOutputEpoch : '';
5253
+ updates.readOutputSeq = finiteNumberOrNull(staged.readOutputSeq);
3815
5254
  }
3816
5255
 
3817
5256
  if (typeof flags.unread === 'boolean') {
3818
- const result = flags.unread
3819
- ? this.markAgentUnreadCursor(agentId)
3820
- : this.markAgentReadCursor(agentId, undefined, { emitUpdate: false });
3821
- if (result.error) return result;
3822
- updates.unread = result.unread;
3823
- updates.attentionSeq = result.attentionSeq;
3824
- updates.readAttentionSeq = result.readAttentionSeq;
3825
- readUpdateChanged = readUpdateChanged || result.changed === true;
5257
+ const previousReadSeq = finiteNonNegativeInteger(staged.readAttentionSeq);
5258
+ const previousUnread = staged.unread === true;
5259
+ if (flags.unread) {
5260
+ if (finiteNonNegativeInteger(staged.attentionSeq) === 0) {
5261
+ const now = Date.now();
5262
+ staged.attentionSeq = 1;
5263
+ staged.attentionUpdatedAt = now;
5264
+ staged.attentionReason = 'manual-unread';
5265
+ staged.attentionOutputEpoch = typeof staged.runtimeEpoch === 'string' ? staged.runtimeEpoch : '';
5266
+ staged.attentionOutputSeq = Number.isFinite(staged.lastOutputSeq) ? staged.lastOutputSeq : null;
5267
+ staged.attentionAutoReadNext = false;
5268
+ }
5269
+ staged.readAttentionSeq = Math.max(0, finiteNonNegativeInteger(staged.attentionSeq) - 1);
5270
+ } else {
5271
+ staged.readAttentionSeq = finiteNonNegativeInteger(staged.attentionSeq);
5272
+ }
5273
+ staged.readAttentionAt = Date.now();
5274
+ staged.unread = agentAttentionUnread(staged);
5275
+ readUpdateChanged = readUpdateChanged
5276
+ || previousReadSeq !== staged.readAttentionSeq
5277
+ || previousUnread !== staged.unread;
5278
+ updates.unread = staged.unread;
5279
+ updates.attentionSeq = finiteNonNegativeInteger(staged.attentionSeq);
5280
+ updates.readAttentionSeq = finiteNonNegativeInteger(staged.readAttentionSeq);
3826
5281
  }
3827
5282
 
3828
5283
  if (typeof flags.readAttentionSeq === 'number' && Number.isFinite(flags.readAttentionSeq)) {
3829
- const result = this.markAgentReadCursor(agentId, flags.readAttentionSeq, { emitUpdate: false });
3830
- if (result.error) return result;
3831
- updates.unread = result.unread;
3832
- updates.attentionSeq = result.attentionSeq;
3833
- updates.readAttentionSeq = result.readAttentionSeq;
3834
- readUpdateChanged = readUpdateChanged || result.changed === true;
3835
- }
3836
-
3837
- if (flags.archived === true) {
3838
- if (agent.id === this.mainAgentId) {
3839
- return { error: 'Main Agent cannot be archived' };
3840
- }
3841
- return { error: 'Use archiveAgent to archive live agents' };
5284
+ const previousReadSeq = finiteNonNegativeInteger(staged.readAttentionSeq);
5285
+ const previousUnread = staged.unread === true;
5286
+ const attentionSeq = finiteNonNegativeInteger(staged.attentionSeq);
5287
+ staged.readAttentionSeq = Math.min(
5288
+ attentionSeq,
5289
+ Math.max(previousReadSeq, finiteNonNegativeInteger(flags.readAttentionSeq)),
5290
+ );
5291
+ staged.readAttentionAt = Date.now();
5292
+ staged.unread = agentAttentionUnread(staged);
5293
+ readUpdateChanged = readUpdateChanged
5294
+ || previousReadSeq !== staged.readAttentionSeq
5295
+ || previousUnread !== staged.unread;
5296
+ updates.unread = staged.unread;
5297
+ updates.attentionSeq = attentionSeq;
5298
+ updates.readAttentionSeq = staged.readAttentionSeq;
3842
5299
  }
3843
5300
 
3844
5301
  if (flags.archived === false) {
3845
- structuralUpdateChanged = structuralUpdateChanged || agent.archived === true || agent.archivedAt !== null;
3846
- agent.archived = false;
3847
- agent.archivedAt = null;
3848
- updates.archived = agent.archived;
3849
- updates.archivedAt = agent.archivedAt;
5302
+ structuralUpdateChanged = structuralUpdateChanged || staged.archived === true || staged.archivedAt !== null;
5303
+ staged.archived = false;
5304
+ staged.archivedAt = null;
5305
+ updates.archived = staged.archived;
5306
+ updates.archivedAt = staged.archivedAt;
3850
5307
  }
3851
5308
 
3852
- if (
3853
- typeof flags.pinned === 'boolean'
3854
- || (typeof flags.readOutputEpoch === 'string' && typeof flags.readOutputSeq === 'number')
3855
- ) {
3856
- this.ensurePersistentAgentSession(agent);
5309
+ if (admission) {
5310
+ transitionLifecycleOperation(staged, admission.operation.id, 'succeeded');
5311
+ }
5312
+ try {
5313
+ this.ensurePersistentAgentSession(staged);
5314
+ } catch (error) {
5315
+ return {
5316
+ error: `Failed to update Agent: ${error.message || error}`,
5317
+ ...(admission ? { operationId: admission.operation.id } : {}),
5318
+ retryable: true,
5319
+ };
5320
+ }
5321
+ Object.assign(agent, staged);
5322
+ if (structuralUpdateChanged || readUpdateChanged) {
3857
5323
  this.updateEngineProviderSessionMetadata(agent);
3858
5324
  }
3859
5325
  if (structuralUpdateChanged) {
@@ -3873,6 +5339,7 @@ class AgentManager extends EventEmitter {
3873
5339
  ...updates,
3874
5340
  changed: structuralUpdateChanged || readUpdateChanged,
3875
5341
  requiresState: structuralUpdateChanged,
5342
+ ...(admission ? { operationId: admission.operation.id } : {}),
3876
5343
  };
3877
5344
  }
3878
5345
 
@@ -3884,18 +5351,7 @@ class AgentManager extends EventEmitter {
3884
5351
  String(afterAgentId || ''),
3885
5352
  );
3886
5353
  if (result.error) return result;
3887
-
3888
- const updates = [];
3889
- for (const [updatedAgentId, projectOrder] of result.updates) {
3890
- const agent = this.agents.get(updatedAgentId);
3891
- if (!agent) continue;
3892
- agent.projectOrder = projectOrder;
3893
- this.ensurePersistentAgentSession(agent);
3894
- this.updateEngineProviderSessionMetadata(agent);
3895
- updates.push({ agentId: updatedAgentId, projectOrder });
3896
- }
3897
- this.emit('update');
3898
- return { agentId, projectOrder: finiteOrder(this.agents.get(agentId)?.projectOrder), updates };
5354
+ return this.commitAgentOrderUpdates(agentId, result.updates, 'projectOrder');
3899
5355
  }
3900
5356
 
3901
5357
  reorderPinnedAgent(agentId, { beforeAgentId = '', afterAgentId = '' } = {}) {
@@ -3906,21 +5362,76 @@ class AgentManager extends EventEmitter {
3906
5362
  String(afterAgentId || ''),
3907
5363
  );
3908
5364
  if (result.error) return result;
5365
+ return this.commitAgentOrderUpdates(agentId, result.updates, 'pinnedOrder');
5366
+ }
3909
5367
 
3910
- const updates = [];
3911
- for (const [updatedAgentId, pinnedOrder] of result.updates) {
3912
- const agent = this.agents.get(updatedAgentId);
3913
- if (!agent) continue;
3914
- agent.pinnedOrder = pinnedOrder;
3915
- this.ensurePersistentAgentSession(agent);
3916
- this.updateEngineProviderSessionMetadata(agent);
3917
- updates.push({ agentId: updatedAgentId, pinnedOrder });
5368
+ commitAgentOrderUpdates(agentId, orderUpdates, field) {
5369
+ const staged = [...orderUpdates].map(([updatedAgentId, order]) => ({
5370
+ agent: this.agents.get(updatedAgentId),
5371
+ order,
5372
+ })).filter(item => item.agent);
5373
+ const conflicting = staged.find(item => this.agentLifecycleOperations.has(item.agent.id));
5374
+ if (conflicting) {
5375
+ const lifecycleOperation = this.agentLifecycleOperations.get(conflicting.agent.id);
5376
+ return { error: `Agent lifecycle change already in progress: ${lifecycleOperation.label}` };
5377
+ }
5378
+
5379
+ const attempted = [];
5380
+ try {
5381
+ for (const item of staged) {
5382
+ item.stagedAgent = { ...item.agent, [field]: item.order };
5383
+ attempted.push(item);
5384
+ this.ensurePersistentAgentSession(item.stagedAgent);
5385
+ }
5386
+ } catch (error) {
5387
+ let rollbackError = null;
5388
+ for (const item of attempted.reverse()) {
5389
+ try {
5390
+ const agentRecordId = item.stagedAgent.agentRecordId
5391
+ || item.stagedAgent.persistentSessionId
5392
+ || item.agent.agentRecordId
5393
+ || item.agent.persistentSessionId;
5394
+ this.ensurePersistentAgentSession({
5395
+ ...item.agent,
5396
+ agentRecordId,
5397
+ persistentSessionId: agentRecordId,
5398
+ });
5399
+ } catch (restoreError) {
5400
+ rollbackError = restoreError;
5401
+ }
5402
+ }
5403
+ return {
5404
+ error: `Failed to reorder Agents: ${error.message || error}${
5405
+ rollbackError ? `; storage rollback failed: ${rollbackError.message || rollbackError}` : ''
5406
+ }`,
5407
+ };
3918
5408
  }
5409
+
5410
+ const updates = staged.map(item => {
5411
+ item.agent[field] = item.order;
5412
+ setAgentRecordId(
5413
+ item.agent,
5414
+ item.stagedAgent.agentRecordId || item.stagedAgent.persistentSessionId || '',
5415
+ );
5416
+ this.updateEngineProviderSessionMetadata(item.agent);
5417
+ return { agentId: item.agent.id, [field]: item.order };
5418
+ });
3919
5419
  this.emit('update');
3920
- return { agentId, pinnedOrder: finiteOrder(this.agents.get(agentId)?.pinnedOrder), updates };
5420
+ return {
5421
+ agentId,
5422
+ [field]: finiteOrder(this.agents.get(agentId)?.[field]),
5423
+ updates,
5424
+ };
3921
5425
  }
3922
5426
 
3923
5427
  reorderAgent(agentId, neighbors = {}) {
5428
+ if (this.disposing) {
5429
+ return { error: 'Farming is shutting down; Agent updates are not accepted' };
5430
+ }
5431
+ const lifecycleOperation = this.agentLifecycleOperations.get(agentId);
5432
+ if (lifecycleOperation) {
5433
+ return { error: `Agent lifecycle change already in progress: ${lifecycleOperation.label}` };
5434
+ }
3924
5435
  const agent = this.agents.get(agentId);
3925
5436
  if (!agent) return { error: 'Agent not found' };
3926
5437
  return agent.pinned === true
@@ -3934,50 +5445,117 @@ class AgentManager extends EventEmitter {
3934
5445
  return this.restartAgentWithPermissionMode(agentId, mode);
3935
5446
  }
3936
5447
 
3937
- async restartAgentWithPermissionMode(agentId, mode) {
3938
- const inFlight = this.permissionRestartInFlight.get(agentId);
5448
+ runAgentLifecycleOperation(agentId, key, kind, label, operation, sameKindConflictError = '') {
5449
+ const inFlight = this.agentLifecycleOperations.get(agentId);
3939
5450
  if (inFlight) {
3940
- return inFlight.mode === mode
3941
- ? inFlight.promise
3942
- : { error: 'Permission change already in progress' };
3943
- }
3944
-
3945
- const restart = this.performAgentPermissionRestart(agentId, mode);
3946
- const entry = { mode, promise: restart };
3947
- this.permissionRestartInFlight.set(agentId, entry);
3948
- try {
3949
- return await restart;
3950
- } finally {
3951
- if (this.permissionRestartInFlight.get(agentId) === entry) {
3952
- this.permissionRestartInFlight.delete(agentId);
5451
+ if (inFlight.key === key) return inFlight.promise;
5452
+ if (sameKindConflictError && inFlight.kind === kind) {
5453
+ return Promise.resolve({ error: sameKindConflictError });
3953
5454
  }
3954
- }
5455
+ return inFlight.promise
5456
+ .catch(() => {})
5457
+ .then(() => this.runAgentLifecycleOperation(
5458
+ agentId,
5459
+ key,
5460
+ kind,
5461
+ label,
5462
+ operation,
5463
+ sameKindConflictError,
5464
+ ));
5465
+ }
5466
+ if (this.disposing) {
5467
+ return Promise.resolve({ error: 'Farming is shutting down; Agent lifecycle changes are not accepted' });
5468
+ }
5469
+
5470
+ const token = Symbol(key);
5471
+ const promise = Promise.resolve().then(() => operation(token));
5472
+ const entry = { key, kind, label, token, promise, agentIds: new Set([agentId]) };
5473
+ this.agentLifecycleOperations.set(agentId, entry);
5474
+ void promise.finally(() => {
5475
+ for (const ownedAgentId of entry.agentIds) {
5476
+ if (this.agentLifecycleOperations.get(ownedAgentId) === entry) {
5477
+ this.agentLifecycleOperations.delete(ownedAgentId);
5478
+ }
5479
+ }
5480
+ }).catch(() => {});
5481
+ return promise;
3955
5482
  }
3956
5483
 
3957
- async restartAgentRuntimeMode(agentId, mode) {
3958
- const inFlight = this.runtimeRestartInFlight.get(agentId);
3959
- if (inFlight) {
3960
- return inFlight.mode === mode
3961
- ? inFlight.promise
3962
- : { error: 'Agent runtime switch already in progress' };
5484
+ async whenAgentLifecycleIdle(agentId) {
5485
+ await this.whenRecovered();
5486
+ while (true) {
5487
+ const inFlight = this.agentLifecycleOperations.get(agentId);
5488
+ if (!inFlight) return;
5489
+ await inFlight.promise.catch(() => {});
3963
5490
  }
3964
- const restart = this.performAgentRuntimeModeRestart(agentId, mode);
3965
- const entry = { mode, promise: restart };
3966
- this.runtimeRestartInFlight.set(agentId, entry);
3967
- try {
3968
- return await restart;
3969
- } finally {
3970
- if (this.runtimeRestartInFlight.get(agentId) === entry) {
3971
- this.runtimeRestartInFlight.delete(agentId);
5491
+ }
5492
+
5493
+ beginAgentStartLifecycleOperation(agentId, startAdmissionToken) {
5494
+ if (this.agentLifecycleOperations.has(agentId)) return null;
5495
+ if (this.disposing && !this.agentStartAdmissions.has(startAdmissionToken)) return null;
5496
+ let resolveCompletion;
5497
+ const promise = new Promise(resolve => {
5498
+ resolveCompletion = resolve;
5499
+ });
5500
+ const entry = {
5501
+ key: 'start',
5502
+ kind: 'start',
5503
+ label: 'start',
5504
+ token: Symbol('start'),
5505
+ promise,
5506
+ agentIds: new Set([agentId]),
5507
+ };
5508
+ this.agentLifecycleOperations.set(agentId, entry);
5509
+ let finished = false;
5510
+ return () => {
5511
+ if (finished) return;
5512
+ finished = true;
5513
+ if (this.agentLifecycleOperations.get(agentId) === entry) {
5514
+ this.agentLifecycleOperations.delete(agentId);
3972
5515
  }
3973
- }
5516
+ resolveCompletion({ agentId, started: true });
5517
+ };
5518
+ }
5519
+
5520
+ adoptAgentLifecycleOperation(agentId, lifecycleToken) {
5521
+ if (!lifecycleToken) return false;
5522
+ const entry = [...new Set(this.agentLifecycleOperations.values())]
5523
+ .find(candidate => candidate.token === lifecycleToken);
5524
+ if (!entry) return false;
5525
+ const existing = this.agentLifecycleOperations.get(agentId);
5526
+ if (existing && existing !== entry) return false;
5527
+ entry.agentIds.add(agentId);
5528
+ this.agentLifecycleOperations.set(agentId, entry);
5529
+ return true;
5530
+ }
5531
+
5532
+ restartAgentWithPermissionMode(agentId, mode) {
5533
+ return this.runAgentLifecycleOperation(
5534
+ agentId,
5535
+ `permission-restart:${mode}`,
5536
+ 'permission-restart',
5537
+ 'permission restart',
5538
+ token => this.performAgentPermissionRestart(agentId, mode, token),
5539
+ 'Permission change already in progress',
5540
+ );
3974
5541
  }
3975
5542
 
3976
- async performAgentRuntimeModeRestart(agentId, mode) {
5543
+ restartAgentRuntimeMode(agentId, mode) {
5544
+ return this.runAgentLifecycleOperation(
5545
+ agentId,
5546
+ `runtime-switch:${mode}`,
5547
+ 'runtime-switch',
5548
+ 'runtime switch',
5549
+ token => this.performAgentRuntimeModeRestart(agentId, mode, token),
5550
+ 'Agent runtime switch already in progress',
5551
+ );
5552
+ }
5553
+
5554
+ async performAgentRuntimeModeRestart(agentId, mode, lifecycleToken) {
3977
5555
  const agent = this.agents.get(agentId);
3978
5556
  if (!agent) return { error: 'Agent not found' };
3979
5557
  const provider = agent.providerSessionProvider || '';
3980
- const requestedMode = ['terminal', 'json', 'acp', 'chat'].includes(mode) ? mode : '';
5558
+ const requestedMode = ['terminal', 'acp', 'chat'].includes(mode) ? mode : '';
3981
5559
  const nextMode = requestedMode === 'acp' && provider === 'codex' ? 'chat' : requestedMode;
3982
5560
  if (!nextMode) return { error: 'Unsupported Agent runtime mode' };
3983
5561
  const currentKind = runtimeKind(agent);
@@ -4084,6 +5662,7 @@ class AgentManager extends EventEmitter {
4084
5662
  codexApprovalMode: agent.launchPermissionMode || undefined,
4085
5663
  jsonCliEvents: preserved.jsonCliEvents,
4086
5664
  runtimeSwitchVerifiedSessionId: startsFreshChatSession ? '' : sessionId,
5665
+ lifecycleToken,
4087
5666
  ...acpSessionOptions,
4088
5667
  ...(provider === 'codex' && !startsFreshChatSession
4089
5668
  ? preserveCodexSessionProfileOptions()
@@ -4094,6 +5673,7 @@ class AgentManager extends EventEmitter {
4094
5673
  ...restartOptions,
4095
5674
  agentRuntimeMode: originalMode,
4096
5675
  acpStartFresh: false,
5676
+ ...(originalMode === 'json' ? { allowLegacyJsonRuntime: true } : {}),
4097
5677
  };
4098
5678
  const startReplacement = options => new Promise(resolve => {
4099
5679
  let settled = false;
@@ -4120,7 +5700,14 @@ class AgentManager extends EventEmitter {
4120
5700
  Object.assign(replacement, preserved);
4121
5701
  this.ensurePersistentAgentSession(replacement);
4122
5702
  };
4123
- await this.killAgent(agentId, { reason: 'runtime-switch', recordHistory: false, emitUpdate: false });
5703
+ const killResult = await this.killAgent(agentId, {
5704
+ reason: 'runtime-switch',
5705
+ recordHistory: false,
5706
+ emitUpdate: false,
5707
+ lifecycleToken,
5708
+ persistDeleteOperation: false,
5709
+ });
5710
+ if (killResult?.error) return killResult;
4124
5711
  const switched = await startReplacement(restartOptions);
4125
5712
  if (switched.restartedAgentId && !switched.error) {
4126
5713
  restorePreservedState(switched.restartedAgentId);
@@ -4133,11 +5720,21 @@ class AgentManager extends EventEmitter {
4133
5720
  };
4134
5721
  }
4135
5722
  if (switched.restartedAgentId && this.agents.has(switched.restartedAgentId)) {
4136
- await this.killAgent(switched.restartedAgentId, {
4137
- reason: 'runtime-switch-start-failed',
4138
- recordHistory: false,
4139
- emitUpdate: false,
5723
+ const cleanup = await this.killAgent(switched.restartedAgentId, {
5724
+ reason: 'runtime-switch-start-failed',
5725
+ recordHistory: false,
5726
+ emitUpdate: false,
5727
+ lifecycleToken,
4140
5728
  });
5729
+ if (cleanup?.error) {
5730
+ this.emit('update');
5731
+ return {
5732
+ agentId,
5733
+ restartedAgentId: switched.restartedAgentId,
5734
+ cleanupUncertain: true,
5735
+ error: `${switched.error || 'Failed to switch Agent runtime'} Replacement cleanup could not be verified: ${cleanup.error}`,
5736
+ };
5737
+ }
4141
5738
  }
4142
5739
 
4143
5740
  const restored = await startReplacement(originalOptions);
@@ -4153,16 +5750,24 @@ class AgentManager extends EventEmitter {
4153
5750
  warning: `${switched.error || 'Failed to switch Agent runtime'} Original runtime restored.`,
4154
5751
  };
4155
5752
  }
5753
+ let restoreCleanupError = '';
4156
5754
  if (restored.restartedAgentId && this.agents.has(restored.restartedAgentId)) {
4157
- await this.killAgent(restored.restartedAgentId, {
5755
+ const cleanup = await this.killAgent(restored.restartedAgentId, {
4158
5756
  reason: 'runtime-switch-restore-failed',
4159
5757
  recordHistory: false,
4160
5758
  emitUpdate: false,
5759
+ lifecycleToken,
4161
5760
  });
5761
+ restoreCleanupError = cleanup?.error || '';
4162
5762
  }
4163
5763
  this.emit('update');
4164
5764
  return {
4165
- error: `${switched.error || 'Failed to switch Agent runtime'} Restore also failed: ${restored.error || 'unknown error'}`,
5765
+ ...(restoreCleanupError
5766
+ ? { restartedAgentId: restored.restartedAgentId, cleanupUncertain: true }
5767
+ : {}),
5768
+ error: `${switched.error || 'Failed to switch Agent runtime'} Restore also failed: ${
5769
+ restored.error || 'unknown error'
5770
+ }${restoreCleanupError ? ` Cleanup could not be verified: ${restoreCleanupError}` : ''}`,
4166
5771
  };
4167
5772
  }
4168
5773
 
@@ -4181,7 +5786,7 @@ class AgentManager extends EventEmitter {
4181
5786
  });
4182
5787
  }
4183
5788
 
4184
- async performAgentPermissionRestart(agentId, mode) {
5789
+ async performAgentPermissionRestart(agentId, mode, lifecycleToken) {
4185
5790
  const agent = this.agents.get(agentId);
4186
5791
  if (!agent) {
4187
5792
  return { error: 'Agent not found' };
@@ -4255,6 +5860,7 @@ class AgentManager extends EventEmitter {
4255
5860
  ])),
4256
5861
  projectOrder: finiteOrder(agent.projectOrder),
4257
5862
  pinnedOrder: finiteOrder(agent.pinnedOrder),
5863
+ lifecycleToken,
4258
5864
  ...acpSessionOptions,
4259
5865
  ...(provider === 'codex' ? { codexApprovalMode: nextMode } : { claudePermissionMode: nextMode }),
4260
5866
  ...(provider === 'codex' && hasResumableSession
@@ -4271,16 +5877,14 @@ class AgentManager extends EventEmitter {
4271
5877
  readAttentionSeq: finiteNonNegativeInteger(agent.readAttentionSeq),
4272
5878
  };
4273
5879
 
4274
- this.permissionRestartSuppressedAgentIds.add(agentId);
4275
- try {
4276
- await this.killAgent(agentId, {
4277
- reason: 'permission-restart',
4278
- recordHistory: false,
4279
- emitUpdate: false,
4280
- });
4281
- } finally {
4282
- this.permissionRestartSuppressedAgentIds.delete(agentId);
4283
- }
5880
+ const killResult = await this.killAgent(agentId, {
5881
+ reason: 'permission-restart',
5882
+ recordHistory: false,
5883
+ emitUpdate: false,
5884
+ lifecycleToken,
5885
+ persistDeleteOperation: false,
5886
+ });
5887
+ if (killResult?.error) return killResult;
4284
5888
 
4285
5889
  return new Promise((resolve) => {
4286
5890
  const startResult = this.startAgent(command, effectiveAgentWorkspaceRoot(agent) || null, (restartedAgentId, error) => {
@@ -4543,7 +6147,43 @@ class AgentManager extends EventEmitter {
4543
6147
  return removedKeys;
4544
6148
  }
4545
6149
 
4546
- async deleteForkWorktreeProject(workspace, options = {}) {
6150
+ deleteForkWorktreeProject(workspace, options = {}) {
6151
+ const workspaceKey = canonicalWorkspacePath(this.expandWorkspacePath(workspace));
6152
+ if (!workspaceKey) return this.deleteForkWorktreeProjectAdmitted(workspace, options);
6153
+ const inFlight = this.projectWorkspaceDeleteAdmissions.get(workspaceKey);
6154
+ if (inFlight) return inFlight;
6155
+
6156
+ const operation = this.deleteForkWorktreeProjectAdmitted(workspace, options);
6157
+ this.projectWorkspaceDeleteAdmissions.set(workspaceKey, operation);
6158
+ void operation.finally(() => {
6159
+ if (this.projectWorkspaceDeleteAdmissions.get(workspaceKey) === operation) {
6160
+ this.projectWorkspaceDeleteAdmissions.delete(workspaceKey);
6161
+ }
6162
+ }).catch(() => {});
6163
+ return operation;
6164
+ }
6165
+
6166
+ async deleteForkWorktreeProjectAdmitted(workspace, options = {}) {
6167
+ await this.whenRecovered();
6168
+ const workspaceKey = canonicalWorkspacePath(this.expandWorkspacePath(workspace));
6169
+ const relatedStarts = workspaceKey
6170
+ ? [...this.agentStartAdmissions.values()].filter(admission => (
6171
+ !admission.workspaceKey
6172
+ || isSameOrDescendantPath(workspaceKey, admission.workspaceKey)
6173
+ ))
6174
+ : [];
6175
+ try {
6176
+ await withBoundedWait(
6177
+ Promise.allSettled(relatedStarts.map(admission => admission.promise)),
6178
+ WORKTREE_DELETE_START_DRAIN_TIMEOUT_MS,
6179
+ `Worktree ${workspaceKey} Agent start drain`,
6180
+ );
6181
+ } catch (error) {
6182
+ return {
6183
+ workspace: workspaceKey,
6184
+ error: error.message || String(error),
6185
+ };
6186
+ }
4547
6187
  const inspected = await this.inspectForkWorktreeProject(workspace);
4548
6188
  if (inspected.error) return inspected;
4549
6189
  if (inspected.requiresForce && options.force !== true) {
@@ -4553,11 +6193,27 @@ class AgentManager extends EventEmitter {
4553
6193
  };
4554
6194
  }
4555
6195
 
6196
+ const archivedAgentIds = [];
6197
+ const removedMainPageSessionKeys = [];
4556
6198
  const projectAgents = this.agentsForProjectWorkspace(inspected.workspace);
6199
+ for (const agent of projectAgents) {
6200
+ const result = await this.archiveAgent(agent.id, { requireEngineExit: true });
6201
+ if (result && !result.error) {
6202
+ archivedAgentIds.push(agent.id);
6203
+ removedMainPageSessionKeys.push(...(result.removedMainPageSessionKeys || []));
6204
+ } else {
6205
+ return {
6206
+ ...inspected,
6207
+ error: `Agent ${agent.id} could not be stopped before deleting the worktree: ${result?.error || 'Failed to archive Agent'}`,
6208
+ archivedAgentIds,
6209
+ removedMainPageSessionKeys: Array.from(new Set(removedMainPageSessionKeys)),
6210
+ };
6211
+ }
6212
+ }
6213
+
4557
6214
  const args = ['-C', inspected.workspace, 'worktree', 'remove'];
4558
6215
  if (options.force === true) args.push('--force');
4559
6216
  args.push(inspected.workspace);
4560
-
4561
6217
  try {
4562
6218
  await execFileAsync('git', args, {
4563
6219
  timeout: 60000,
@@ -4568,34 +6224,25 @@ class AgentManager extends EventEmitter {
4568
6224
  return {
4569
6225
  ...inspected,
4570
6226
  error: message || 'Failed to delete git worktree',
6227
+ archivedAgentIds,
6228
+ removedMainPageSessionKeys: Array.from(new Set(removedMainPageSessionKeys)),
4571
6229
  };
4572
6230
  }
4573
6231
 
4574
- const archivedAgentIds = [];
4575
- const removedMainPageSessionKeys = [];
4576
- const cleanupErrors = [];
4577
- for (const agent of projectAgents) {
4578
- const result = await this.archiveAgent(agent.id, { requireEngineExit: true });
4579
- if (result && !result.error) {
4580
- archivedAgentIds.push(agent.id);
4581
- removedMainPageSessionKeys.push(...(result.removedMainPageSessionKeys || []));
4582
- } else {
4583
- cleanupErrors.push(`${agent.id}: ${result?.error || 'Failed to archive Agent'}`);
4584
- }
4585
- }
4586
6232
  return {
4587
6233
  workspace: inspected.workspace,
4588
6234
  deleted: true,
4589
6235
  forced: options.force === true,
4590
6236
  archivedAgentIds,
4591
6237
  removedMainPageSessionKeys: Array.from(new Set(removedMainPageSessionKeys)),
4592
- ...(cleanupErrors.length > 0 ? {
4593
- cleanupError: cleanupErrors.join('; '),
4594
- } : {}),
4595
6238
  };
4596
6239
  }
4597
6240
 
4598
- async forkAgent(agentId, mode = 'same-worktree') {
6241
+ async forkAgent(agentId, mode = 'same-worktree', options = {}) {
6242
+ await this.whenRecovered();
6243
+ if (this.disposing) {
6244
+ return { error: 'Farming is shutting down; Agent lifecycle changes are not accepted' };
6245
+ }
4599
6246
  const agent = this.agents.get(agentId);
4600
6247
  if (!agent) {
4601
6248
  return { error: 'Agent not found' };
@@ -4603,6 +6250,22 @@ class AgentManager extends EventEmitter {
4603
6250
  if (!['same-worktree', 'new-worktree'].includes(mode)) {
4604
6251
  return { error: 'Unsupported fork mode' };
4605
6252
  }
6253
+ if (options.targetRuntime === 'chat') {
6254
+ if (mode !== 'same-worktree') {
6255
+ return { error: 'Conversation Fork supports only the same worktree' };
6256
+ }
6257
+ const expectedRevision = Number(options.expectedRevision);
6258
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
6259
+ return { error: 'Conversation Fork requires an exact transcript revision' };
6260
+ }
6261
+ return this.runAgentLifecycleOperation(
6262
+ agentId,
6263
+ `conversation-fork:${expectedRevision}`,
6264
+ 'conversation-fork',
6265
+ 'conversation fork',
6266
+ lifecycleToken => this.performAcpConversationFork(agentId, expectedRevision, lifecycleToken),
6267
+ );
6268
+ }
4606
6269
  if (agent.providerSessionProvider === 'codex' && agent.providerSessionTemporary === true) {
4607
6270
  await this.providerSessionService.resolveTemporaryCodex(agentId, { force: true });
4608
6271
  if (agent.providerSessionTemporary === true) {
@@ -4668,53 +6331,308 @@ class AgentManager extends EventEmitter {
4668
6331
  });
4669
6332
  }
4670
6333
 
4671
- async ensureCodexSessionAvailableForFork(agent, resumedSession, sourceWorkspace) {
4672
- const providerHomeId = agent.providerHomeId || resumedSession.providerHomeId || 'default';
4673
- const providerHomePath = agent.providerHomePath || '';
4674
- const sessionId = resumedSession.sessionId;
4675
- const unarchiveKey = `${providerHomePath || providerHomeId}:${sessionId}`;
4676
- const inFlight = this.codexSessionUnarchiveInFlight.get(unarchiveKey);
4677
- if (inFlight) return inFlight;
6334
+ async performAcpConversationFork(agentId, expectedRevision, lifecycleToken) {
6335
+ const agent = this.agents.get(agentId);
6336
+ if (!agent) return { error: 'Agent not found' };
6337
+ if (runtimeKind(agent) !== 'acp') {
6338
+ return { error: 'Conversation Fork requires an ACP Chat Agent' };
6339
+ }
6340
+ const provider = String(agent.providerSessionProvider || '').trim();
6341
+ const sourceSessionId = String(agent.providerSessionId || '').trim();
6342
+ if (!provider || !isSafeProviderSessionId(sourceSessionId) || agent.providerSessionTemporary === true) {
6343
+ return { error: 'Conversation Fork requires a stable ACP provider session' };
6344
+ }
6345
+
6346
+ const workspace = effectiveAgentWorkspaceRoot(agent);
6347
+ let acpSessionOptions = {};
6348
+ try {
6349
+ acpSessionOptions = this.acpRuntime.getSessionRequestOptions(agentId);
6350
+ } catch (error) {
6351
+ return { error: error.message || 'Failed to read ACP session options' };
6352
+ }
6353
+
6354
+ if (providerAcpForkMode(provider) === 'target-process') {
6355
+ return this.performTargetProcessAcpConversationFork({
6356
+ agent,
6357
+ provider,
6358
+ sourceSessionId,
6359
+ workspace,
6360
+ expectedRevision,
6361
+ lifecycleToken,
6362
+ acpSessionOptions,
6363
+ });
6364
+ }
6365
+
6366
+ let forkedSessionId = '';
6367
+ try {
6368
+ const forked = await this.acpRuntime.forkSession(agentId, {
6369
+ ...acpSessionOptions,
6370
+ cwd: workspace,
6371
+ expectedRevision,
6372
+ requireLoad: true,
6373
+ });
6374
+ forkedSessionId = String(forked?.sessionId || '').trim();
6375
+ } catch (error) {
6376
+ return { error: error.message || 'Failed to fork ACP conversation' };
6377
+ }
6378
+ if (!isSafeProviderSessionId(forkedSessionId) || forkedSessionId === sourceSessionId) {
6379
+ return { error: 'ACP Conversation Fork did not return a distinct resumable session' };
6380
+ }
6381
+
6382
+ const command = buildAgentSessionResumeCommand(provider, forkedSessionId, {
6383
+ cwd: workspace,
6384
+ providerHomePath: agent.providerHomePath || '',
6385
+ });
6386
+ if (!command) {
6387
+ return { error: 'Failed to build provider resume command for the forked ACP session' };
6388
+ }
6389
+
6390
+ return new Promise(resolve => {
6391
+ let settled = false;
6392
+ const finish = async (forkedAgentId, error) => {
6393
+ if (settled) return;
6394
+ settled = true;
6395
+ if (error || !forkedAgentId) {
6396
+ let rollbackError = '';
6397
+ if (!forkedAgentId) {
6398
+ try {
6399
+ await this.acpRuntime.deleteSession(agentId, forkedSessionId);
6400
+ } catch (cleanupError) {
6401
+ rollbackError = cleanupError.message || String(cleanupError);
6402
+ }
6403
+ }
6404
+ resolve({
6405
+ error: [
6406
+ error || 'Failed to start forked ACP Chat Agent',
6407
+ rollbackError ? `forked session cleanup failed: ${rollbackError}` : '',
6408
+ ].filter(Boolean).join('; '),
6409
+ ...(forkedAgentId ? { retainedAgentId: forkedAgentId } : {}),
6410
+ });
6411
+ return;
6412
+ }
6413
+ resolve({
6414
+ agentId: forkedAgentId,
6415
+ workspace,
6416
+ mode: 'same-worktree',
6417
+ targetRuntime: 'chat',
6418
+ providerSessionId: forkedSessionId,
6419
+ });
6420
+ };
4678
6421
 
4679
- const unarchivePromise = (async () => {
4680
- let session;
4681
6422
  try {
4682
- session = await findAgentSession('codex', sessionId, {
4683
- limit: 1000,
4684
- providerLimit: 1000,
4685
- scanLimit: 5000,
4686
- providerHomeId,
4687
- providerHomes: providerHomePath
4688
- ? { codex: [{ id: providerHomeId, path: providerHomePath }] }
4689
- : undefined,
6423
+ const started = this.startAgent(command, workspace, finish, {
6424
+ wantsMain: false,
6425
+ parentAgentId: agent.id,
6426
+ task: agent.task ? `Fork: ${agent.task}` : `Fork of ${agent.command}`,
6427
+ workflowTemplate: agent.workflowTemplate || '',
6428
+ source: 'ui-fork-acp-chat',
6429
+ providerHomeId: agent.providerHomeId || 'default',
6430
+ providerHomePath: agent.providerHomePath || '',
6431
+ providerSessionTitle: agent.providerSessionTitle || '',
6432
+ requiredCliVersion: provider === 'codex' ? (agent.requiredCliVersion || '') : '',
6433
+ projectWorkspace: workspace,
6434
+ agentRuntimeMode: 'chat',
6435
+ acpHistoryMode: 'load',
6436
+ runtimeSwitchVerifiedSessionId: forkedSessionId,
6437
+ forkedFromProviderSessionId: sourceSessionId,
6438
+ lifecycleToken,
6439
+ ...acpSessionOptions,
6440
+ ...(provider === 'codex'
6441
+ ? {
6442
+ codexApprovalMode: agent.launchPermissionMode || undefined,
6443
+ ...preserveCodexSessionProfileOptions(),
6444
+ }
6445
+ : {}),
6446
+ ...(provider === 'claude'
6447
+ ? { claudePermissionMode: agent.launchPermissionMode || undefined }
6448
+ : {}),
4690
6449
  });
4691
- } catch (error) {
4692
- return {
4693
- error: `Failed to inspect Codex session before forking: ${error && (error.message || error)}`,
4694
- };
6450
+ Promise.resolve(started).then(startedAgentId => {
6451
+ if (!startedAgentId) void finish(null, 'Failed to start forked ACP Chat Agent');
6452
+ }).catch(startError => {
6453
+ void finish(null, startError?.message || 'Failed to start forked ACP Chat Agent');
6454
+ });
6455
+ } catch (startError) {
6456
+ void finish(null, startError?.message || 'Failed to start forked ACP Chat Agent');
4695
6457
  }
4696
- if (!session || session.archived !== true) return null;
6458
+ });
6459
+ }
4697
6460
 
4698
- const result = await this.unarchiveCodexSession(sessionId, {
4699
- ...session,
4700
- // Fork is an action on the live Farming Agent. Its current workspace is
4701
- // authoritative even when the older provider history cwd no longer exists.
4702
- cwd: sourceWorkspace || session.cwd || session.workspace,
4703
- providerHomePath: session.providerHomePath || providerHomePath,
4704
- });
4705
- if (!result?.error) return null;
4706
- return {
4707
- error: `Codex session ${sessionId} is archived and could not be unarchived before forking: ${result.error}`,
4708
- };
4709
- })();
4710
- this.codexSessionUnarchiveInFlight.set(unarchiveKey, unarchivePromise);
6461
+ async performTargetProcessAcpConversationFork(options) {
6462
+ const {
6463
+ agent,
6464
+ provider,
6465
+ sourceSessionId,
6466
+ workspace,
6467
+ expectedRevision,
6468
+ lifecycleToken,
6469
+ acpSessionOptions,
6470
+ } = options;
6471
+ const command = getProviderAdapter(provider)?.executable || provider;
6472
+ let preparedSessionId = '';
6473
+ let result;
4711
6474
  try {
4712
- return await unarchivePromise;
4713
- } finally {
4714
- if (this.codexSessionUnarchiveInFlight.get(unarchiveKey) === unarchivePromise) {
4715
- this.codexSessionUnarchiveInFlight.delete(unarchiveKey);
6475
+ result = await this.acpRuntime.runWithForkReservation(
6476
+ agent.id,
6477
+ { expectedRevision, requireLoad: true },
6478
+ sourceBinding => {
6479
+ const forkSourceCheckpoint = this.acpRuntime.bindingCheckpoint(sourceBinding).exportCheckpoint();
6480
+ if (!forkSourceCheckpoint?.sessionState) {
6481
+ throw new Error('ACP fork source transcript is unavailable');
6482
+ }
6483
+ return new Promise(resolve => {
6484
+ let settled = false;
6485
+ const finish = (forkedAgentId, error) => {
6486
+ if (settled) return;
6487
+ settled = true;
6488
+ if (error || !forkedAgentId) {
6489
+ resolve({
6490
+ error: error || 'Failed to start forked ACP Chat Agent',
6491
+ ...(forkedAgentId ? { retainedAgentId: forkedAgentId } : {}),
6492
+ });
6493
+ return;
6494
+ }
6495
+ const forkedAgent = this.agents.get(forkedAgentId);
6496
+ const forkedSessionId = String(forkedAgent?.providerSessionId || preparedSessionId).trim();
6497
+ resolve({
6498
+ agentId: forkedAgentId,
6499
+ workspace,
6500
+ mode: 'same-worktree',
6501
+ targetRuntime: 'chat',
6502
+ providerSessionId: forkedSessionId,
6503
+ });
6504
+ };
6505
+
6506
+ try {
6507
+ const started = this.startAgent(command, workspace, finish, {
6508
+ wantsMain: false,
6509
+ parentAgentId: agent.id,
6510
+ task: agent.task ? `Fork: ${agent.task}` : `Fork of ${agent.command}`,
6511
+ workflowTemplate: agent.workflowTemplate || '',
6512
+ source: 'ui-fork-acp-chat',
6513
+ providerHomeId: agent.providerHomeId || 'default',
6514
+ providerHomePath: agent.providerHomePath || '',
6515
+ providerSessionTitle: agent.providerSessionTitle || '',
6516
+ projectWorkspace: workspace,
6517
+ agentRuntimeMode: 'chat',
6518
+ acpForkSourceSessionId: sourceSessionId,
6519
+ acpForkSourceCheckpoint: forkSourceCheckpoint,
6520
+ forkedFromProviderSessionId: sourceSessionId,
6521
+ lifecycleToken,
6522
+ ...acpSessionOptions,
6523
+ onAcpForkSessionCreated: sessionId => {
6524
+ preparedSessionId = String(sessionId || '').trim();
6525
+ },
6526
+ onAcpSessionPrepared: prepared => {
6527
+ preparedSessionId = String(prepared?.sessionId || '').trim();
6528
+ },
6529
+ ...(provider === 'claude'
6530
+ ? { claudePermissionMode: agent.launchPermissionMode || undefined }
6531
+ : {}),
6532
+ });
6533
+ Promise.resolve(started).then(startedAgentId => {
6534
+ if (!startedAgentId) void finish(null, 'Failed to start forked ACP Chat Agent');
6535
+ }).catch(startError => {
6536
+ void finish(null, startError?.message || 'Failed to start forked ACP Chat Agent');
6537
+ });
6538
+ } catch (startError) {
6539
+ void finish(null, startError?.message || 'Failed to start forked ACP Chat Agent');
6540
+ }
6541
+ });
6542
+ },
6543
+ );
6544
+ } catch (error) {
6545
+ return { error: error.message || 'Failed to fork ACP conversation' };
6546
+ }
6547
+ if (
6548
+ result?.error
6549
+ && !result.retainedAgentId
6550
+ && isSafeProviderSessionId(preparedSessionId)
6551
+ ) {
6552
+ try {
6553
+ await this.acpRuntime.deleteSession(agent.id, preparedSessionId);
6554
+ } catch (cleanupError) {
6555
+ result.retainedProviderSessionId = preparedSessionId;
6556
+ result.error = `${result.error}; forked session ${preparedSessionId} cleanup failed: ${cleanupError.message || cleanupError}`;
4716
6557
  }
4717
6558
  }
6559
+ return result;
6560
+ }
6561
+
6562
+ enqueueCodexSessionMutation(sessionId, options, type, operation, joinSameType = false) {
6563
+ const providerHomeId = String(options?.providerHomeId || 'default').trim() || 'default';
6564
+ const key = JSON.stringify([providerHomeId, sessionId]);
6565
+ const current = this.codexSessionMutationQueues.get(key);
6566
+ if (joinSameType && current?.type === type) return current.promise;
6567
+
6568
+ const previous = current?.promise || Promise.resolve();
6569
+ const next = previous.catch(() => {}).then(operation);
6570
+ const entry = { type, promise: next };
6571
+ this.codexSessionMutationQueues.set(key, entry);
6572
+ void next.finally(() => {
6573
+ if (this.codexSessionMutationQueues.get(key) === entry) {
6574
+ this.codexSessionMutationQueues.delete(key);
6575
+ }
6576
+ }).catch(() => {});
6577
+ return next;
6578
+ }
6579
+
6580
+ ensureCodexSessionAvailable(sessionId, options = {}) {
6581
+ const providerHomeId = String(options.providerHomeId || 'default').trim() || 'default';
6582
+ const providerHomePath = String(options.providerHomePath || '').trim();
6583
+ return this.enqueueCodexSessionMutation(
6584
+ sessionId,
6585
+ { providerHomeId, providerHomePath },
6586
+ 'unarchive',
6587
+ async () => {
6588
+ let session;
6589
+ try {
6590
+ session = await findAgentSession('codex', sessionId, {
6591
+ limit: 1000,
6592
+ providerLimit: 1000,
6593
+ scanLimit: 5000,
6594
+ providerHomeId,
6595
+ providerHomes: options.providerHomes || (providerHomePath
6596
+ ? { codex: [{ id: providerHomeId, path: providerHomePath }] }
6597
+ : undefined),
6598
+ });
6599
+ } catch (error) {
6600
+ return {
6601
+ error: `Failed to inspect Codex session before unarchiving: ${error && (error.message || error)}`,
6602
+ };
6603
+ }
6604
+ if (!session || session.archived !== true) return null;
6605
+
6606
+ const result = await this.unarchiveCodexSession(sessionId, {
6607
+ ...session,
6608
+ cwd: options.cwd || session.cwd || session.workspace,
6609
+ providerHomePath: session.providerHomePath || providerHomePath,
6610
+ });
6611
+ return result?.error ? { error: result.error } : null;
6612
+ },
6613
+ true,
6614
+ );
6615
+ }
6616
+
6617
+ async ensureCodexSessionAvailableForFork(agent, resumedSession, sourceWorkspace) {
6618
+ const providerHomeId = agent.providerHomeId || resumedSession.providerHomeId || 'default';
6619
+ const providerHomePath = agent.providerHomePath || '';
6620
+ const sessionId = resumedSession.sessionId;
6621
+ const result = await this.ensureCodexSessionAvailable(sessionId, {
6622
+ providerHomeId,
6623
+ providerHomePath,
6624
+ providerHomes: providerHomePath
6625
+ ? { codex: [{ id: providerHomeId, path: providerHomePath }] }
6626
+ : undefined,
6627
+ // Fork is an action on the live Farming Agent. Its current workspace is
6628
+ // authoritative even when the older provider history cwd no longer exists.
6629
+ cwd: sourceWorkspace,
6630
+ });
6631
+ if (!result?.error) return null;
6632
+ const detail = result.error.startsWith('Failed to inspect Codex session')
6633
+ ? result.error.replace('before unarchiving', 'before forking')
6634
+ : `Codex session ${sessionId} is archived and could not be unarchived before forking: ${result.error}`;
6635
+ return { error: detail };
4718
6636
  }
4719
6637
 
4720
6638
  recordTaskHistory(agent, options = {}) {
@@ -4748,36 +6666,126 @@ class AgentManager extends EventEmitter {
4748
6666
  }
4749
6667
  }
4750
6668
 
4751
- async archiveAgent(agentId, options = {}) {
6669
+ archiveAgent(agentId, options = {}) {
6670
+ if (options.skipRecoveryWait !== true && !this.recoveryComplete) {
6671
+ return this.whenRecovered().then(() => this.archiveAgent(agentId, {
6672
+ ...options,
6673
+ skipRecoveryWait: true,
6674
+ }));
6675
+ }
6676
+ const inFlight = this.agentLifecycleOperations.get(agentId);
6677
+ if (inFlight) {
6678
+ return this.runAgentLifecycleOperation(
6679
+ agentId,
6680
+ 'archive',
6681
+ 'archive',
6682
+ 'archive',
6683
+ lifecycleToken => this.performArchiveAgent(agentId, options, lifecycleToken),
6684
+ );
6685
+ }
4752
6686
  const agent = this.agents.get(agentId);
4753
6687
  if (!agent) {
4754
- return { error: 'Agent not found' };
6688
+ return Promise.resolve({ error: 'Agent not found' });
4755
6689
  }
4756
6690
  if (agent.id === this.mainAgentId) {
4757
- return { error: 'Main Agent cannot be archived' };
6691
+ return Promise.resolve({ error: 'Main Agent cannot be archived' });
4758
6692
  }
4759
6693
 
4760
- if (options.requireEngineExit !== true) {
4761
- const removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents([agent]);
4762
- this.ensurePersistentAgentSession(agent, { visibleOnMainPage: false });
4763
- await this.killAgent(agentId, {
4764
- reason: options.reason || 'manual-archive',
4765
- recordHistory: options.recordHistory !== false && !isEphemeralShellAgent(agent),
4766
- });
4767
- if (options.scheduleProviderArchive !== false) this.scheduleCodexSessionArchive(agent);
4768
- return { agentId, archived: true, removed: true, removedMainPageSessionKeys };
4769
- }
6694
+ return this.runAgentLifecycleOperation(
6695
+ agentId,
6696
+ 'archive',
6697
+ 'archive',
6698
+ 'archive',
6699
+ lifecycleToken => this.performArchiveAgent(agentId, options, lifecycleToken),
6700
+ );
6701
+ }
4770
6702
 
6703
+ async performArchiveAgent(agentId, options, lifecycleToken) {
6704
+ const agent = this.agents.get(agentId);
6705
+ if (!agent) return { error: 'Agent not found' };
6706
+ const admission = this.beginPersistentAgentOperation(agent, 'archive', 'archive', {
6707
+ reason: options.reason || 'manual-archive',
6708
+ structuredProcessProofRequired: runtimeKind(agent) === 'acp',
6709
+ });
6710
+ if (admission.error) return { agentId, error: admission.error };
6711
+ const operationId = admission.operation.id;
4771
6712
  const killResult = await this.killAgent(agentId, {
4772
6713
  reason: options.reason || 'manual-archive',
4773
- recordHistory: options.recordHistory !== false && !isEphemeralShellAgent(agent),
6714
+ recordHistory: false,
4774
6715
  requireEngineExit: true,
6716
+ retainAgentRecord: true,
6717
+ emitUpdate: false,
6718
+ lifecycleToken,
6719
+ persistDeleteOperation: false,
6720
+ skipRecoveryWait: options.skipRecoveryWait === true,
4775
6721
  });
4776
- if (killResult?.error) return killResult;
4777
- const removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents([agent]);
4778
- this.ensurePersistentAgentSession(agent, { visibleOnMainPage: false });
6722
+ if (killResult?.error) {
6723
+ try {
6724
+ this.transitionPersistentAgentOperation(agent, operationId, 'blocked', killResult.error);
6725
+ } catch (error) {
6726
+ return {
6727
+ ...killResult,
6728
+ error: `${killResult.error}; failed to persist blocked Archive: ${error.message || error}`,
6729
+ };
6730
+ }
6731
+ return { ...killResult, operationId };
6732
+ }
6733
+ let removedMainPageSessionKeys = [];
6734
+ try {
6735
+ this.transitionPersistentAgentOperation(agent, operationId, 'succeeded', '', {
6736
+ visibleOnMainPage: false,
6737
+ archived: true,
6738
+ archivedAt: Date.now(),
6739
+ runtimeAgentId: '',
6740
+ });
6741
+ } catch (error) {
6742
+ this.emit('update');
6743
+ return {
6744
+ agentId,
6745
+ error: `Agent stopped, but archive metadata could not be saved: ${error.message || error}`,
6746
+ stopped: true,
6747
+ archived: false,
6748
+ retryable: true,
6749
+ operationId,
6750
+ removedMainPageSessionKeys,
6751
+ };
6752
+ }
6753
+ let metadataWarning = '';
6754
+ try {
6755
+ removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents([agent]);
6756
+ } catch (error) {
6757
+ metadataWarning = `Agent archived, but main-page membership cleanup failed: ${error.message || error}`;
6758
+ console.error(metadataWarning);
6759
+ }
6760
+
6761
+ let historyWarning = '';
6762
+ if (options.recordHistory !== false && !isEphemeralShellAgent(agent)) {
6763
+ const previousTaskHistory = this.taskHistory;
6764
+ try {
6765
+ this.recordTaskHistory(agent, {
6766
+ reason: options.reason || 'manual-archive',
6767
+ archivedAt: Date.now(),
6768
+ });
6769
+ } catch (error) {
6770
+ this.taskHistory = previousTaskHistory;
6771
+ historyWarning = `Agent stopped, but history could not be saved: ${error.message || error}`;
6772
+ console.error(historyWarning);
6773
+ }
6774
+ }
6775
+ this.forgetStoppedAgentRecord(agentId);
4779
6776
  if (options.scheduleProviderArchive !== false) this.scheduleCodexSessionArchive(agent);
4780
- return { agentId, archived: true, removed: true, removedMainPageSessionKeys };
6777
+ return {
6778
+ agentId,
6779
+ archived: true,
6780
+ removed: true,
6781
+ operationId,
6782
+ removedMainPageSessionKeys,
6783
+ ...(
6784
+ metadataWarning || historyWarning
6785
+ ? { warning: [metadataWarning, historyWarning].filter(Boolean).join('; ') }
6786
+ : {}
6787
+ ),
6788
+ };
4781
6789
  }
4782
6790
 
4783
6791
  scheduleCodexSessionArchive(agent) {
@@ -4797,8 +6805,16 @@ class AgentManager extends EventEmitter {
4797
6805
  workspace: agent.projectWorkspace || '',
4798
6806
  providerHomePath: agent.providerHomePath || '',
4799
6807
  };
4800
- void Promise.resolve()
4801
- .then(() => this.archiveCodexSession(sessionId, session))
6808
+ void this.enqueueCodexSessionMutation(
6809
+ sessionId,
6810
+ {
6811
+ providerHomeId: agent.providerHomeId || 'default',
6812
+ providerHomePath: agent.providerHomePath || '',
6813
+ },
6814
+ 'archive',
6815
+ () => this.archiveCodexSession(sessionId, session),
6816
+ true,
6817
+ )
4802
6818
  .then(result => {
4803
6819
  if (result?.error) {
4804
6820
  console.error(`Failed to archive Codex session ${sessionId}: ${result.error}`);
@@ -4809,54 +6825,399 @@ class AgentManager extends EventEmitter {
4809
6825
  });
4810
6826
  }
4811
6827
 
4812
- async killAgent(agentId, options = {}) {
6828
+ admitPersistentDelete(agent, options = {}) {
6829
+ const activeOperation = activeLifecycleOperation(agent);
6830
+ if (
6831
+ activeOperation?.type === 'create'
6832
+ && ['pending', 'blocked'].includes(activeOperation.state)
6833
+ ) {
6834
+ const previousJournal = lifecycleJournal(agent);
6835
+ try {
6836
+ transitionLifecycleOperation(
6837
+ agent,
6838
+ activeOperation.id,
6839
+ 'cancelled',
6840
+ 'Create was superseded by Delete',
6841
+ );
6842
+ const admittedDelete = beginLifecycleOperation(
6843
+ agent,
6844
+ 'delete',
6845
+ 'delete',
6846
+ {
6847
+ reason: options.reason || 'manual-kill',
6848
+ structuredProcessProofRequired: runtimeKind(agent) === 'acp',
6849
+ },
6850
+ );
6851
+ const persistentSessionId = this.ensurePersistentAgentSession(agent);
6852
+ if (
6853
+ typeof this.configManager?.ensureAgentSessionRecord === 'function'
6854
+ && !persistentSessionId
6855
+ ) {
6856
+ throw new Error('Agent session store did not return a persistent id');
6857
+ }
6858
+ return {
6859
+ operationId: admittedDelete.operation.id,
6860
+ completesBlockedCreate: false,
6861
+ };
6862
+ } catch (error) {
6863
+ agent.lifecycleJournal = previousJournal;
6864
+ return { error: `Failed to persist Create cleanup retry: ${error.message || error}` };
6865
+ }
6866
+ }
6867
+ const admitted = this.beginPersistentAgentOperation(agent, 'delete', 'delete', {
6868
+ reason: options.reason || 'manual-kill',
6869
+ structuredProcessProofRequired: runtimeKind(agent) === 'acp',
6870
+ });
6871
+ if (admitted.error) return { error: admitted.error };
6872
+ return {
6873
+ operationId: admitted.operation.id,
6874
+ completesBlockedCreate: false,
6875
+ };
6876
+ }
6877
+
6878
+ async requestKillAgent(agentId, options = {}) {
6879
+ if (options.skipRecoveryWait !== true) await this.whenRecovered();
6880
+ const existing = this.agentLifecycleOperations.get(agentId);
6881
+ if (existing && existing.key !== 'kill') {
6882
+ await existing.promise.catch(() => {});
6883
+ return this.requestKillAgent(agentId, options);
6884
+ }
6885
+ const completion = this.killAgent(agentId, options);
6886
+ const operation = activeLifecycleOperation(this.agents.get(agentId));
6887
+ if (!operation || !['create', 'delete'].includes(operation.type)) {
6888
+ const result = await completion;
6889
+ return { result, completion: Promise.resolve(result) };
6890
+ }
6891
+ return {
6892
+ result: {
6893
+ agentId,
6894
+ accepted: true,
6895
+ operationId: operation.id,
6896
+ operationType: operation.type,
6897
+ operationState: operation.state,
6898
+ },
6899
+ completion,
6900
+ };
6901
+ }
6902
+
6903
+ killAgent(agentId, options = {}) {
6904
+ if (options.skipRecoveryWait !== true && !this.recoveryComplete) {
6905
+ return this.whenRecovered().then(() => this.killAgent(agentId, {
6906
+ ...options,
6907
+ skipRecoveryWait: true,
6908
+ }));
6909
+ }
6910
+ const inFlight = this.agentLifecycleOperations.get(agentId);
6911
+ if (inFlight) {
6912
+ if (options.lifecycleToken && inFlight.token === options.lifecycleToken) {
6913
+ return this.performKillAgent(agentId, options);
6914
+ }
6915
+ return this.runAgentLifecycleOperation(
6916
+ agentId,
6917
+ 'kill',
6918
+ 'kill',
6919
+ 'kill',
6920
+ lifecycleToken => {
6921
+ const agent = this.agents.get(agentId);
6922
+ if (!agent) return { agentId, killed: true, missing: true };
6923
+ let queuedOptions = options;
6924
+ if (options.persistDeleteOperation !== false && !options.persistentOperationId) {
6925
+ const admitted = this.admitPersistentDelete(agent, options);
6926
+ if (admitted.error) return { agentId, error: admitted.error };
6927
+ queuedOptions = {
6928
+ ...options,
6929
+ persistentOperationId: admitted.operationId,
6930
+ completesBlockedCreate: admitted.completesBlockedCreate,
6931
+ };
6932
+ }
6933
+ return this.performKillAgent(agentId, { ...queuedOptions, lifecycleToken });
6934
+ },
6935
+ );
6936
+ }
6937
+ const agent = this.agents.get(agentId);
6938
+ if (!agent) return Promise.resolve({ agentId, killed: true, missing: true });
6939
+ let admittedOptions = options;
6940
+ if (options.persistDeleteOperation !== false && !options.persistentOperationId) {
6941
+ const admitted = this.admitPersistentDelete(agent, options);
6942
+ if (admitted.error) return Promise.resolve({ agentId, error: admitted.error });
6943
+ admittedOptions = {
6944
+ ...options,
6945
+ persistentOperationId: admitted.operationId,
6946
+ completesBlockedCreate: admitted.completesBlockedCreate,
6947
+ };
6948
+ }
6949
+
6950
+ return this.runAgentLifecycleOperation(
6951
+ agentId,
6952
+ 'kill',
6953
+ 'kill',
6954
+ 'kill',
6955
+ lifecycleToken => this.performKillAgent(agentId, { ...admittedOptions, lifecycleToken }),
6956
+ );
6957
+ }
6958
+
6959
+ async performKillAgent(agentId, options = {}) {
4813
6960
  const agent = this.agents.get(agentId);
4814
6961
  if (!agent) return { agentId, killed: true, missing: true };
4815
6962
 
4816
- const engine = this.engineBridge.getEngine(agent.engineName);
4817
- if (options.requireEngineExit === true && !engine) {
4818
- return { agentId, error: 'Agent runtime is unavailable; process exit cannot be verified' };
4819
- }
4820
- try {
4821
- if (engine) {
4822
- await engine.killSession(agentId);
6963
+ let persistentOperationId = options.persistentOperationId || '';
6964
+ const completesBlockedCreate = options.completesBlockedCreate === true;
6965
+ const cleanupFailure = (message, kind = '') => {
6966
+ const error = String(message || 'Failed to stop Agent runtime');
6967
+ if (kind === 'acp' || kind === 'json') {
6968
+ return this.markStructuredAgentCleanupUncertain(
6969
+ agentId,
6970
+ kind,
6971
+ error,
6972
+ { operationId: persistentOperationId },
6973
+ );
4823
6974
  }
4824
- } catch (error) {
4825
- console.error('Failed to kill agent:', error);
4826
- if (options.requireEngineExit === true) {
4827
- return { agentId, error: error.message || 'Failed to stop Agent runtime' };
6975
+ agent.status = 'error';
6976
+ agent.engineStatus = 'cleanup-uncertain';
6977
+ if (persistentOperationId) {
6978
+ try {
6979
+ this.transitionPersistentAgentOperation(agent, persistentOperationId, 'blocked', error);
6980
+ } catch (persistError) {
6981
+ return {
6982
+ agentId,
6983
+ error: `${error}; failed to persist blocked Agent operation: ${persistError.message || persistError}`,
6984
+ cleanupUncertain: true,
6985
+ retryable: true,
6986
+ };
6987
+ }
4828
6988
  }
4829
- }
6989
+ this.emit('update');
6990
+ return { agentId, error, cleanupUncertain: true, retryable: true };
6991
+ };
4830
6992
 
4831
- if (options.requireEngineExit === true && engine) {
4832
- const deadline = Date.now() + 3000;
4833
- let lastState = null;
4834
- while (Date.now() < deadline) {
6993
+ const requireEngineExit = options.requireEngineExit !== false;
6994
+ const currentRuntimeKind = runtimeKind(agent);
6995
+ if (!this.verifiedStoppedAgentIds.has(agentId)) {
6996
+ this.permissionRestartSuppressedAgentIds.add(agentId);
6997
+ try {
6998
+ if (currentRuntimeKind === 'acp') {
6999
+ if (typeof this.acpRuntime?.unregisterAgentAndWait !== 'function') {
7000
+ return cleanupFailure('ACP runtime exit cannot be verified', 'acp');
7001
+ }
7002
+ const stopped = await this.acpRuntime.unregisterAgentAndWait(agentId);
7003
+ if (stopped !== true) {
7004
+ if (!agent.structuredRuntimeProcess) {
7005
+ if (
7006
+ options.acknowledgeUnprovenAcpExit !== true
7007
+ || agent.requiresProcessExitAcknowledgement !== true
7008
+ ) {
7009
+ return cleanupFailure('ACP runtime binding is missing; process exit cannot be verified', 'acp');
7010
+ }
7011
+ const previousAcknowledgedAt = agent.legacyAcpProcessExitAcknowledgedAt;
7012
+ agent.legacyAcpProcessExitAcknowledgedAt = Date.now();
7013
+ agent.requiresProcessExitAcknowledgement = false;
7014
+ try {
7015
+ this.ensurePersistentAgentSession(agent);
7016
+ } catch (error) {
7017
+ agent.legacyAcpProcessExitAcknowledgedAt = previousAcknowledgedAt;
7018
+ agent.requiresProcessExitAcknowledgement = true;
7019
+ throw error;
7020
+ }
7021
+ } else {
7022
+ const cleanup = await this.stopPersistedAcpProcessGroup(agent.structuredRuntimeProcess);
7023
+ if (cleanup.stopped !== true) {
7024
+ return cleanupFailure(
7025
+ 'ACP runtime binding is missing and the persisted process identity could not be safely stopped',
7026
+ 'acp',
7027
+ );
7028
+ }
7029
+ }
7030
+ }
7031
+ } else if (currentRuntimeKind === 'json') {
7032
+ if (typeof this.jsonCliRuntime?.unregisterAgentAndWait !== 'function') {
7033
+ return cleanupFailure('JSON runtime exit cannot be verified', 'json');
7034
+ }
7035
+ const stopped = await this.jsonCliRuntime.unregisterAgentAndWait(agentId);
7036
+ if (stopped !== true) {
7037
+ return cleanupFailure('JSON runtime binding is missing; process exit cannot be verified', 'json');
7038
+ }
7039
+ } else {
7040
+ const engine = this.engineBridge.getEngine(agent.engineName);
7041
+ if (requireEngineExit && !engine) {
7042
+ return cleanupFailure('Agent runtime is unavailable; process exit cannot be verified');
7043
+ }
7044
+ let killError = null;
4835
7045
  try {
4836
- lastState = await engine.getSessionState(agentId);
7046
+ if (engine) {
7047
+ await engine.killSession(agentId);
7048
+ }
4837
7049
  } catch (error) {
4838
- if (isSessionNotAvailableError(error)) {
4839
- lastState = null;
4840
- break;
7050
+ console.error('Failed to kill agent:', error);
7051
+ killError = error;
7052
+ }
7053
+
7054
+ if (requireEngineExit && engine) {
7055
+ const deadline = Date.now() + 3000;
7056
+ let lastState = null;
7057
+ while (Date.now() < deadline) {
7058
+ try {
7059
+ lastState = await engine.getSessionState(agentId);
7060
+ } catch (error) {
7061
+ if (isSessionNotAvailableError(error)) {
7062
+ lastState = null;
7063
+ break;
7064
+ }
7065
+ return cleanupFailure(error.message || 'Failed to verify Agent process exit');
7066
+ }
7067
+ if (!isLiveEngineSessionState(lastState)) break;
7068
+ await new Promise(resolve => setTimeout(resolve, 50));
4841
7069
  }
4842
- return { agentId, error: error.message || 'Failed to verify Agent process exit' };
7070
+ if (isLiveEngineSessionState(lastState)) {
7071
+ return cleanupFailure(killError?.message || 'Agent process did not exit within 3 seconds');
7072
+ }
7073
+ } else if (killError) {
7074
+ return cleanupFailure(killError.message || 'Failed to stop Agent runtime');
4843
7075
  }
4844
- if (!isLiveEngineSessionState(lastState)) break;
4845
- await new Promise(resolve => setTimeout(resolve, 50));
4846
7076
  }
4847
- if (isLiveEngineSessionState(lastState)) {
4848
- return { agentId, error: 'Agent process did not exit within 3 seconds' };
7077
+ } catch (error) {
7078
+ if (currentRuntimeKind === 'acp' || currentRuntimeKind === 'json') {
7079
+ return cleanupFailure(error.message || 'Failed to stop Agent runtime', currentRuntimeKind);
7080
+ }
7081
+ return cleanupFailure(error.message || 'Failed to stop Agent runtime');
7082
+ } finally {
7083
+ this.permissionRestartSuppressedAgentIds.delete(agentId);
4849
7084
  }
4850
7085
  }
7086
+ if (currentRuntimeKind === 'acp' || currentRuntimeKind === 'json') {
7087
+ agent.structuredRuntimeProcess = null;
7088
+ }
4851
7089
 
4852
- if (options.recordHistory !== false && !isEphemeralShellAgent(agent)) {
4853
- this.recordTaskHistory(agent, {
7090
+ if (completesBlockedCreate) {
7091
+ try {
7092
+ this.transitionPersistentAgentOperation(
7093
+ agent,
7094
+ persistentOperationId,
7095
+ 'failed',
7096
+ 'Create runtime was stopped by Delete recovery',
7097
+ );
7098
+ } catch (error) {
7099
+ const message = `Agent stopped, but Create cleanup could not be committed: ${error.message || error}`;
7100
+ this.verifiedStoppedAgentIds.add(agentId);
7101
+ agent.status = 'stopped';
7102
+ agent.engineStatus = 'exited';
7103
+ this.emit('update');
7104
+ return {
7105
+ agentId,
7106
+ error: message,
7107
+ stopped: true,
7108
+ retryable: true,
7109
+ operationId: persistentOperationId,
7110
+ };
7111
+ }
7112
+ const admittedDelete = this.beginPersistentAgentOperation(agent, 'delete', 'delete', {
4854
7113
  reason: options.reason || 'manual-kill',
4855
- archivedAt: Date.now(),
7114
+ structuredProcessProofRequired: runtimeKind(agent) === 'acp',
4856
7115
  });
7116
+ if (admittedDelete.error) {
7117
+ return { agentId, error: admittedDelete.error, stopped: true, retryable: true };
7118
+ }
7119
+ persistentOperationId = admittedDelete.operation.id;
7120
+ }
7121
+
7122
+ if (persistentOperationId) {
7123
+ try {
7124
+ this.transitionPersistentAgentOperation(agent, persistentOperationId, 'succeeded', '', {
7125
+ visibleOnMainPage: false,
7126
+ archived: true,
7127
+ archivedAt: Date.now(),
7128
+ runtimeAgentId: '',
7129
+ });
7130
+ this.removeMainPageProviderSessionsForAgents([agent]);
7131
+ } catch (error) {
7132
+ const message = `Agent stopped, but Delete metadata could not be committed: ${error.message || error}`;
7133
+ console.error(message);
7134
+ this.verifiedStoppedAgentIds.add(agentId);
7135
+ agent.status = 'stopped';
7136
+ agent.engineStatus = 'exited';
7137
+ this.emit('update');
7138
+ return {
7139
+ agentId,
7140
+ error: message,
7141
+ stopped: true,
7142
+ retryable: true,
7143
+ operationId: persistentOperationId,
7144
+ };
7145
+ }
7146
+ }
7147
+
7148
+ let historyWarning = '';
7149
+ if (options.recordHistory !== false && !isEphemeralShellAgent(agent)) {
7150
+ const previousTaskHistory = this.taskHistory;
7151
+ try {
7152
+ this.recordTaskHistory(agent, {
7153
+ reason: options.reason || 'manual-kill',
7154
+ archivedAt: Date.now(),
7155
+ });
7156
+ } catch (error) {
7157
+ this.taskHistory = previousTaskHistory;
7158
+ historyWarning = `Agent stopped, but history could not be saved: ${error.message || error}`;
7159
+ console.error(historyWarning);
7160
+ }
4857
7161
  }
4858
7162
 
7163
+ if (options.retainAgentRecord === true) {
7164
+ this.verifiedStoppedAgentIds.add(agentId);
7165
+ agent.status = 'stopped';
7166
+ agent.engineStatus = 'exited';
7167
+ if (options.emitUpdate !== false) this.emit('update');
7168
+ return {
7169
+ agentId,
7170
+ killed: true,
7171
+ retained: true,
7172
+ ...(historyWarning ? { warning: historyWarning } : {}),
7173
+ };
7174
+ }
7175
+
7176
+ this.forgetStoppedAgentRecord(agentId, { emitUpdate: options.emitUpdate !== false });
7177
+ return {
7178
+ agentId,
7179
+ killed: true,
7180
+ ...(persistentOperationId ? { operationId: persistentOperationId } : {}),
7181
+ ...(historyWarning ? { warning: historyWarning } : {}),
7182
+ };
7183
+ }
7184
+
7185
+ markStructuredAgentCleanupUncertain(agentId, kind, error, options = {}) {
7186
+ const agent = this.agents.get(agentId);
7187
+ const message = String(error || 'Agent runtime exit cannot be verified');
7188
+ if (agent) {
7189
+ agent.status = 'error';
7190
+ agent.engineStatus = 'cleanup-uncertain';
7191
+ const runtime = runtimeBindingOf(agent, kind);
7192
+ if (runtime) {
7193
+ runtime.state = 'error';
7194
+ runtime.error = message;
7195
+ }
7196
+ if (options.operationId) {
7197
+ try {
7198
+ this.transitionPersistentAgentOperation(agent, options.operationId, 'blocked', message);
7199
+ } catch (persistError) {
7200
+ return {
7201
+ agentId,
7202
+ error: `${message}; failed to persist blocked Agent operation: ${persistError.message || persistError}`,
7203
+ cleanupUncertain: true,
7204
+ retryable: true,
7205
+ };
7206
+ }
7207
+ }
7208
+ if (options.emitUpdate !== false) this.emit('update');
7209
+ }
7210
+ return {
7211
+ agentId,
7212
+ error: message,
7213
+ cleanupUncertain: true,
7214
+ retryable: true,
7215
+ };
7216
+ }
7217
+
7218
+ forgetStoppedAgentRecord(agentId, options = {}) {
4859
7219
  this.agents.delete(agentId);
7220
+ this.verifiedStoppedAgentIds.delete(agentId);
4860
7221
  this.lastActivity.delete(agentId);
4861
7222
  this.lastActivityUpdate.delete(agentId);
4862
7223
  this.outputEvents.delete(agentId);
@@ -4870,10 +7231,7 @@ class AgentManager extends EventEmitter {
4870
7231
  this.mainAgentId = null;
4871
7232
  }
4872
7233
 
4873
- if (options.emitUpdate !== false) {
4874
- this.emit('update');
4875
- }
4876
- return { agentId, killed: true };
7234
+ if (options.emitUpdate !== false) this.emit('update');
4877
7235
  }
4878
7236
 
4879
7237
  async getAgentSessionText(agentId) {
@@ -5219,6 +7577,9 @@ class AgentManager extends EventEmitter {
5219
7577
  terminalInputReceived: agent.terminalInputReceived === true,
5220
7578
  runtimeBinding: publicRuntimeBinding(agent),
5221
7579
  runtimeObservation: deriveRuntimeObservation(agent),
7580
+ lifecycleOperation: publicActiveLifecycleOperation(agent),
7581
+ requiresProcessExitAcknowledgement:
7582
+ agent.requiresProcessExitAcknowledgement === true,
5222
7583
  forkedFromProviderSessionId: agent.forkedFromProviderSessionId || '',
5223
7584
  restartedFromAgentId: agent.restartedFromAgentId || '',
5224
7585
  restartedFromAgentIds: Array.isArray(agent.restartedFromAgentIds) ? agent.restartedFromAgentIds : [],