farming-code 2.2.7 → 2.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +110 -199
  2. package/README.zh_cn.md +106 -237
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1496 -737
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +45 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +30 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/farming-net-pass.js +285 -0
  22. package/backend/farming-net-registry.js +112 -0
  23. package/backend/farming-net-server.js +298 -0
  24. package/backend/farming-session-store.js +5 -13
  25. package/backend/git-worktree-info.js +181 -0
  26. package/backend/local-session-engine.js +411 -186
  27. package/backend/main-page-session.js +5 -2
  28. package/backend/native-pty-controller-generation.js +106 -0
  29. package/backend/native-pty-host-client.js +275 -7
  30. package/backend/native-pty-host-identity.js +86 -0
  31. package/backend/native-pty-host.js +813 -114
  32. package/backend/native-session-engine.js +100 -28
  33. package/backend/packaged-node-pty.js +22 -2
  34. package/backend/provider-adapters.js +253 -0
  35. package/backend/provider-session-service.js +241 -0
  36. package/backend/runtime-observation.js +81 -0
  37. package/backend/server.js +294 -86
  38. package/backend/session-engine-bridge.js +21 -2
  39. package/backend/session-engine-router.js +1 -1
  40. package/backend/session-engine.js +1 -1
  41. package/backend/session-stream-protocol.js +185 -0
  42. package/backend/storage-layout.js +55 -0
  43. package/backend/terminal-attach-checkpoint.js +74 -0
  44. package/backend/terminal-exit-quiescence.js +39 -0
  45. package/backend/terminal-reducer-flow-control.js +97 -0
  46. package/backend/terminal-screen-state.js +11 -2
  47. package/backend/terminal-screen-worker-pool.js +59 -6
  48. package/backend/terminal-screen-worker-thread.js +97 -57
  49. package/backend/terminal-screen-worker.js +133 -51
  50. package/backend/terminal-state-serialization.js +127 -0
  51. package/backend/terminal-status.js +23 -4
  52. package/backend/usage-monitor.js +81 -4
  53. package/backend/workspace-directory.js +152 -0
  54. package/backend/workspace-file-router.js +182 -76
  55. package/backend/workspace-file-service.js +319 -4
  56. package/backend/workspace-root-registry.js +164 -0
  57. package/dist/assets/App-CHYpgECZ.js +208 -0
  58. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview--Cri80nQ.js} +92 -92
  59. package/dist/assets/FileEditorPane-DGJlEKdf.js +2 -0
  60. package/dist/assets/IconGlyphs-Cc14sepw.js +1 -0
  61. package/dist/assets/ProjectFilesSection-Cz1wG4NT.js +12 -0
  62. package/dist/assets/{ReviewPage-BaXu1ZdX.js → ReviewPage-LonRc6Fz.js} +1 -1
  63. package/dist/assets/code-dark-B1pAtO_P.css +1 -0
  64. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  65. package/dist/assets/{index-B8SHldPE.js → index-miO2r9IZ.js} +3 -3
  66. package/dist/assets/main-DNe0jPw-.css +1 -0
  67. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  68. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +1 -0
  69. package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +4 -0
  70. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  71. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  72. package/dist/index.html +1 -1
  73. package/frontend/farming-net/app.css +625 -0
  74. package/frontend/farming-net/app.js +268 -0
  75. package/frontend/farming-net/index.html +86 -0
  76. package/frontend/reading-anchor.js +198 -0
  77. package/frontend/session-bridge.js +12 -3
  78. package/frontend/session-modal-bridge.js +5 -12
  79. package/frontend/skins/crt/app.js +1976 -793
  80. package/frontend/skins/crt/index.html +313 -23
  81. package/frontend/skins/crt/styles/billing.css +294 -223
  82. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  83. package/frontend/terminal-replay.js +372 -0
  84. package/package.json +10 -3
  85. package/shared/browser-protocol.d.ts +5 -0
  86. package/shared/browser-protocol.js +130 -0
  87. package/dist/assets/App-iA2BU9P6.js +0 -124
  88. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  89. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  90. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  91. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  92. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  93. package/dist/assets/main-DpYuBonn.css +0 -1
  94. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  95. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  96. package/dist/assets/qoder-gHCinseV.svg +0 -1
  97. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  98. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  99. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  100. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  101. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  102. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const net = require('net');
5
6
  const os = require('os');
@@ -20,6 +21,30 @@ const {
20
21
  } = require('./local-session-engine');
21
22
  const { deriveTerminalStatus } = require('./terminal-status');
22
23
  const { probeUnixSocket } = require('./terminal-runtime-cleanup');
24
+ const { nativePtyHostRuntimeIdentity } = require('./native-pty-host-identity');
25
+ const {
26
+ allocateNativePtyRuntimeGeneration,
27
+ formatNativePtyRuntimeEpoch,
28
+ } = require('./native-pty-controller-generation');
29
+ const {
30
+ acknowledgeTerminalReducerData,
31
+ createTerminalReducerFlowControl,
32
+ ensureTerminalReducerFlowControl,
33
+ enqueueTerminalReducerData,
34
+ resetTerminalReducerFlowControl,
35
+ setTerminalExternalFlowControlBlocked,
36
+ } = require('./terminal-reducer-flow-control');
37
+ const {
38
+ normalizeTerminalStateEntry,
39
+ serializeTerminalState,
40
+ } = require('./terminal-state-serialization');
41
+ const {
42
+ captureTerminalAttachCheckpoint,
43
+ } = require('./terminal-attach-checkpoint');
44
+ const {
45
+ acceptTerminalExitData,
46
+ waitForTerminalExitDataQuiescence,
47
+ } = require('./terminal-exit-quiescence');
23
48
 
24
49
  const OUTPUT_LIMIT = 10000;
25
50
  const DEFAULT_COLS = 80;
@@ -27,6 +52,11 @@ const DEFAULT_ROWS = 30;
27
52
  const OWNER_CHECK_INTERVAL_MS = 1000;
28
53
  const DISPOSE_PTY_GRACE_MS = 800;
29
54
  const DEFAULT_IDLE_EXIT_MS = 60000;
55
+ const DEFAULT_CLIENT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;
56
+ const DEFAULT_CLIENT_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
57
+ const HOST_RUNTIME_IDENTITY = nativePtyHostRuntimeIdentity();
58
+ const CONTROLLER_MUTATION_ADMISSION = Symbol('controllerMutationAdmission');
59
+ const TERMINAL_HISTORY_RESTORED_MESSAGE = '\r\n\x1b[7m History restored \x1b[0m\r\n';
30
60
 
31
61
  function trimOutput(output) {
32
62
  const text = typeof output === 'string' ? output : '';
@@ -52,11 +82,32 @@ class NativePtyHost {
52
82
  this.configDir = options.configDir || process.env.FARMING_CONFIG_DIR || path.join(os.homedir(), '.farming');
53
83
  this.socketPath = options.socketPath || process.env.FARMING_NATIVE_PTY_HOST_SOCKET || nativePtyHostSocketPath(this.configDir);
54
84
  this.ownerPid = Number(options.ownerPid || process.env.FARMING_NATIVE_PTY_HOST_OWNER_PID || 0);
85
+ this.runtimeIdentity = options.runtimeIdentity || HOST_RUNTIME_IDENTITY;
86
+ this.exitOnShutdown = options.exitOnShutdown !== false;
55
87
  this.sessions = new Map();
56
88
  this.clients = new Set();
57
89
  this.ownerCheckTimer = null;
58
90
  this.idleExitTimer = null;
59
91
  this.hasAcceptedClient = false;
92
+ this.activeControllerClient = null;
93
+ this.activeControllerIdentity = null;
94
+ this.sessionMutationQueues = new Map();
95
+ this.activeControllerMutations = new Set();
96
+ this.controllerRegistrationQueue = Promise.resolve();
97
+ this.controllerHandoff = null;
98
+ this.rotationPreparation = null;
99
+ this.clientMaxBufferedBytes = normalizePositiveInteger(
100
+ options.clientMaxBufferedBytes,
101
+ DEFAULT_CLIENT_MAX_BUFFERED_BYTES,
102
+ 1024,
103
+ 256 * 1024 * 1024,
104
+ );
105
+ this.clientMaxRequestBytes = normalizePositiveInteger(
106
+ options.clientMaxRequestBytes,
107
+ DEFAULT_CLIENT_MAX_REQUEST_BYTES,
108
+ 1024,
109
+ 256 * 1024 * 1024,
110
+ );
60
111
  this.idleExitMs = normalizePositiveInteger(
61
112
  options.idleExitMs ?? process.env.FARMING_NATIVE_PTY_HOST_IDLE_EXIT_MS,
62
113
  DEFAULT_IDLE_EXIT_MS,
@@ -125,7 +176,7 @@ class NativePtyHost {
125
176
  }
126
177
 
127
178
  handleConnection(socket) {
128
- const client = { socket, buffer: '' };
179
+ const client = { socket, buffer: '', disconnected: false };
129
180
  this.hasAcceptedClient = true;
130
181
  this.cancelIdleExit();
131
182
  this.clients.add(client);
@@ -135,8 +186,43 @@ class NativePtyHost {
135
186
  }
136
187
 
137
188
  removeClient(client) {
189
+ if (!client || client.disconnected === true) return Promise.resolve();
190
+ client.disconnected = true;
138
191
  this.clients.delete(client);
192
+ let retirement = Promise.resolve();
193
+ if (
194
+ this.activeControllerClient === client
195
+ || this.controllerHandoff?.fromClient === client
196
+ || this.controllerHandoff?.toClient === client
197
+ ) {
198
+ retirement = this.controllerRegistrationQueue
199
+ .catch(() => {})
200
+ .then(() => this.retireControllerClient(client));
201
+ this.controllerRegistrationQueue = retirement;
202
+ }
139
203
  this.scheduleIdleExitIfUnused();
204
+ return retirement;
205
+ }
206
+
207
+ async retireControllerClient(client) {
208
+ if (this.activeControllerClient !== client) return;
209
+ const handoff = {
210
+ fromClient: client,
211
+ fromIdentity: this.activeControllerIdentity,
212
+ toClient: null,
213
+ toIdentity: null,
214
+ };
215
+ this.controllerHandoff = handoff;
216
+ try {
217
+ // A disconnect closes admission immediately, but an operation that was
218
+ // already admitted owns its linearization point until it completes.
219
+ // Retire the server client only after that exact set has drained.
220
+ await Promise.allSettled([...this.activeControllerMutations]);
221
+ if (this.activeControllerClient !== client) return;
222
+ this.activeControllerClient = null;
223
+ } finally {
224
+ if (this.controllerHandoff === handoff) this.controllerHandoff = null;
225
+ }
140
226
  }
141
227
 
142
228
  hasLiveSessions() {
@@ -186,6 +272,10 @@ class NativePtyHost {
186
272
  client.buffer += chunk.toString('utf8');
187
273
  let newline = client.buffer.indexOf('\n');
188
274
  while (newline >= 0) {
275
+ if (newline > this.clientMaxRequestBytes) {
276
+ this.disconnectSlowClient(client, 'native pty request exceeded limit');
277
+ return;
278
+ }
189
279
  const line = client.buffer.slice(0, newline);
190
280
  client.buffer = client.buffer.slice(newline + 1);
191
281
  if (line.trim()) {
@@ -193,6 +283,9 @@ class NativePtyHost {
193
283
  }
194
284
  newline = client.buffer.indexOf('\n');
195
285
  }
286
+ if (client.buffer.length > this.clientMaxRequestBytes) {
287
+ this.disconnectSlowClient(client, 'native pty request exceeded limit');
288
+ }
196
289
  }
197
290
 
198
291
  async handleClientMessage(client, line) {
@@ -207,7 +300,7 @@ class NativePtyHost {
207
300
  if (!id) return;
208
301
 
209
302
  try {
210
- const result = await this.dispatch(message.method, message.params || {});
303
+ const result = await this.dispatch(message.method, message.params || {}, client);
211
304
  this.send(client, { id, ok: true, result });
212
305
  } catch (error) {
213
306
  this.send(client, {
@@ -218,45 +311,146 @@ class NativePtyHost {
218
311
  }
219
312
  }
220
313
 
221
- dispatch(method, params) {
314
+ dispatch(method, params, client) {
222
315
  switch (method) {
223
316
  case 'ping':
224
- return { ok: true };
317
+ return {
318
+ ok: true,
319
+ pid: process.pid,
320
+ runtimeIdentity: this.runtimeIdentity,
321
+ };
322
+ case 'registerController':
323
+ return this.registerController(client, params.identity || {});
225
324
  case 'createSession':
226
- return this.createSession(params.options || {});
325
+ return this.enqueueControllerMutation(
326
+ params.options?.agentId,
327
+ client,
328
+ () => this.createSession(params.options || {}),
329
+ );
227
330
  case 'sendInput':
228
- return this.sendInput(params.sessionId, params.input);
331
+ return this.enqueueControllerMutation(
332
+ params.sessionId,
333
+ client,
334
+ () => this.sendInput(params.sessionId, params.input, params.expectedRuntimeEpoch || ''),
335
+ );
229
336
  case 'resizeSession':
230
- return this.resizeSession(params.sessionId, params.cols, params.rows);
337
+ return this.enqueueControllerMutation(
338
+ params.sessionId,
339
+ client,
340
+ () => this.resizeSession(params.sessionId, params.cols, params.rows),
341
+ );
231
342
  case 'clearBuffer':
232
- return this.clearBuffer(params.sessionId);
343
+ return this.enqueueControllerMutation(
344
+ params.sessionId,
345
+ client,
346
+ () => this.clearBuffer(params.sessionId, params.expectedRuntimeEpoch || ''),
347
+ );
233
348
  case 'killSession':
234
- return this.killSession(params.sessionId);
349
+ return this.enqueueControllerMutation(
350
+ params.sessionId,
351
+ client,
352
+ () => this.killSession(params.sessionId),
353
+ );
235
354
  case 'getSessionState':
236
355
  return this.getSessionState(params.sessionId);
356
+ case 'getSessionAttachCheckpoint':
357
+ return this.getSessionAttachCheckpoint(params.sessionId, client);
237
358
  case 'getSessionPreview':
238
359
  return this.getSessionPreview(params.sessionId);
239
360
  case 'recoverSessions':
240
361
  return this.recoverSessions();
362
+ case 'serializeTerminalState':
363
+ return this.serializeTerminalState(client);
364
+ case 'resumeTerminalState':
365
+ return this.resumeTerminalState(client, params.preparationToken || '');
241
366
  case 'updateSessionMetadata':
242
- return this.updateSessionMetadata(params.sessionId, params.patch || {});
367
+ return this.enqueueControllerMutation(
368
+ params.sessionId,
369
+ client,
370
+ () => this.updateSessionMetadata(params.sessionId, params.patch || {}),
371
+ );
243
372
  case 'shutdownHost':
244
- return this.shutdownHost();
373
+ return this.shutdownHost(
374
+ client,
375
+ params.controller || {},
376
+ params.preparationToken || '',
377
+ );
245
378
  default:
246
379
  throw new Error(`Unknown native pty host method: ${method}`);
247
380
  }
248
381
  }
249
382
 
383
+ enqueueControllerMutation(sessionId, client, operation) {
384
+ // Admission is fenced before queueing. Once a newer controller starts its
385
+ // handoff, no additional work from the retiring controller may enter the
386
+ // mutation set; work admitted before that cut is allowed to finish.
387
+ try {
388
+ this.assertActiveController(client);
389
+ if (this.controllerHandoff) {
390
+ throw new Error('Native pty controller handoff is in progress');
391
+ }
392
+ if (this.rotationPreparation) {
393
+ throw new Error('Native pty host is frozen for runtime rotation');
394
+ }
395
+ } catch (error) {
396
+ return Promise.reject(error);
397
+ }
398
+ const admission = Object.freeze({
399
+ [CONTROLLER_MUTATION_ADMISSION]: true,
400
+ client,
401
+ controllerId: client.controllerId,
402
+ controllerGeneration: client.controllerGeneration,
403
+ });
404
+ const key = typeof sessionId === 'string' && sessionId ? sessionId : '__host__';
405
+ const previous = this.sessionMutationQueues.get(key) || Promise.resolve();
406
+ const next = previous
407
+ .catch(() => {})
408
+ .then(() => {
409
+ if (this.rotationPreparation) {
410
+ throw new Error('Native pty host is frozen for runtime rotation');
411
+ }
412
+ return operation(admission);
413
+ });
414
+ this.activeControllerMutations.add(next);
415
+ this.sessionMutationQueues.set(key, next);
416
+ const cleanup = () => {
417
+ this.activeControllerMutations.delete(next);
418
+ if (this.sessionMutationQueues.get(key) === next) {
419
+ this.sessionMutationQueues.delete(key);
420
+ }
421
+ };
422
+ next.then(cleanup, cleanup);
423
+ return next;
424
+ }
425
+
250
426
  send(client, message) {
251
- if (!client || !client.socket || client.socket.destroyed) return;
252
- client.socket.write(`${JSON.stringify(message)}\n`);
427
+ this.writeClientMessage(client, `${JSON.stringify(message)}\n`);
428
+ }
429
+
430
+ disconnectSlowClient(client, reason) {
431
+ if (!client || !client.socket || client.socket.destroyed) return false;
432
+ client.socket.destroy(new Error(reason));
433
+ return true;
434
+ }
435
+
436
+ writeClientMessage(client, message) {
437
+ if (!client || !client.socket || client.socket.destroyed) return false;
438
+ if (client.socket.writableLength > this.clientMaxBufferedBytes) {
439
+ this.disconnectSlowClient(client, 'native pty client backpressure');
440
+ return false;
441
+ }
442
+ const accepted = client.socket.write(message);
443
+ if (!accepted && client.socket.writableLength > this.clientMaxBufferedBytes) {
444
+ this.disconnectSlowClient(client, 'native pty client backpressure');
445
+ return false;
446
+ }
447
+ return true;
253
448
  }
254
449
 
255
450
  broadcast(event, payload) {
256
451
  const message = `${JSON.stringify({ event, payload })}\n`;
257
452
  for (const client of this.clients) {
258
- if (client.socket.destroyed) continue;
259
- client.socket.write(message);
453
+ this.writeClientMessage(client, message);
260
454
  }
261
455
  }
262
456
 
@@ -264,7 +458,88 @@ class NativePtyHost {
264
458
  this.broadcast(event, payload);
265
459
  }
266
460
 
461
+ registerController(client, rawIdentity) {
462
+ if (!client || client.disconnected === true) {
463
+ throw new Error('Native pty controller client is disconnected');
464
+ }
465
+ const identity = {
466
+ id: typeof rawIdentity.id === 'string' ? rawIdentity.id : '',
467
+ generation: Math.floor(Number(rawIdentity.generation)),
468
+ };
469
+ if (!identity.id || !Number.isFinite(identity.generation) || identity.generation <= 0) {
470
+ throw new Error('Invalid native pty controller identity');
471
+ }
472
+
473
+ const registration = this.controllerRegistrationQueue
474
+ .catch(() => {})
475
+ .then(async () => {
476
+ const current = this.activeControllerIdentity;
477
+ if (
478
+ current &&
479
+ (identity.generation < current.generation ||
480
+ (identity.generation === current.generation && current.id !== identity.id))
481
+ ) {
482
+ throw new Error('Stale native pty controller');
483
+ }
484
+
485
+ const replacesController = !current
486
+ || current.id !== identity.id
487
+ || current.generation !== identity.generation
488
+ || this.activeControllerClient !== client;
489
+ if (replacesController && current) {
490
+ const handoff = {
491
+ fromClient: this.activeControllerClient,
492
+ fromIdentity: current,
493
+ toClient: client,
494
+ toIdentity: identity,
495
+ };
496
+ this.controllerHandoff = handoff;
497
+ try {
498
+ // New admissions from the retiring controller are now closed.
499
+ // Drain the exact set admitted before that cut before publishing
500
+ // the new generation as active.
501
+ await Promise.allSettled([...this.activeControllerMutations]);
502
+ } finally {
503
+ if (this.controllerHandoff === handoff) this.controllerHandoff = null;
504
+ }
505
+ }
506
+
507
+ if (client.disconnected === true) {
508
+ throw new Error('Native pty controller client is disconnected');
509
+ }
510
+
511
+ this.activeControllerIdentity = identity;
512
+ this.activeControllerClient = client;
513
+ client.controllerId = identity.id;
514
+ client.controllerGeneration = identity.generation;
515
+ return {
516
+ registered: true,
517
+ controllerId: identity.id,
518
+ controllerGeneration: identity.generation,
519
+ };
520
+ });
521
+ this.controllerRegistrationQueue = registration;
522
+ return registration;
523
+ }
524
+
525
+ assertActiveController(client) {
526
+ if (client?.[CONTROLLER_MUTATION_ADMISSION] === true) return;
527
+ if (
528
+ !client ||
529
+ client.disconnected === true ||
530
+ client !== this.activeControllerClient ||
531
+ !this.activeControllerIdentity ||
532
+ client.controllerId !== this.activeControllerIdentity.id ||
533
+ client.controllerGeneration !== this.activeControllerIdentity.generation
534
+ ) {
535
+ throw new Error('Native pty mutation requires the active controller');
536
+ }
537
+ }
538
+
267
539
  async createSession(options) {
540
+ if (this.rotationPreparation) {
541
+ throw new Error('Native pty host is frozen for runtime rotation');
542
+ }
268
543
  this.cancelIdleExit();
269
544
  const normalized = options.shellIntegrationPrepared === true
270
545
  ? {
@@ -286,9 +561,31 @@ class NativePtyHost {
286
561
  return { sessionId: agentId, status: existing.status };
287
562
  }
288
563
 
289
- const cols = normalized.cols || DEFAULT_COLS;
290
- const rows = normalized.rows || DEFAULT_ROWS;
291
- const screenWorker = await this.screenWorkerPool.acquire({ cols, rows });
564
+ const reviveState = options.reviveState
565
+ ? normalizeTerminalStateEntry(options.reviveState)
566
+ : null;
567
+ const reviveEvent = reviveState?.replayEvent?.events?.[0] || null;
568
+ const cols = reviveEvent?.cols || normalized.cols || DEFAULT_COLS;
569
+ const rows = reviveEvent?.rows || normalized.rows || DEFAULT_ROWS;
570
+ const runtimeGeneration = await allocateNativePtyRuntimeGeneration(this.configDir);
571
+ const runtimeEpoch = formatNativePtyRuntimeEpoch(runtimeGeneration);
572
+ const screenWorker = await this.screenWorkerPool.acquire({ cols, rows, runtimeEpoch });
573
+ const replayText = reviveEvent?.data || '';
574
+ const restoredOutput = reviveState
575
+ ? `${replayText}${TERMINAL_HISTORY_RESTORED_MESSAGE}`
576
+ : '';
577
+ let restoredScreenState = null;
578
+ if (restoredOutput) {
579
+ try {
580
+ await screenWorker.append(restoredOutput, 1, 1);
581
+ restoredScreenState = await screenWorker.getState();
582
+ } catch (error) {
583
+ await screenWorker.dispose().catch(() => {});
584
+ throw new Error(`Failed to restore serialized terminal history: ${error.message}`, {
585
+ cause: error,
586
+ });
587
+ }
588
+ }
292
589
  const metadata = {
293
590
  ...(normalized.metadata || {}),
294
591
  protocolVersion: 1,
@@ -321,14 +618,20 @@ class NativePtyHost {
321
618
  cwd: normalized.cwd,
322
619
  metadata,
323
620
  process: ptyProcess,
324
- output: '',
325
- outputSeq: 0,
326
- renderOutput: '',
327
- previewText: '',
328
- previewSnapshot: null,
329
- previewCols: cols,
330
- previewRows: rows,
331
- title: '',
621
+ output: trimOutput(restoredOutput),
622
+ outputSeq: restoredOutput ? 1 : 0,
623
+ stateRevision: restoredOutput ? 1 : 0,
624
+ runtimeEpoch,
625
+ runtimeGeneration,
626
+ stateProofAvailable: true,
627
+ reducerFlowControl: createTerminalReducerFlowControl(),
628
+ reducerCommitQueue: Promise.resolve(),
629
+ renderOutput: restoredScreenState?.renderOutput || restoredOutput,
630
+ previewText: restoredScreenState?.previewText || restoredOutput,
631
+ previewSnapshot: restoredScreenState?.previewSnapshot || null,
632
+ previewCols: restoredScreenState?.cols || cols,
633
+ previewRows: restoredScreenState?.rows || rows,
634
+ title: restoredScreenState?.title || '',
332
635
  status: 'running',
333
636
  terminalBusy: null,
334
637
  shellCwd: '',
@@ -355,7 +658,24 @@ class NativePtyHost {
355
658
  sessionId: agentId,
356
659
  status: session.status,
357
660
  startedAt: session.startedAt,
661
+ runtimeEpoch: session.runtimeEpoch,
662
+ outputSeq: session.outputSeq,
663
+ stateRevision: session.stateRevision,
358
664
  });
665
+ if (restoredOutput) {
666
+ this.emitSessionEvent('session-sync', {
667
+ sessionId: agentId,
668
+ output: session.renderOutput,
669
+ textOutput: session.output,
670
+ replaceLive: true,
671
+ runtimeEpoch: session.runtimeEpoch,
672
+ outputSeq: session.outputSeq,
673
+ stateRevision: session.stateRevision,
674
+ cols: session.previewCols,
675
+ rows: session.previewRows,
676
+ revived: true,
677
+ });
678
+ }
359
679
 
360
680
  return { sessionId: agentId, status: session.status };
361
681
  }
@@ -363,7 +683,7 @@ class NativePtyHost {
363
683
  bindScreenWorker(session) {
364
684
  session.screenWorker.on('preview', ({ previewText, title, cols, rows, previewSnapshot }) => {
365
685
  const current = this.sessions.get(session.id);
366
- if (!current) return;
686
+ if (current !== session) return;
367
687
 
368
688
  current.previewText = previewText || '';
369
689
  current.previewSnapshot = previewSnapshot || null;
@@ -375,6 +695,7 @@ class NativePtyHost {
375
695
  this.emitSessionEvent('session-title', {
376
696
  sessionId: current.id,
377
697
  title: current.title,
698
+ runtimeEpoch: current.runtimeEpoch,
378
699
  });
379
700
  }
380
701
 
@@ -384,34 +705,60 @@ class NativePtyHost {
384
705
  cols: current.previewCols,
385
706
  rows: current.previewRows,
386
707
  previewSnapshot: current.previewSnapshot,
708
+ runtimeEpoch: current.runtimeEpoch,
387
709
  });
388
710
  });
389
711
 
390
712
  session.screenWorker.on('error', error => {
391
- this.emitSessionEvent('session-error', {
392
- sessionId: session.id,
393
- error: `Failed to update terminal screen state: ${error.message}`,
394
- fatal: false,
395
- });
713
+ this.failSessionScreenState(session, error);
714
+ });
715
+ }
716
+
717
+ failSessionScreenState(session, error) {
718
+ const current = this.sessions.get(session.id);
719
+ if (current !== session || current.stateProofAvailable === false) return;
720
+ current.stateProofAvailable = false;
721
+ const message = error instanceof Error ? error.message : String(error || 'unknown reducer failure');
722
+ this.emitSessionEvent('session-error', {
723
+ sessionId: current.id,
724
+ error: `Terminal state reducer failed: ${message}`,
725
+ fatal: true,
726
+ runtimeEpoch: current.runtimeEpoch,
396
727
  });
728
+ try {
729
+ current.process?.kill();
730
+ } catch {
731
+ // The session is already unusable because its authoritative reducer failed.
732
+ }
397
733
  }
398
734
 
399
735
  bindPty(session) {
400
- session.process.onData(data => this.handleSessionData(session.id, data));
736
+ session.process.onData(data => this.handleSessionData(session.id, data, session));
401
737
  session.process.onExit(({ code }) => {
402
- this.handleSessionExit(session.id, code).catch(error => {
738
+ this.handleSessionExit(session.id, code, session).catch(error => {
739
+ if (this.sessions.get(session.id) !== session) return;
403
740
  this.emitSessionEvent('session-error', {
404
741
  sessionId: session.id,
405
742
  error: error.message,
406
743
  fatal: false,
744
+ runtimeEpoch: session.runtimeEpoch,
407
745
  });
408
746
  });
409
747
  });
410
748
  }
411
749
 
412
- handleSessionData(sessionId, rawData) {
750
+ handleSessionData(sessionId, rawData, expectedSession = null) {
413
751
  const current = this.sessions.get(sessionId);
414
- if (!current) return;
752
+ if (
753
+ !current ||
754
+ (expectedSession && current !== expectedSession) ||
755
+ current.stateProofAvailable === false
756
+ ) return;
757
+ if (!acceptTerminalExitData(current)) {
758
+ current.finalCheckpoint = null;
759
+ this.failSessionScreenState(current, new Error('PTY emitted data after its final checkpoint cut'));
760
+ return;
761
+ }
415
762
 
416
763
  const busyState = parseShellBusyMarkers(rawData, current.terminalBusy, current.shellBusyMarkerPending);
417
764
  current.shellBusyMarkerPending = busyState.pending;
@@ -460,13 +807,26 @@ class NativePtyHost {
460
807
  shellLastCommandDurationMs: current.shellLastCommandDurationMs,
461
808
  statusMarkerSeen: busyState.statusMarkerSeen,
462
809
  busyMarkerSeen: busyState.busyMarkerSeen,
810
+ runtimeEpoch: current.runtimeEpoch,
463
811
  });
464
812
  }
465
813
 
466
814
  const data = busyState.data;
467
815
  if (!data) return;
468
816
 
817
+ const reducerDelivery = enqueueTerminalReducerData(
818
+ ensureTerminalReducerFlowControl(current),
819
+ current.process,
820
+ data,
821
+ );
822
+ if (reducerDelivery.error) {
823
+ this.failSessionScreenState(current, reducerDelivery.error);
824
+ return;
825
+ }
469
826
  current.outputSeq += 1;
827
+ current.stateRevision += 1;
828
+ const outputSeq = current.outputSeq;
829
+ const stateRevision = current.stateRevision;
470
830
  current.output = trimOutput(current.output + data);
471
831
  current.lastActivityAt = Date.now();
472
832
 
@@ -476,46 +836,85 @@ class NativePtyHost {
476
836
  this.emitSessionEvent('session-title', {
477
837
  sessionId,
478
838
  title: current.title,
839
+ runtimeEpoch: current.runtimeEpoch,
479
840
  });
480
841
  }
481
842
 
482
- this.emitSessionEvent('session-output', {
483
- sessionId,
484
- data,
485
- outputSeq: current.outputSeq,
486
- });
487
- this.emitSessionEvent('session-activity', {
488
- sessionId,
489
- lastActivityAt: current.lastActivityAt,
490
- });
491
-
492
843
  if (current.screenWorker) {
493
- current.screenWorker.append(data);
844
+ const commit = current.screenWorker.append(data, stateRevision, outputSeq);
845
+ current.reducerCommitQueue = current.reducerCommitQueue.then(() => commit).then(() => {
846
+ const latest = this.sessions.get(sessionId);
847
+ if (latest !== current || latest.stateProofAvailable === false) return;
848
+ const flowError = acknowledgeTerminalReducerData(
849
+ ensureTerminalReducerFlowControl(latest),
850
+ latest.process,
851
+ reducerDelivery.bytes,
852
+ );
853
+ if (flowError) {
854
+ this.failSessionScreenState(latest, flowError);
855
+ return;
856
+ }
857
+ this.emitSessionEvent('session-output', {
858
+ sessionId,
859
+ data,
860
+ runtimeEpoch: latest.runtimeEpoch,
861
+ outputSeq,
862
+ stateRevision,
863
+ });
864
+ this.emitSessionEvent('session-activity', {
865
+ sessionId,
866
+ lastActivityAt: latest.lastActivityAt,
867
+ runtimeEpoch: latest.runtimeEpoch,
868
+ });
869
+ }).catch(error => this.failSessionScreenState(current, error));
494
870
  }
495
871
  }
496
872
 
497
- async handleSessionExit(sessionId, code) {
873
+ handleSessionExit(sessionId, code, expectedSession = null) {
498
874
  const session = this.sessions.get(sessionId);
499
- if (!session || session.status === 'exited') return;
875
+ if (
876
+ !session ||
877
+ (expectedSession && session !== expectedSession) ||
878
+ session.status === 'exited'
879
+ ) return Promise.resolve();
880
+ if (session.exitFinalizationPromise) return session.exitFinalizationPromise;
881
+ const finalization = this.finalizeSessionExit(sessionId, code, session);
882
+ session.exitFinalizationPromise = finalization;
883
+ return finalization;
884
+ }
500
885
 
501
- let screenState = null;
502
- if (session.screenWorker) {
503
- screenState = await session.screenWorker.getState().catch(() => null);
504
- }
505
- if (screenState) {
506
- session.renderOutput = screenState.renderOutput || session.renderOutput;
507
- session.previewText = screenState.previewText || session.previewText || session.output.slice(-2000);
508
- session.previewSnapshot = screenState.previewSnapshot || session.previewSnapshot;
509
- session.previewCols = screenState.cols || session.previewCols;
510
- session.previewRows = screenState.rows || session.previewRows;
511
- session.title = screenState.title || session.title;
886
+ async finalizeSessionExit(sessionId, code, session) {
887
+ const quiesced = await waitForTerminalExitDataQuiescence(session, {
888
+ flushMs: this.terminalExitDataFlushMs,
889
+ isCurrent: () => this.sessions.get(sessionId) === session,
890
+ });
891
+ if (!quiesced) return;
892
+
893
+ await Promise.resolve(session.reducerCommitQueue).catch(() => {});
894
+ if (this.sessions.get(sessionId) !== session) return;
895
+ const finalCheckpoint = await captureTerminalAttachCheckpoint(session, { requireCurrentCut: true });
896
+ if (this.sessions.get(sessionId) !== session) return;
897
+ if (finalCheckpoint) {
898
+ session.finalCheckpoint = Object.freeze({ ...finalCheckpoint });
899
+ session.renderOutput = finalCheckpoint.renderOutput;
900
+ session.previewText = finalCheckpoint.previewText;
901
+ session.previewSnapshot = finalCheckpoint.previewSnapshot;
902
+ session.previewCols = finalCheckpoint.cols;
903
+ session.previewRows = finalCheckpoint.rows;
904
+ session.title = finalCheckpoint.title || session.title;
905
+ } else {
906
+ this.failSessionScreenState(session, new Error('Unable to capture the exact final Terminal checkpoint'));
512
907
  }
513
908
 
514
909
  session.status = 'exited';
515
910
  session.exitedAt = Date.now();
911
+ resetTerminalReducerFlowControl(
912
+ ensureTerminalReducerFlowControl(session),
913
+ session.process,
914
+ );
516
915
  cleanupShellBusyIntegration(session.shellBusyIntegration);
517
916
  if (session.screenWorker) {
518
- session.screenWorker.dispose().catch(() => {});
917
+ await session.screenWorker.dispose().catch(() => {});
519
918
  session.screenWorker = null;
520
919
  }
521
920
 
@@ -523,107 +922,226 @@ class NativePtyHost {
523
922
  sessionId,
524
923
  code: code == null ? 'unknown' : code,
525
924
  exitedAt: session.exitedAt,
925
+ runtimeEpoch: session.runtimeEpoch,
926
+ outputSeq: session.outputSeq,
927
+ stateRevision: session.stateRevision,
928
+ stateProofAvailable: session.stateProofAvailable !== false,
526
929
  });
527
- this.emitSessionEvent('session-preview', {
528
- sessionId,
529
- previewText: session.previewText,
530
- cols: session.previewCols,
531
- rows: session.previewRows,
532
- previewSnapshot: session.previewSnapshot,
533
- });
930
+ if (finalCheckpoint) {
931
+ this.emitSessionEvent('session-preview', {
932
+ sessionId,
933
+ previewText: session.previewText,
934
+ cols: session.previewCols,
935
+ rows: session.previewRows,
936
+ previewSnapshot: session.previewSnapshot,
937
+ runtimeEpoch: session.runtimeEpoch,
938
+ });
939
+ }
534
940
  this.scheduleIdleExitIfUnused();
535
941
  }
536
942
 
537
- async sendInput(sessionId, input) {
943
+ async sendInput(sessionId, input, expectedRuntimeEpoch = '') {
538
944
  const session = this.sessions.get(sessionId);
539
- if (!session || !session.process || session.status === 'exited') {
945
+ if (!session || !session.process || session.status === 'exited' || session.exitFinalizing === true) {
540
946
  throw new Error('Session not available');
541
947
  }
948
+ if (session.rotationFrozen === true) {
949
+ throw new Error('Terminal session is frozen for native PTY host rotation');
950
+ }
951
+ if (expectedRuntimeEpoch && expectedRuntimeEpoch !== session.runtimeEpoch) {
952
+ return { status: 'input-rejected', reason: 'runtime-epoch-mismatch' };
953
+ }
542
954
  session.process.write(terminalInputToPtyString(input));
543
955
  session.lastActivityAt = Date.now();
544
956
  this.emitSessionEvent('session-activity', {
545
957
  sessionId,
546
958
  lastActivityAt: session.lastActivityAt,
959
+ runtimeEpoch: session.runtimeEpoch,
547
960
  });
548
961
  return { sent: true };
549
962
  }
550
963
 
964
+ async getSessionAttachCheckpoint(sessionId, client) {
965
+ this.assertActiveController(client);
966
+ const session = this.sessions.get(sessionId);
967
+ if (!session) return null;
968
+ const checkpoint = await captureTerminalAttachCheckpoint(session);
969
+ return this.sessions.get(sessionId) === session ? checkpoint : null;
970
+ }
971
+
551
972
  async resizeSession(sessionId, cols, rows) {
552
973
  const session = this.sessions.get(sessionId);
553
- if (!session || !session.process || session.status === 'exited') {
554
- return { resized: false };
974
+ if (!session || !session.process || session.status === 'exited' || session.exitFinalizing === true) {
975
+ return { status: 'resize-rejected', reason: 'session-unavailable', resized: false };
976
+ }
977
+ if (session.rotationFrozen === true) {
978
+ return { status: 'resize-rejected', reason: 'runtime-rotation', resized: false };
555
979
  }
556
980
 
557
981
  const nextCols = normalizePositiveInteger(cols, session.previewCols || DEFAULT_COLS, 1, 1000);
558
982
  const nextRows = normalizePositiveInteger(rows, session.previewRows || DEFAULT_ROWS, 1, 1000);
559
- session.process.resize(nextCols, nextRows);
983
+ if (nextCols === session.previewCols && nextRows === session.previewRows) {
984
+ return {
985
+ status: 'resize-committed',
986
+ resized: true,
987
+ unchanged: true,
988
+ };
989
+ }
990
+
991
+ try {
992
+ session.process.resize(nextCols, nextRows);
993
+ } catch (error) {
994
+ return {
995
+ status: 'resize-rejected',
996
+ reason: 'pty-resize-failed',
997
+ resized: false,
998
+ error: error instanceof Error ? error.message : String(error || 'unknown PTY resize failure'),
999
+ };
1000
+ }
1001
+ session.stateRevision += 1;
1002
+ const stateRevision = session.stateRevision;
1003
+ const outputSeq = session.outputSeq;
1004
+ const runtimeEpoch = session.runtimeEpoch;
560
1005
  session.previewCols = nextCols;
561
1006
  session.previewRows = nextRows;
562
1007
 
563
1008
  if (session.screenWorker) {
564
- try {
565
- const screenState = await session.screenWorker.resize(nextCols, nextRows);
1009
+ const reducerCommit = session.screenWorker.resize(nextCols, nextRows, stateRevision);
1010
+ const publishedCommit = session.reducerCommitQueue.then(() => reducerCommit).then((screenState) => {
1011
+ if (this.sessions.get(sessionId) !== session) {
1012
+ throw new Error('Terminal session was replaced during resize');
1013
+ }
1014
+ if (
1015
+ screenState.runtimeEpoch !== runtimeEpoch ||
1016
+ screenState.outputSeq !== outputSeq ||
1017
+ screenState.stateRevision !== stateRevision
1018
+ ) {
1019
+ throw new Error('Terminal screen resize returned a non-authoritative state cut');
1020
+ }
566
1021
  session.previewText = screenState.previewText || '';
567
1022
  session.previewSnapshot = screenState.previewSnapshot || session.previewSnapshot;
568
- session.renderOutput = screenState.renderOutput || session.renderOutput;
1023
+ session.renderOutput = typeof screenState.renderOutput === 'string'
1024
+ ? screenState.renderOutput
1025
+ : session.renderOutput;
569
1026
  session.previewCols = screenState.cols || nextCols;
570
1027
  session.previewRows = screenState.rows || nextRows;
571
1028
  session.title = screenState.title || session.title;
572
- } catch (error) {
573
- this.emitSessionEvent('session-error', {
1029
+ this.emitSessionEvent('session-transition', {
574
1030
  sessionId,
575
- error: `Failed to resize terminal screen state: ${error.message}`,
576
- fatal: false,
1031
+ kind: 'resize',
1032
+ data: '',
1033
+ runtimeEpoch: screenState.runtimeEpoch,
1034
+ outputSeq: screenState.outputSeq,
1035
+ stateRevision: screenState.stateRevision,
1036
+ cols: screenState.cols,
1037
+ rows: screenState.rows,
577
1038
  });
1039
+ return screenState;
1040
+ });
1041
+ session.reducerCommitQueue = publishedCommit.catch(error => {
1042
+ this.failSessionScreenState(session, error);
1043
+ });
1044
+ try {
1045
+ await publishedCommit;
1046
+ } catch {
1047
+ return { status: 'resize-rejected', reason: 'screen-reducer-failed', resized: false };
578
1048
  }
579
1049
  }
1050
+ if (this.sessions.get(sessionId) !== session) {
1051
+ return { status: 'resize-rejected', reason: 'session-replaced', resized: false };
1052
+ }
580
1053
 
581
- return { resized: true, cols: session.previewCols, rows: session.previewRows };
1054
+ return {
1055
+ status: 'resize-committed',
1056
+ resized: true,
1057
+ unchanged: false,
1058
+ runtimeEpoch,
1059
+ outputSeq,
1060
+ stateRevision,
1061
+ cols: nextCols,
1062
+ rows: nextRows,
1063
+ };
582
1064
  }
583
1065
 
584
- async clearBuffer(sessionId) {
1066
+ async clearBuffer(sessionId, expectedRuntimeEpoch = '') {
585
1067
  const session = this.sessions.get(sessionId);
586
- if (!session) {
1068
+ if (!session || session.status === 'exited' || session.exitFinalizing === true) {
587
1069
  return { cleared: false };
588
1070
  }
1071
+ if (session.rotationFrozen === true) {
1072
+ return { cleared: false, reason: 'runtime-rotation' };
1073
+ }
1074
+ if (expectedRuntimeEpoch && expectedRuntimeEpoch !== session.runtimeEpoch) {
1075
+ return { cleared: false, reason: 'runtime-epoch-mismatch' };
1076
+ }
589
1077
 
590
1078
  session.output = '';
591
- session.outputSeq = (session.outputSeq || 0) + 1;
1079
+ session.stateRevision += 1;
1080
+ const stateRevision = session.stateRevision;
1081
+ const outputSeq = session.outputSeq;
1082
+ const runtimeEpoch = session.runtimeEpoch;
592
1083
  session.renderOutput = '';
593
1084
  session.previewText = '';
594
1085
  session.previewSnapshot = null;
595
1086
  session.lastActivityAt = Date.now();
596
1087
 
1088
+ let exactState = {
1089
+ renderOutput: '',
1090
+ runtimeEpoch,
1091
+ outputSeq,
1092
+ stateRevision,
1093
+ cols: session.previewCols,
1094
+ rows: session.previewRows,
1095
+ };
597
1096
  if (session.screenWorker) {
598
- try {
599
- const screenState = await session.screenWorker.clear();
600
- session.renderOutput = screenState.renderOutput || '';
1097
+ const reducerCommit = session.screenWorker.clear(stateRevision, outputSeq);
1098
+ const publishedCommit = session.reducerCommitQueue.then(() => reducerCommit).then((screenState) => {
1099
+ if (this.sessions.get(sessionId) !== session) {
1100
+ throw new Error('Terminal session was replaced during clear');
1101
+ }
1102
+ if (
1103
+ screenState.runtimeEpoch !== runtimeEpoch ||
1104
+ screenState.outputSeq !== outputSeq ||
1105
+ screenState.stateRevision !== stateRevision
1106
+ ) {
1107
+ throw new Error('Terminal screen clear returned a non-authoritative state cut');
1108
+ }
1109
+ session.renderOutput = typeof screenState.renderOutput === 'string' ? screenState.renderOutput : '';
601
1110
  session.previewText = screenState.previewText || '';
602
1111
  session.previewSnapshot = screenState.previewSnapshot || null;
603
1112
  session.previewCols = screenState.cols || session.previewCols;
604
1113
  session.previewRows = screenState.rows || session.previewRows;
1114
+ exactState = screenState;
605
1115
  if (screenState.title && screenState.title !== session.title) {
606
1116
  session.title = screenState.title;
607
1117
  this.emitSessionEvent('session-title', {
608
1118
  sessionId,
609
1119
  title: session.title,
1120
+ runtimeEpoch: session.runtimeEpoch,
610
1121
  });
611
1122
  }
612
- } catch (error) {
613
- this.emitSessionEvent('session-error', {
1123
+ this.emitSessionEvent('session-transition', {
614
1124
  sessionId,
615
- error: `Failed to clear terminal screen state: ${error.message}`,
616
- fatal: false,
1125
+ kind: 'clear',
1126
+ data: '\x1b[2J\x1b[3J\x1b[H',
1127
+ runtimeEpoch: screenState.runtimeEpoch,
1128
+ outputSeq: screenState.outputSeq,
1129
+ stateRevision: screenState.stateRevision,
1130
+ cols: screenState.cols,
1131
+ rows: screenState.rows,
617
1132
  });
1133
+ return screenState;
1134
+ });
1135
+ session.reducerCommitQueue = publishedCommit.catch(error => {
1136
+ this.failSessionScreenState(session, error);
1137
+ });
1138
+ try {
1139
+ await publishedCommit;
1140
+ } catch {
1141
+ return { cleared: false };
618
1142
  }
619
1143
  }
620
-
621
- this.emitSessionEvent('session-sync', {
622
- sessionId,
623
- output: session.renderOutput || session.output,
624
- outputSeq: session.outputSeq,
625
- replaceLive: true,
626
- });
1144
+ if (this.sessions.get(sessionId) !== session) return { cleared: false, reason: 'session-replaced' };
627
1145
  this.emitSessionEvent('session-preview', {
628
1146
  sessionId,
629
1147
  previewText: session.previewText,
@@ -631,12 +1149,21 @@ class NativePtyHost {
631
1149
  rows: session.previewRows,
632
1150
  previewSnapshot: session.previewSnapshot,
633
1151
  title: session.title,
1152
+ runtimeEpoch: session.runtimeEpoch,
634
1153
  });
635
1154
  this.emitSessionEvent('session-activity', {
636
1155
  sessionId,
637
1156
  lastActivityAt: session.lastActivityAt,
1157
+ runtimeEpoch: session.runtimeEpoch,
638
1158
  });
639
- return { cleared: true, outputSeq: session.outputSeq };
1159
+ return {
1160
+ cleared: true,
1161
+ runtimeEpoch: exactState.runtimeEpoch,
1162
+ outputSeq: exactState.outputSeq,
1163
+ stateRevision: exactState.stateRevision,
1164
+ cols: exactState.cols,
1165
+ rows: exactState.rows,
1166
+ };
640
1167
  }
641
1168
 
642
1169
  async killSession(sessionId) {
@@ -648,7 +1175,7 @@ class NativePtyHost {
648
1175
  session.process.kill('SIGTERM');
649
1176
  const timer = setTimeout(() => {
650
1177
  const latest = this.sessions.get(sessionId);
651
- if (!latest || latest.status === 'exited' || !latest.process) return;
1178
+ if (latest !== session || latest.status === 'exited' || !latest.process) return;
652
1179
  latest.process.kill('SIGKILL');
653
1180
  }, 1500);
654
1181
  if (typeof timer.unref === 'function') timer.unref();
@@ -658,23 +1185,34 @@ class NativePtyHost {
658
1185
  async getSessionState(sessionId) {
659
1186
  const session = this.sessions.get(sessionId);
660
1187
  if (!session) return null;
661
- const screenState = session.screenWorker
662
- ? await session.screenWorker.getState().catch(() => null)
663
- : null;
664
-
665
- const title = (screenState && screenState.title) || session.title;
666
- const previewText = (screenState && screenState.previewText) || session.previewText || session.output.slice(-2000);
1188
+ // Cut the raw output before the async worker request. The screen worker
1189
+ // receives every append with its output sequence and reports the exact
1190
+ // sequence represented by its serialized terminal state.
1191
+ const snapshotOutput = session.output;
1192
+ const fallbackPreviewText = session.previewText || snapshotOutput.slice(-2000);
1193
+ const fallbackPreviewSnapshot = session.previewSnapshot;
1194
+ const fallbackPreviewCols = session.previewCols;
1195
+ const fallbackPreviewRows = session.previewRows;
1196
+ const fallbackTitle = session.title;
1197
+ const stateProofAvailable = session.stateProofAvailable !== false;
1198
+ const checkpoint = stateProofAvailable ? await captureTerminalAttachCheckpoint(session) : null;
1199
+ if (this.sessions.get(sessionId) !== session) return null;
1200
+ const title = checkpoint ? checkpoint.title : fallbackTitle;
1201
+ const previewText = checkpoint ? checkpoint.previewText : (stateProofAvailable ? fallbackPreviewText : '');
667
1202
 
668
1203
  return {
669
1204
  sessionId: session.id,
670
1205
  status: session.status,
671
- output: session.output,
672
- outputSeq: session.outputSeq || 0,
673
- renderOutput: (screenState && screenState.renderOutput) || session.renderOutput || session.output,
1206
+ runtimeEpoch: session.runtimeEpoch,
1207
+ output: snapshotOutput,
1208
+ outputSeq: checkpoint?.outputSeq ?? null,
1209
+ stateRevision: checkpoint?.stateRevision ?? null,
1210
+ stateProofAvailable,
1211
+ renderOutput: checkpoint ? checkpoint.renderOutput : (stateProofAvailable ? snapshotOutput : ''),
674
1212
  previewText,
675
- previewSnapshot: (screenState && screenState.previewSnapshot) || session.previewSnapshot,
676
- previewCols: (screenState && screenState.cols) || session.previewCols,
677
- previewRows: (screenState && screenState.rows) || session.previewRows,
1213
+ previewSnapshot: checkpoint ? checkpoint.previewSnapshot : (stateProofAvailable ? fallbackPreviewSnapshot : null),
1214
+ previewCols: checkpoint ? checkpoint.cols : fallbackPreviewCols,
1215
+ previewRows: checkpoint ? checkpoint.rows : fallbackPreviewRows,
678
1216
  title,
679
1217
  lastActivityAt: session.lastActivityAt,
680
1218
  startedAt: session.startedAt,
@@ -723,6 +1261,137 @@ class NativePtyHost {
723
1261
  return recovered;
724
1262
  }
725
1263
 
1264
+ serializeTerminalState(client = null) {
1265
+ if (client) this.assertActiveController(client);
1266
+ if (this.rotationPreparation) {
1267
+ if (
1268
+ client &&
1269
+ this.rotationPreparation.controllerClient &&
1270
+ this.rotationPreparation.controllerClient !== client
1271
+ ) {
1272
+ throw new Error('Native pty host rotation is owned by another controller');
1273
+ }
1274
+ return this.rotationPreparation.promise;
1275
+ }
1276
+
1277
+ const preparation = {
1278
+ token: crypto.randomUUID(),
1279
+ controllerClient: client,
1280
+ phase: 'preparing',
1281
+ promise: null,
1282
+ serializedTerminalState: '',
1283
+ };
1284
+ this.rotationPreparation = preparation;
1285
+ preparation.promise = this.prepareSerializedTerminalState(preparation);
1286
+ return preparation.promise;
1287
+ }
1288
+
1289
+ async prepareSerializedTerminalState(preparation) {
1290
+ const entries = [];
1291
+ try {
1292
+ await Promise.allSettled([...this.activeControllerMutations]);
1293
+ if (this.rotationPreparation !== preparation) {
1294
+ throw new Error('Native pty host rotation preparation was cancelled');
1295
+ }
1296
+
1297
+ const liveSessions = [...this.sessions.values()]
1298
+ .filter(session => session.status === 'running' && session.stateProofAvailable !== false);
1299
+ for (const session of liveSessions) {
1300
+ session.rotationFrozen = true;
1301
+ const flowError = setTerminalExternalFlowControlBlocked(
1302
+ session.reducerFlowControl,
1303
+ session.process,
1304
+ true,
1305
+ );
1306
+ if (flowError) throw flowError;
1307
+ }
1308
+
1309
+ for (const session of liveSessions) {
1310
+ await session.reducerCommitQueue;
1311
+ const state = await this.getSessionState(session.id);
1312
+ const current = this.sessions.get(session.id);
1313
+ if (
1314
+ current !== session ||
1315
+ session.status !== 'running' ||
1316
+ state?.status !== 'running'
1317
+ ) {
1318
+ continue;
1319
+ }
1320
+ if (
1321
+ !state ||
1322
+ typeof state.renderOutput !== 'string' ||
1323
+ !Number.isFinite(state.outputSeq) ||
1324
+ !Number.isFinite(state.stateRevision)
1325
+ ) {
1326
+ throw new Error(`Cannot serialize terminal ${session.id} without an exact reducer checkpoint`);
1327
+ }
1328
+ entries.push({
1329
+ id: session.id,
1330
+ metadata: session.metadata || {},
1331
+ processDetails: {
1332
+ cwd: session.shellCwd || session.cwd || '',
1333
+ title: state.title || session.title || '',
1334
+ },
1335
+ processLaunchConfig: {
1336
+ command: session.command || '',
1337
+ args: session.args || [],
1338
+ category: session.metadata?.category || '',
1339
+ },
1340
+ replayEvent: {
1341
+ events: [{
1342
+ data: state.renderOutput,
1343
+ cols: state.previewCols || session.previewCols || DEFAULT_COLS,
1344
+ rows: state.previewRows || session.previewRows || DEFAULT_ROWS,
1345
+ }],
1346
+ },
1347
+ timestamp: Date.now(),
1348
+ });
1349
+ }
1350
+ const serializedTerminalState = serializeTerminalState(entries);
1351
+ preparation.phase = 'prepared';
1352
+ preparation.serializedTerminalState = serializedTerminalState;
1353
+ return {
1354
+ preparationToken: preparation.token,
1355
+ serializedTerminalState,
1356
+ };
1357
+ } catch (error) {
1358
+ this.resumePreparedTerminalState(preparation.token);
1359
+ throw error;
1360
+ }
1361
+ }
1362
+
1363
+ resumeTerminalState(client = null, preparationToken = '') {
1364
+ if (client) this.assertActiveController(client);
1365
+ return this.resumePreparedTerminalState(preparationToken);
1366
+ }
1367
+
1368
+ resumePreparedTerminalState(preparationToken = '') {
1369
+ const preparation = this.rotationPreparation;
1370
+ if (!preparation) return { resumed: 0 };
1371
+ if (preparationToken && preparation.token !== preparationToken) {
1372
+ throw new Error('Native pty rotation preparation token does not match');
1373
+ }
1374
+ let resumed = 0;
1375
+ for (const session of this.sessions.values()) {
1376
+ if (session.rotationFrozen !== true) continue;
1377
+ session.rotationFrozen = false;
1378
+ const flowError = setTerminalExternalFlowControlBlocked(
1379
+ session.reducerFlowControl,
1380
+ session.process,
1381
+ false,
1382
+ );
1383
+ if (flowError) {
1384
+ this.failSessionScreenState(session, flowError);
1385
+ continue;
1386
+ }
1387
+ resumed += 1;
1388
+ }
1389
+ if (this.rotationPreparation === preparation) {
1390
+ this.rotationPreparation = null;
1391
+ }
1392
+ return { resumed };
1393
+ }
1394
+
726
1395
  async updateSessionMetadata(sessionId, patch) {
727
1396
  const session = this.sessions.get(sessionId);
728
1397
  if (!session) return null;
@@ -735,9 +1404,39 @@ class NativePtyHost {
735
1404
  return session.metadata;
736
1405
  }
737
1406
 
738
- shutdownHost() {
1407
+ shutdownHost(client, rawIdentity = {}, preparationToken = '') {
1408
+ const requestedGeneration = Math.floor(Number(rawIdentity.generation));
1409
+ const requestedId = typeof rawIdentity.id === 'string' ? rawIdentity.id : '';
1410
+ const active = this.activeControllerIdentity;
1411
+ const activeClient = client
1412
+ && client === this.activeControllerClient
1413
+ && active
1414
+ && client.controllerId === active.id
1415
+ && client.controllerGeneration === active.generation;
1416
+ const newerController = requestedId
1417
+ && Number.isFinite(requestedGeneration)
1418
+ && requestedGeneration > 0
1419
+ && (!active || requestedGeneration > active.generation);
1420
+ if (!activeClient && !newerController) {
1421
+ throw new Error('Native pty shutdown requires the active or a newer controller');
1422
+ }
1423
+ const preparation = this.rotationPreparation;
1424
+ if (preparation) {
1425
+ if (
1426
+ preparation.phase !== 'prepared' ||
1427
+ !preparationToken ||
1428
+ preparationToken !== preparation.token
1429
+ ) {
1430
+ throw new Error('Native pty shutdown requires the prepared rotation token');
1431
+ }
1432
+ preparation.phase = 'committing';
1433
+ } else if (this.hasLiveSessions()) {
1434
+ throw new Error('Native pty shutdown requires a prepared terminal checkpoint');
1435
+ }
739
1436
  setImmediate(() => {
740
- this.dispose().finally(() => process.exit(0));
1437
+ this.dispose().finally(() => {
1438
+ if (this.exitOnShutdown) process.exit(0);
1439
+ });
741
1440
  });
742
1441
  return { shuttingDown: true };
743
1442
  }