farming-code 2.2.8 → 2.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +36 -23
  2. package/README.zh_cn.md +36 -23
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1482 -730
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +45 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +30 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/farming-net-pass.js +285 -0
  22. package/backend/farming-net-registry.js +112 -0
  23. package/backend/farming-net-server.js +298 -0
  24. package/backend/farming-session-store.js +5 -13
  25. package/backend/git-worktree-info.js +181 -0
  26. package/backend/local-session-engine.js +411 -186
  27. package/backend/main-page-session.js +5 -2
  28. package/backend/native-pty-controller-generation.js +106 -0
  29. package/backend/native-pty-host-client.js +275 -7
  30. package/backend/native-pty-host-identity.js +86 -0
  31. package/backend/native-pty-host.js +813 -114
  32. package/backend/native-session-engine.js +100 -28
  33. package/backend/packaged-node-pty.js +22 -2
  34. package/backend/provider-adapters.js +253 -0
  35. package/backend/provider-session-service.js +241 -0
  36. package/backend/runtime-observation.js +81 -0
  37. package/backend/server.js +294 -86
  38. package/backend/session-engine-bridge.js +21 -2
  39. package/backend/session-engine-router.js +1 -1
  40. package/backend/session-engine.js +1 -1
  41. package/backend/session-stream-protocol.js +185 -0
  42. package/backend/storage-layout.js +55 -0
  43. package/backend/terminal-attach-checkpoint.js +74 -0
  44. package/backend/terminal-exit-quiescence.js +39 -0
  45. package/backend/terminal-reducer-flow-control.js +97 -0
  46. package/backend/terminal-screen-state.js +11 -2
  47. package/backend/terminal-screen-worker-pool.js +59 -6
  48. package/backend/terminal-screen-worker-thread.js +97 -57
  49. package/backend/terminal-screen-worker.js +133 -51
  50. package/backend/terminal-state-serialization.js +127 -0
  51. package/backend/terminal-status.js +23 -4
  52. package/backend/usage-monitor.js +81 -4
  53. package/backend/workspace-directory.js +152 -0
  54. package/backend/workspace-file-router.js +182 -76
  55. package/backend/workspace-file-service.js +319 -4
  56. package/backend/workspace-root-registry.js +164 -0
  57. package/dist/assets/App-CHYpgECZ.js +208 -0
  58. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview--Cri80nQ.js} +92 -92
  59. package/dist/assets/FileEditorPane-DGJlEKdf.js +2 -0
  60. package/dist/assets/IconGlyphs-Cc14sepw.js +1 -0
  61. package/dist/assets/ProjectFilesSection-Cz1wG4NT.js +12 -0
  62. package/dist/assets/{ReviewPage-BaXu1ZdX.js → ReviewPage-LonRc6Fz.js} +1 -1
  63. package/dist/assets/code-dark-B1pAtO_P.css +1 -0
  64. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  65. package/dist/assets/{index-BrbljRqn.js → index-miO2r9IZ.js} +3 -3
  66. package/dist/assets/main-DNe0jPw-.css +1 -0
  67. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  68. package/dist/assets/workspace-editor-monaco-5gFbeDPF.js +1 -0
  69. package/dist/assets/workspace-editor-monaco-xPv8JzHH.js +4 -0
  70. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  71. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  72. package/dist/index.html +1 -1
  73. package/frontend/farming-net/app.css +625 -0
  74. package/frontend/farming-net/app.js +268 -0
  75. package/frontend/farming-net/index.html +86 -0
  76. package/frontend/reading-anchor.js +198 -0
  77. package/frontend/session-bridge.js +12 -3
  78. package/frontend/session-modal-bridge.js +5 -12
  79. package/frontend/skins/crt/app.js +1976 -793
  80. package/frontend/skins/crt/index.html +313 -23
  81. package/frontend/skins/crt/styles/billing.css +294 -223
  82. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  83. package/frontend/terminal-replay.js +372 -0
  84. package/package.json +10 -3
  85. package/shared/browser-protocol.d.ts +5 -0
  86. package/shared/browser-protocol.js +130 -0
  87. package/dist/assets/App-8dYAM6ql.js +0 -124
  88. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  89. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  90. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  91. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  92. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  93. package/dist/assets/main-D073SnW4.css +0 -1
  94. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  95. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  96. package/dist/assets/qoder-gHCinseV.svg +0 -1
  97. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  98. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  99. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  100. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  101. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  102. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -9,6 +9,25 @@ const {
9
9
  const { terminalInputToPtyString } = require('./input-parts');
10
10
  const { deriveTerminalStatus } = require('./terminal-status');
11
11
  const { normalizeInteractiveTerminalEnv } = require('./agent-env');
12
+ const {
13
+ allocateNativePtyRuntimeGeneration,
14
+ formatNativePtyRuntimeEpoch,
15
+ } = require('./native-pty-controller-generation');
16
+ const storageLayout = require('./storage-layout');
17
+ const {
18
+ acknowledgeTerminalReducerData,
19
+ createTerminalReducerFlowControl,
20
+ ensureTerminalReducerFlowControl,
21
+ enqueueTerminalReducerData,
22
+ resetTerminalReducerFlowControl,
23
+ } = require('./terminal-reducer-flow-control');
24
+ const {
25
+ captureTerminalAttachCheckpoint,
26
+ } = require('./terminal-attach-checkpoint');
27
+ const {
28
+ acceptTerminalExitData,
29
+ waitForTerminalExitDataQuiescence,
30
+ } = require('./terminal-exit-quiescence');
12
31
 
13
32
  const CONTROLLED_BASH_PROMPT = [
14
33
  '\\[\\e[90m\\]│\\[\\e[0m\\] ',
@@ -145,8 +164,9 @@ function normalizeShellSessionOptions(options) {
145
164
  }
146
165
 
147
166
  class LocalSessionEngine extends SessionEngine {
148
- constructor() {
167
+ constructor(options = {}) {
149
168
  super();
169
+ this.configDir = options.configDir || storageLayout.farmingConfigDir();
150
170
  this.sessions = new Map();
151
171
  this.screenWorkerPool = new TerminalScreenWorkerPool({
152
172
  size: 3,
@@ -162,9 +182,13 @@ class LocalSessionEngine extends SessionEngine {
162
182
  const normalized = normalizeShellSessionOptions(options);
163
183
  const previewCols = normalized.cols || 80;
164
184
  const previewRows = normalized.rows || 30;
185
+ const runtimeGeneration = await allocateNativePtyRuntimeGeneration(this.configDir);
186
+ const runtimeEpoch = formatNativePtyRuntimeEpoch(runtimeGeneration);
165
187
  const screenWorker = await this.screenWorkerPool.acquire({
166
188
  cols: previewCols,
167
189
  rows: previewRows,
190
+ runtimeEpoch,
191
+ runtimeGeneration,
168
192
  });
169
193
 
170
194
  let ptyProcess;
@@ -190,6 +214,11 @@ class LocalSessionEngine extends SessionEngine {
190
214
  process: ptyProcess,
191
215
  output: '',
192
216
  outputSeq: 0,
217
+ stateRevision: 0,
218
+ runtimeEpoch,
219
+ stateProofAvailable: true,
220
+ reducerFlowControl: createTerminalReducerFlowControl(),
221
+ reducerCommitQueue: Promise.resolve(),
193
222
  renderOutput: '',
194
223
  previewText: '',
195
224
  previewSnapshot: null,
@@ -216,7 +245,7 @@ class LocalSessionEngine extends SessionEngine {
216
245
 
217
246
  session.screenWorker.on('preview', ({ previewText, title, cols, rows, previewSnapshot }) => {
218
247
  const current = this.sessions.get(session.id);
219
- if (!current) return;
248
+ if (current !== session) return;
220
249
 
221
250
  current.previewText = previewText || '';
222
251
  current.previewSnapshot = previewSnapshot || null;
@@ -227,7 +256,8 @@ class LocalSessionEngine extends SessionEngine {
227
256
  current.title = title;
228
257
  this.emit('session-title', {
229
258
  sessionId: current.id,
230
- title: current.title
259
+ title: current.title,
260
+ runtimeEpoch: current.runtimeEpoch,
231
261
  });
232
262
  }
233
263
 
@@ -237,184 +267,275 @@ class LocalSessionEngine extends SessionEngine {
237
267
  cols: current.previewCols,
238
268
  rows: current.previewRows,
239
269
  previewSnapshot: current.previewSnapshot,
270
+ runtimeEpoch: current.runtimeEpoch,
240
271
  });
241
272
  });
242
273
 
243
274
  session.screenWorker.on('error', (error) => {
244
- this.emit('session-error', {
245
- sessionId: session.id,
246
- error: `Failed to update terminal screen state: ${error.message}`,
247
- fatal: false
248
- });
275
+ this.failSessionScreenState(session, error);
249
276
  });
250
277
 
251
278
  this.sessions.set(session.id, session);
252
279
  this.emit('session-started', {
253
280
  sessionId: session.id,
254
281
  status: session.status,
255
- startedAt: session.startedAt
282
+ startedAt: session.startedAt,
283
+ runtimeEpoch: session.runtimeEpoch,
284
+ outputSeq: session.outputSeq,
285
+ stateRevision: session.stateRevision
256
286
  });
257
287
 
258
- ptyProcess.onData((data) => {
259
- const current = this.sessions.get(session.id);
260
- if (!current) return;
261
-
262
- const busyState = parseShellBusyMarkers(data, current.terminalBusy, current.shellBusyMarkerPending);
263
- current.shellBusyMarkerPending = busyState.pending;
264
- if (busyState.markerSeen) {
265
- const markerAt = Date.now();
266
- current.terminalBusy = busyState.terminalBusy;
267
- if (busyState.cwd) {
268
- current.shellCwd = busyState.cwd;
269
- }
270
- if (busyState.exitCodeSeen) {
271
- current.shellLastExitCode = busyState.lastExitCode;
272
- }
273
- if (busyState.shellEvent) {
274
- current.shellLastEvent = busyState.shellEvent;
275
- }
276
- if (busyState.shellEvent === 'start') {
277
- current.shellCommandStartedAt = markerAt;
278
- }
279
- if (busyState.commandTextSeen) {
280
- current.shellCommand = busyState.shellCommand || '';
281
- }
282
- if (busyState.shellEvent === 'finish') {
283
- const commandStartedAt = current.shellCommandStartedAt;
284
- if (current.shellCommand) {
285
- current.shellLastCommand = current.shellCommand;
286
- }
287
- if (typeof commandStartedAt === 'number') {
288
- current.shellLastCommandStartedAt = commandStartedAt;
289
- current.shellLastCommandFinishedAt = markerAt;
290
- current.shellLastCommandDurationMs = Math.max(0, markerAt - commandStartedAt);
291
- }
292
- current.shellCommand = '';
293
- current.shellCommandStartedAt = null;
294
- }
295
- this.emit('session-busy-state', {
296
- sessionId: current.id,
297
- terminalBusy: current.terminalBusy,
298
- cwd: current.shellCwd || current.cwd,
299
- lastExitCode: current.shellLastExitCode,
300
- shellEvent: current.shellLastEvent,
301
- shellCommand: current.shellCommand,
302
- shellLastCommand: current.shellLastCommand,
303
- shellCommandStartedAt: current.shellCommandStartedAt,
304
- shellLastCommandStartedAt: current.shellLastCommandStartedAt,
305
- shellLastCommandFinishedAt: current.shellLastCommandFinishedAt,
306
- shellLastCommandDurationMs: current.shellLastCommandDurationMs,
307
- statusMarkerSeen: busyState.statusMarkerSeen,
308
- busyMarkerSeen: busyState.busyMarkerSeen,
309
- });
310
- }
288
+ ptyProcess.onData(data => this.handleSessionData(session.id, data, session));
311
289
 
312
- data = busyState.data;
313
- if (!data) return;
290
+ ptyProcess.onExit(({ code }) => {
291
+ void this.handleSessionExit(session, code);
292
+ });
314
293
 
315
- current.outputSeq += 1;
316
- current.output += data;
317
- current.lastActivityAt = Date.now();
294
+ return {
295
+ sessionId: session.id,
296
+ status: session.status
297
+ };
298
+ }
318
299
 
319
- if (current.output.length > 10000) {
320
- current.output = current.output.slice(-10000);
321
- }
300
+ handleSessionExit(session, code) {
301
+ if (this.sessions.get(session.id) !== session || session.status === 'exited') {
302
+ return Promise.resolve();
303
+ }
304
+ if (session.exitFinalizationPromise) return session.exitFinalizationPromise;
305
+ const finalization = this.finalizeSessionExit(session, code);
306
+ session.exitFinalizationPromise = finalization;
307
+ return finalization;
308
+ }
322
309
 
323
- const fallbackTitle = extractLatestTerminalTitle(data);
324
- if (fallbackTitle && fallbackTitle !== current.title) {
325
- current.title = fallbackTitle;
326
- this.emit('session-title', {
327
- sessionId: current.id,
328
- title: current.title
329
- });
310
+ async finalizeSessionExit(session, code) {
311
+ const quiesced = await waitForTerminalExitDataQuiescence(session, {
312
+ flushMs: this.terminalExitDataFlushMs,
313
+ isCurrent: () => this.sessions.get(session.id) === session,
314
+ });
315
+ if (!quiesced) return;
316
+ await Promise.resolve(session.reducerCommitQueue).catch(() => {});
317
+ if (this.sessions.get(session.id) !== session) return;
318
+ const finalCheckpoint = await captureTerminalAttachCheckpoint(session, { requireCurrentCut: true });
319
+ if (this.sessions.get(session.id) !== session) return;
320
+
321
+ session.status = 'exited';
322
+ session.exitedAt = Date.now();
323
+ resetTerminalReducerFlowControl(
324
+ ensureTerminalReducerFlowControl(session),
325
+ session.process,
326
+ );
327
+ if (finalCheckpoint) {
328
+ session.finalCheckpoint = Object.freeze({ ...finalCheckpoint });
329
+ session.renderOutput = finalCheckpoint.renderOutput;
330
+ session.previewText = finalCheckpoint.previewText;
331
+ session.previewSnapshot = finalCheckpoint.previewSnapshot;
332
+ session.previewCols = finalCheckpoint.cols;
333
+ session.previewRows = finalCheckpoint.rows;
334
+ session.title = finalCheckpoint.title || session.title;
335
+ } else {
336
+ this.failSessionScreenState(session, new Error('Unable to capture the exact final Terminal checkpoint'));
337
+ }
338
+ if (session.screenWorker) {
339
+ await session.screenWorker.dispose().catch(() => {});
340
+ session.screenWorker = null;
341
+ }
342
+ cleanupShellBusyIntegration(session.shellBusyIntegration);
343
+
344
+ this.emit('session-exited', {
345
+ sessionId: session.id,
346
+ code,
347
+ exitedAt: session.exitedAt,
348
+ runtimeEpoch: session.runtimeEpoch,
349
+ outputSeq: session.outputSeq,
350
+ stateRevision: session.stateRevision,
351
+ stateProofAvailable: session.stateProofAvailable !== false,
352
+ });
353
+ if (finalCheckpoint) {
354
+ this.emit('session-preview', {
355
+ sessionId: session.id,
356
+ previewText: session.previewText,
357
+ cols: session.previewCols,
358
+ rows: session.previewRows,
359
+ previewSnapshot: session.previewSnapshot,
360
+ runtimeEpoch: session.runtimeEpoch,
361
+ });
362
+ }
363
+ }
364
+
365
+ handleSessionData(sessionId, rawData, expectedSession = null) {
366
+ const current = this.sessions.get(sessionId);
367
+ if (!current || (expectedSession && current !== expectedSession) || current.stateProofAvailable === false) return;
368
+ if (!acceptTerminalExitData(current)) {
369
+ current.finalCheckpoint = null;
370
+ this.failSessionScreenState(current, new Error('PTY emitted data after its final checkpoint cut'));
371
+ return;
372
+ }
373
+
374
+ const busyState = parseShellBusyMarkers(
375
+ rawData,
376
+ current.terminalBusy,
377
+ current.shellBusyMarkerPending,
378
+ );
379
+ current.shellBusyMarkerPending = busyState.pending;
380
+ if (busyState.markerSeen) {
381
+ const markerAt = Date.now();
382
+ current.terminalBusy = busyState.terminalBusy;
383
+ if (busyState.cwd) current.shellCwd = busyState.cwd;
384
+ if (busyState.exitCodeSeen) current.shellLastExitCode = busyState.lastExitCode;
385
+ if (busyState.shellEvent) current.shellLastEvent = busyState.shellEvent;
386
+ if (busyState.shellEvent === 'start') current.shellCommandStartedAt = markerAt;
387
+ if (busyState.commandTextSeen) current.shellCommand = busyState.shellCommand || '';
388
+ if (busyState.shellEvent === 'finish') {
389
+ const commandStartedAt = current.shellCommandStartedAt;
390
+ if (current.shellCommand) current.shellLastCommand = current.shellCommand;
391
+ if (typeof commandStartedAt === 'number') {
392
+ current.shellLastCommandStartedAt = commandStartedAt;
393
+ current.shellLastCommandFinishedAt = markerAt;
394
+ current.shellLastCommandDurationMs = Math.max(0, markerAt - commandStartedAt);
395
+ }
396
+ current.shellCommand = '';
397
+ current.shellCommandStartedAt = null;
330
398
  }
399
+ this.emit('session-busy-state', {
400
+ sessionId: current.id,
401
+ terminalBusy: current.terminalBusy,
402
+ cwd: current.shellCwd || current.cwd,
403
+ lastExitCode: current.shellLastExitCode,
404
+ shellEvent: current.shellLastEvent,
405
+ shellCommand: current.shellCommand,
406
+ shellLastCommand: current.shellLastCommand,
407
+ shellCommandStartedAt: current.shellCommandStartedAt,
408
+ shellLastCommandStartedAt: current.shellLastCommandStartedAt,
409
+ shellLastCommandFinishedAt: current.shellLastCommandFinishedAt,
410
+ shellLastCommandDurationMs: current.shellLastCommandDurationMs,
411
+ statusMarkerSeen: busyState.statusMarkerSeen,
412
+ busyMarkerSeen: busyState.busyMarkerSeen,
413
+ runtimeEpoch: current.runtimeEpoch,
414
+ });
415
+ }
331
416
 
332
- this.emit('session-output', {
417
+ const data = busyState.data;
418
+ if (!data) return;
419
+ const reducerDelivery = enqueueTerminalReducerData(
420
+ ensureTerminalReducerFlowControl(current),
421
+ current.process,
422
+ data,
423
+ );
424
+ if (reducerDelivery.error) {
425
+ this.failSessionScreenState(current, reducerDelivery.error);
426
+ return;
427
+ }
428
+ current.outputSeq += 1;
429
+ current.stateRevision += 1;
430
+ const outputSeq = current.outputSeq;
431
+ const stateRevision = current.stateRevision;
432
+ current.output = `${current.output}${data}`.slice(-10000);
433
+ current.lastActivityAt = Date.now();
434
+
435
+ const fallbackTitle = extractLatestTerminalTitle(data);
436
+ if (fallbackTitle && fallbackTitle !== current.title) {
437
+ current.title = fallbackTitle;
438
+ this.emit('session-title', {
333
439
  sessionId: current.id,
440
+ title: current.title,
441
+ runtimeEpoch: current.runtimeEpoch,
442
+ });
443
+ }
444
+
445
+ const commit = current.screenWorker.append(data, stateRevision, outputSeq);
446
+ current.reducerCommitQueue = current.reducerCommitQueue.then(() => commit).then(() => {
447
+ const latest = this.sessions.get(current.id);
448
+ if (latest !== current || latest.stateProofAvailable === false) return;
449
+ const flowError = acknowledgeTerminalReducerData(
450
+ ensureTerminalReducerFlowControl(latest),
451
+ latest.process,
452
+ reducerDelivery.bytes,
453
+ );
454
+ if (flowError) {
455
+ this.failSessionScreenState(latest, flowError);
456
+ return;
457
+ }
458
+ this.emit('session-output', {
459
+ sessionId: latest.id,
334
460
  data,
335
- outputSeq: current.outputSeq
461
+ runtimeEpoch: latest.runtimeEpoch,
462
+ outputSeq,
463
+ stateRevision,
336
464
  });
337
465
  this.emit('session-activity', {
338
- sessionId: current.id,
339
- lastActivityAt: current.lastActivityAt
466
+ sessionId: latest.id,
467
+ lastActivityAt: latest.lastActivityAt,
468
+ runtimeEpoch: latest.runtimeEpoch,
340
469
  });
470
+ }).catch(error => this.failSessionScreenState(current, error));
471
+ }
341
472
 
342
- current.screenWorker.append(data);
343
- });
344
-
345
- ptyProcess.onExit(({ code }) => {
346
- const current = this.sessions.get(session.id);
347
- if (!current) return;
348
-
349
- current.screenWorker.getState().catch(() => ({
350
- renderOutput: current.renderOutput,
351
- previewText: current.previewText,
352
- previewSnapshot: current.previewSnapshot,
353
- title: current.title
354
- })).then((screenState) => {
355
- const latest = this.sessions.get(session.id);
356
- if (!latest) return;
357
-
358
- latest.status = 'exited';
359
- latest.exitedAt = Date.now();
360
- latest.renderOutput = screenState.renderOutput || latest.renderOutput;
361
- latest.previewText = screenState.previewText || latest.previewText || latest.output.slice(-2000);
362
- latest.previewSnapshot = screenState.previewSnapshot || latest.previewSnapshot;
363
- latest.previewCols = screenState.cols || latest.previewCols;
364
- latest.previewRows = screenState.rows || latest.previewRows;
365
- latest.title = screenState.title || latest.title;
366
- if (latest.screenWorker) {
367
- latest.screenWorker.dispose().catch(() => {});
368
- latest.screenWorker = null;
369
- }
370
- cleanupShellBusyIntegration(latest.shellBusyIntegration);
371
-
372
- this.emit('session-exited', {
373
- sessionId: latest.id,
374
- code,
375
- exitedAt: latest.exitedAt
376
- });
377
- this.emit('session-preview', {
378
- sessionId: latest.id,
379
- previewText: latest.previewText,
380
- cols: latest.previewCols,
381
- rows: latest.previewRows,
382
- previewSnapshot: latest.previewSnapshot,
383
- });
384
- });
473
+ failSessionScreenState(session, error) {
474
+ const current = this.sessions.get(session.id);
475
+ if (current !== session || current.stateProofAvailable === false) return;
476
+ current.stateProofAvailable = false;
477
+ const message = error instanceof Error ? error.message : String(error || 'unknown reducer failure');
478
+ this.emit('session-error', {
479
+ sessionId: current.id,
480
+ error: `Terminal state reducer failed: ${message}`,
481
+ fatal: true,
482
+ runtimeEpoch: current.runtimeEpoch,
385
483
  });
386
-
387
- return {
388
- sessionId: session.id,
389
- status: session.status
390
- };
484
+ try {
485
+ current.process?.kill();
486
+ } catch {
487
+ // The runtime cannot continue safely without its authoritative reducer.
488
+ }
391
489
  }
392
490
 
393
- async sendInput(sessionId, input) {
491
+ async sendInput(sessionId, input, options = {}) {
394
492
  const session = this.sessions.get(sessionId);
395
- if (!session || !session.process) {
493
+ if (!session || !session.process || session.status === 'exited' || session.exitFinalizing === true) {
396
494
  throw new Error('Session not available');
397
495
  }
398
496
 
497
+ if (options.expectedRuntimeEpoch && options.expectedRuntimeEpoch !== session.runtimeEpoch) {
498
+ return { status: 'input-rejected', reason: 'runtime-epoch-mismatch' };
499
+ }
399
500
  session.process.write(terminalInputToPtyString(input));
400
501
  session.lastActivityAt = Date.now();
401
502
  this.emit('session-activity', {
402
503
  sessionId,
403
- lastActivityAt: session.lastActivityAt
504
+ lastActivityAt: session.lastActivityAt,
505
+ runtimeEpoch: session.runtimeEpoch,
404
506
  });
507
+ return { sent: true };
508
+ }
509
+
510
+ async interruptSession(sessionId, input = '\x03', options = {}) {
511
+ return this.sendInput(sessionId, input, options);
405
512
  }
406
513
 
407
- async interruptSession(sessionId, input = '\x03') {
408
- return this.sendInput(sessionId, input);
514
+ async getSessionAttachCheckpoint(sessionId) {
515
+ const session = this.sessions.get(sessionId);
516
+ if (!session) return null;
517
+ const checkpoint = await captureTerminalAttachCheckpoint(session);
518
+ return this.sessions.get(sessionId) === session ? checkpoint : null;
409
519
  }
410
520
 
411
521
  async resizeSession(sessionId, cols, rows) {
412
522
  const session = this.sessions.get(sessionId);
413
523
  if (!session || !session.process || !session.process.resize) {
414
- return { resized: false };
524
+ return { status: 'resize-rejected', reason: 'session-unavailable', resized: false };
525
+ }
526
+ if (session.status === 'exited' || session.exitFinalizing === true) {
527
+ return { status: 'resize-rejected', reason: 'session-unavailable', resized: false };
415
528
  }
416
- if (session.status === 'exited') {
417
- return { resized: false };
529
+
530
+ if (cols === session.previewCols && rows === session.previewRows) {
531
+ if (this.sessions.get(sessionId) !== session) {
532
+ return { status: 'resize-rejected', reason: 'session-replaced', resized: false };
533
+ }
534
+ return {
535
+ status: 'resize-committed',
536
+ resized: true,
537
+ unchanged: true,
538
+ };
418
539
  }
419
540
 
420
541
  try {
@@ -423,96 +544,191 @@ class LocalSessionEngine extends SessionEngine {
423
544
  if (error && String(error.message || '').includes('EBADF')) {
424
545
  session.status = 'exited';
425
546
  session.exitedAt = session.exitedAt || Date.now();
426
- return { resized: false };
547
+ return { status: 'resize-rejected', reason: 'session-unavailable', resized: false };
427
548
  }
428
- throw error;
549
+ return {
550
+ status: 'resize-rejected',
551
+ reason: 'pty-resize-failed',
552
+ resized: false,
553
+ error: error instanceof Error ? error.message : String(error || 'unknown PTY resize failure'),
554
+ };
555
+ }
556
+ if (this.sessions.get(sessionId) !== session) {
557
+ return { status: 'resize-rejected', reason: 'session-replaced', resized: false };
429
558
  }
559
+ session.stateRevision += 1;
560
+ const stateRevision = session.stateRevision;
561
+ const outputSeq = session.outputSeq;
562
+ const runtimeEpoch = session.runtimeEpoch;
430
563
  session.previewCols = cols || session.previewCols;
431
564
  session.previewRows = rows || session.previewRows;
432
565
  if (!session.screenWorker) {
433
- return { resized: true, cols: session.previewCols, rows: session.previewRows };
566
+ if (this.sessions.get(sessionId) !== session) {
567
+ return { status: 'resize-rejected', reason: 'session-replaced', resized: false };
568
+ }
569
+ return {
570
+ status: 'resize-committed',
571
+ resized: true,
572
+ unchanged: false,
573
+ };
434
574
  }
435
575
 
436
- try {
437
- const screenState = await session.screenWorker.resize(cols, rows);
576
+ const reducerCommit = session.screenWorker.resize(cols, rows, stateRevision);
577
+ const publishedCommit = session.reducerCommitQueue.then(() => reducerCommit).then((screenState) => {
578
+ if (this.sessions.get(sessionId) !== session) {
579
+ throw new Error('Terminal session was replaced during resize');
580
+ }
581
+ if (
582
+ screenState.runtimeEpoch !== runtimeEpoch ||
583
+ screenState.outputSeq !== outputSeq ||
584
+ screenState.stateRevision !== stateRevision
585
+ ) {
586
+ throw new Error('Terminal screen resize returned a non-authoritative state cut');
587
+ }
438
588
  session.previewText = screenState.previewText || '';
439
589
  session.previewSnapshot = screenState.previewSnapshot || session.previewSnapshot;
440
- session.renderOutput = screenState.renderOutput || session.renderOutput;
590
+ session.renderOutput = typeof screenState.renderOutput === 'string'
591
+ ? screenState.renderOutput
592
+ : session.renderOutput;
441
593
  session.previewCols = screenState.cols || cols || session.previewCols;
442
594
  session.previewRows = screenState.rows || rows || session.previewRows;
443
595
  if (screenState.title && screenState.title !== session.title) {
444
596
  session.title = screenState.title;
445
597
  this.emit('session-title', {
446
598
  sessionId,
447
- title: session.title
599
+ title: session.title,
600
+ runtimeEpoch: session.runtimeEpoch,
448
601
  });
449
602
  }
450
- } catch (error) {
451
- this.emit('session-error', {
603
+ this.emit('session-transition', {
452
604
  sessionId,
453
- error: `Failed to resize terminal screen state: ${error.message}`,
454
- fatal: false
605
+ kind: 'resize',
606
+ data: '',
607
+ runtimeEpoch: screenState.runtimeEpoch,
608
+ outputSeq: screenState.outputSeq,
609
+ stateRevision: screenState.stateRevision,
610
+ cols: screenState.cols,
611
+ rows: screenState.rows
455
612
  });
613
+ return screenState;
614
+ });
615
+ session.reducerCommitQueue = publishedCommit.catch(error => {
616
+ this.failSessionScreenState(session, error);
617
+ });
618
+ try {
619
+ await publishedCommit;
620
+ } catch {
621
+ return { status: 'resize-rejected', reason: 'screen-reducer-failed', resized: false };
456
622
  }
457
- return { resized: true, cols: session.previewCols, rows: session.previewRows };
623
+ if (this.sessions.get(sessionId) !== session) {
624
+ return { status: 'resize-rejected', reason: 'session-replaced', resized: false };
625
+ }
626
+ return {
627
+ status: 'resize-committed',
628
+ resized: true,
629
+ unchanged: false,
630
+ runtimeEpoch,
631
+ outputSeq,
632
+ stateRevision,
633
+ cols,
634
+ rows,
635
+ };
458
636
  }
459
637
 
460
- async clearBuffer(sessionId) {
638
+ async clearBuffer(sessionId, options = {}) {
461
639
  const session = this.sessions.get(sessionId);
462
- if (!session) {
640
+ if (!session || session.status === 'exited' || session.exitFinalizing === true) {
463
641
  return { cleared: false };
464
642
  }
643
+ if (options.expectedRuntimeEpoch && options.expectedRuntimeEpoch !== session.runtimeEpoch) {
644
+ return { cleared: false, reason: 'runtime-epoch-mismatch' };
645
+ }
465
646
 
466
647
  session.output = '';
467
- session.outputSeq = (session.outputSeq || 0) + 1;
648
+ session.stateRevision += 1;
649
+ const stateRevision = session.stateRevision;
650
+ const outputSeq = session.outputSeq;
651
+ const runtimeEpoch = session.runtimeEpoch;
468
652
  session.renderOutput = '';
469
653
  session.previewText = '';
470
654
  session.previewSnapshot = null;
471
655
  session.lastActivityAt = Date.now();
472
656
 
657
+ let exactState = {
658
+ renderOutput: '',
659
+ runtimeEpoch,
660
+ outputSeq,
661
+ stateRevision,
662
+ cols: session.previewCols,
663
+ rows: session.previewRows,
664
+ };
473
665
  if (session.screenWorker) {
474
- try {
475
- const screenState = await session.screenWorker.clear();
476
- session.renderOutput = screenState.renderOutput || '';
666
+ const reducerCommit = session.screenWorker.clear(stateRevision, outputSeq);
667
+ const publishedCommit = session.reducerCommitQueue.then(() => reducerCommit).then((screenState) => {
668
+ if (this.sessions.get(sessionId) !== session) {
669
+ throw new Error('Terminal session was replaced during clear');
670
+ }
671
+ if (
672
+ screenState.runtimeEpoch !== runtimeEpoch ||
673
+ screenState.outputSeq !== outputSeq ||
674
+ screenState.stateRevision !== stateRevision
675
+ ) {
676
+ throw new Error('Terminal screen clear returned a non-authoritative state cut');
677
+ }
678
+ session.renderOutput = typeof screenState.renderOutput === 'string' ? screenState.renderOutput : '';
477
679
  session.previewText = screenState.previewText || '';
478
680
  session.previewSnapshot = screenState.previewSnapshot || null;
479
681
  session.previewCols = screenState.cols || session.previewCols;
480
682
  session.previewRows = screenState.rows || session.previewRows;
683
+ exactState = screenState;
481
684
  if (screenState.title && screenState.title !== session.title) {
482
685
  session.title = screenState.title;
483
- this.emit('session-title', {
484
- sessionId,
485
- title: session.title
486
- });
686
+ this.emit('session-title', { sessionId, title: session.title, runtimeEpoch: session.runtimeEpoch });
487
687
  }
488
- } catch (error) {
489
- this.emit('session-error', {
688
+ this.emit('session-transition', {
490
689
  sessionId,
491
- error: `Failed to clear terminal screen state: ${error.message}`,
492
- fatal: false
690
+ kind: 'clear',
691
+ data: '\x1b[2J\x1b[3J\x1b[H',
692
+ runtimeEpoch: screenState.runtimeEpoch,
693
+ outputSeq: screenState.outputSeq,
694
+ stateRevision: screenState.stateRevision,
695
+ cols: screenState.cols,
696
+ rows: screenState.rows
493
697
  });
698
+ return screenState;
699
+ });
700
+ session.reducerCommitQueue = publishedCommit.catch(error => {
701
+ this.failSessionScreenState(session, error);
702
+ });
703
+ try {
704
+ await publishedCommit;
705
+ } catch {
706
+ return { cleared: false };
494
707
  }
495
708
  }
496
-
497
- this.emit('session-sync', {
498
- sessionId,
499
- output: session.renderOutput || session.output,
500
- outputSeq: session.outputSeq,
501
- replaceLive: true
502
- });
709
+ if (this.sessions.get(sessionId) !== session) return { cleared: false, reason: 'session-replaced' };
503
710
  this.emit('session-preview', {
504
711
  sessionId,
505
712
  previewText: session.previewText,
506
713
  cols: session.previewCols,
507
714
  rows: session.previewRows,
508
715
  previewSnapshot: session.previewSnapshot,
509
- title: session.title
716
+ title: session.title,
717
+ runtimeEpoch: session.runtimeEpoch,
510
718
  });
511
719
  this.emit('session-activity', {
512
720
  sessionId,
513
- lastActivityAt: session.lastActivityAt
721
+ lastActivityAt: session.lastActivityAt,
722
+ runtimeEpoch: session.runtimeEpoch,
514
723
  });
515
- return { cleared: true, outputSeq: session.outputSeq };
724
+ return {
725
+ cleared: true,
726
+ runtimeEpoch: exactState.runtimeEpoch,
727
+ outputSeq: exactState.outputSeq,
728
+ stateRevision: exactState.stateRevision,
729
+ cols: exactState.cols,
730
+ rows: exactState.rows,
731
+ };
516
732
  }
517
733
 
518
734
  async killSession(sessionId) {
@@ -527,7 +743,7 @@ class LocalSessionEngine extends SessionEngine {
527
743
 
528
744
  const killTimer = setTimeout(() => {
529
745
  const latest = this.sessions.get(sessionId);
530
- if (!latest || latest.status === 'exited' || !latest.process) {
746
+ if (latest !== session || latest.status === 'exited' || !latest.process) {
531
747
  return;
532
748
  }
533
749
  latest.process.kill('SIGKILL');
@@ -538,23 +754,31 @@ class LocalSessionEngine extends SessionEngine {
538
754
  async getSessionState(sessionId) {
539
755
  const session = this.sessions.get(sessionId);
540
756
  if (!session) return null;
541
- const screenState = session.screenWorker
542
- ? await session.screenWorker.getState().catch(() => null)
543
- : null;
544
-
545
- const title = (screenState && screenState.title) || session.title;
546
- const previewText = (screenState && screenState.previewText) || session.previewText;
757
+ const snapshotOutput = session.output;
758
+ const fallbackPreviewText = session.previewText;
759
+ const fallbackPreviewSnapshot = session.previewSnapshot;
760
+ const fallbackPreviewCols = session.previewCols;
761
+ const fallbackPreviewRows = session.previewRows;
762
+ const fallbackTitle = session.title;
763
+ const stateProofAvailable = session.stateProofAvailable !== false;
764
+ const checkpoint = stateProofAvailable ? await captureTerminalAttachCheckpoint(session) : null;
765
+ if (this.sessions.get(sessionId) !== session) return null;
766
+ const title = checkpoint ? checkpoint.title : fallbackTitle;
767
+ const previewText = checkpoint ? checkpoint.previewText : (stateProofAvailable ? fallbackPreviewText : '');
547
768
 
548
769
  return {
549
770
  sessionId: session.id,
550
771
  status: session.status,
551
- output: session.output,
552
- outputSeq: session.outputSeq || 0,
553
- renderOutput: (screenState && screenState.renderOutput) || session.renderOutput,
772
+ runtimeEpoch: session.runtimeEpoch,
773
+ output: snapshotOutput,
774
+ outputSeq: checkpoint?.outputSeq ?? null,
775
+ stateRevision: checkpoint?.stateRevision ?? null,
776
+ stateProofAvailable,
777
+ renderOutput: checkpoint ? checkpoint.renderOutput : (stateProofAvailable ? snapshotOutput : ''),
554
778
  previewText,
555
- previewSnapshot: (screenState && screenState.previewSnapshot) || session.previewSnapshot,
556
- previewCols: (screenState && screenState.cols) || session.previewCols,
557
- previewRows: (screenState && screenState.rows) || session.previewRows,
779
+ previewSnapshot: checkpoint ? checkpoint.previewSnapshot : (stateProofAvailable ? fallbackPreviewSnapshot : null),
780
+ previewCols: checkpoint ? checkpoint.cols : fallbackPreviewCols,
781
+ previewRows: checkpoint ? checkpoint.rows : fallbackPreviewRows,
558
782
  title,
559
783
  lastActivityAt: session.lastActivityAt,
560
784
  startedAt: session.startedAt,
@@ -593,6 +817,7 @@ class LocalSessionEngine extends SessionEngine {
593
817
  const screenState = session.screenWorker
594
818
  ? await session.screenWorker.getState({ includeRenderOutput: false }).catch(() => null)
595
819
  : null;
820
+ if (this.sessions.get(sessionId) !== session) return '';
596
821
  return (screenState && screenState.previewText) || session.previewText;
597
822
  }
598
823