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
@@ -14,9 +14,12 @@ function isSafeSessionId(sessionId) {
14
14
  }
15
15
 
16
16
  function mainPageAgentSessionKey(provider, sessionId, providerHomeId = '') {
17
+ const normalizedProvider = normalizeMainPageSessionProvider(provider);
18
+ const normalizedSessionId = String(sessionId || '').trim();
19
+ if (!normalizedProvider || !normalizedSessionId) return '';
17
20
  const homeId = String(providerHomeId || '').trim();
18
- if (homeId && homeId !== 'default') return `agent-session:${provider}:home:${homeId}:${sessionId}`;
19
- return `agent-session:${provider}:${sessionId}`;
21
+ if (homeId && homeId !== 'default') return `agent-session:${normalizedProvider}:home:${homeId}:${normalizedSessionId}`;
22
+ return `agent-session:${normalizedProvider}:${normalizedSessionId}`;
20
23
  }
21
24
 
22
25
  function mainPageAgentSessionFromKey(key) {
@@ -0,0 +1,106 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const storageLayout = require('./storage-layout');
5
+
6
+ const LOCK_RETRY_MS = 10;
7
+ const LOCK_TIMEOUT_MS = 5000;
8
+ const STALE_LOCK_MS = 30000;
9
+
10
+ function delay(ms) {
11
+ return new Promise(resolve => setTimeout(resolve, ms));
12
+ }
13
+
14
+ function positiveGeneration(value) {
15
+ const parsed = Math.floor(Number(value));
16
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
17
+ }
18
+
19
+ async function acquireGenerationLock(lockDir) {
20
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
21
+ while (Date.now() < deadline) {
22
+ try {
23
+ await fs.promises.mkdir(lockDir);
24
+ return;
25
+ } catch (error) {
26
+ if (!error || error.code !== 'EEXIST') throw error;
27
+ try {
28
+ const stat = await fs.promises.stat(lockDir);
29
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
30
+ await fs.promises.rm(lockDir, { recursive: true, force: true });
31
+ continue;
32
+ }
33
+ } catch (statError) {
34
+ if (!statError || statError.code !== 'ENOENT') throw statError;
35
+ }
36
+ await delay(LOCK_RETRY_MS);
37
+ }
38
+ }
39
+ throw new Error('Timed out allocating native PTY controller generation');
40
+ }
41
+
42
+ async function allocateGeneration(generationFile, lockDir) {
43
+ const root = path.dirname(generationFile);
44
+ await fs.promises.mkdir(root, { recursive: true });
45
+ await acquireGenerationLock(lockDir);
46
+ try {
47
+ let current = 0;
48
+ try {
49
+ current = positiveGeneration(await fs.promises.readFile(generationFile, 'utf8'));
50
+ } catch (error) {
51
+ if (!error || error.code !== 'ENOENT') throw error;
52
+ }
53
+ const generation = current + 1;
54
+ const temporary = `${generationFile}.${process.pid}.${crypto.randomUUID()}.tmp`;
55
+ await fs.promises.writeFile(temporary, `${generation}\n`, { mode: 0o600 });
56
+ await fs.promises.rename(temporary, generationFile);
57
+ return generation;
58
+ } finally {
59
+ await fs.promises.rm(lockDir, { recursive: true, force: true });
60
+ }
61
+ }
62
+
63
+ async function allocateNativePtyControllerGeneration(configDir) {
64
+ const root = path.resolve(configDir);
65
+ const generationFile = storageLayout.nativePtyControllerGenerationFile(root);
66
+ const lockDir = storageLayout.nativePtyControllerGenerationLockDir(root);
67
+ return allocateGeneration(generationFile, lockDir);
68
+ }
69
+
70
+ async function allocateNativePtyRuntimeGeneration(configDir) {
71
+ const root = path.resolve(configDir);
72
+ return allocateGeneration(
73
+ storageLayout.nativePtyRuntimeGenerationFile(root),
74
+ storageLayout.nativePtyRuntimeGenerationLockDir(root),
75
+ );
76
+ }
77
+
78
+ function formatNativePtyRuntimeEpoch(generation, id = crypto.randomUUID()) {
79
+ const normalized = positiveGeneration(generation);
80
+ if (!normalized) throw new Error('Native PTY runtime generation must be positive');
81
+ return `farming-runtime-v1:${String(normalized).padStart(20, '0')}:${id}`;
82
+ }
83
+
84
+ function nativePtyRuntimeEpochGeneration(runtimeEpoch) {
85
+ const match = /^farming-runtime-v1:(\d{20}):/.exec(String(runtimeEpoch || ''));
86
+ if (!match) return null;
87
+ const generation = Number(match[1]);
88
+ return Number.isSafeInteger(generation) && generation > 0 ? generation : null;
89
+ }
90
+
91
+ function compareNativePtyRuntimeEpochs(left, right) {
92
+ if (left === right) return 0;
93
+ const leftGeneration = nativePtyRuntimeEpochGeneration(left);
94
+ const rightGeneration = nativePtyRuntimeEpochGeneration(right);
95
+ if (leftGeneration === null || rightGeneration === null || leftGeneration === rightGeneration) return null;
96
+ return leftGeneration < rightGeneration ? -1 : 1;
97
+ }
98
+
99
+ module.exports = {
100
+ allocateNativePtyControllerGeneration,
101
+ allocateNativePtyRuntimeGeneration,
102
+ compareNativePtyRuntimeEpochs,
103
+ formatNativePtyRuntimeEpoch,
104
+ nativePtyRuntimeEpochGeneration,
105
+ positiveGeneration,
106
+ };
@@ -1,24 +1,38 @@
1
1
  const EventEmitter = require('events');
2
2
  const { spawn } = require('child_process');
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const net = require('net');
5
6
  const path = require('path');
6
7
  const { nativePtyHostSocketPath } = require('./native-pty-host-path');
8
+ const {
9
+ nativePtyHostRuntimeIdentity,
10
+ nativePtyHostRuntimeIdentityMatches,
11
+ normalizeNativePtyHostRuntimeIdentity,
12
+ } = require('./native-pty-host-identity');
13
+ const {
14
+ allocateNativePtyControllerGeneration,
15
+ positiveGeneration,
16
+ } = require('./native-pty-controller-generation');
7
17
  const storageLayout = require('./storage-layout');
8
18
 
9
19
  const DEFAULT_CONNECT_RETRIES = 300;
10
20
  const DEFAULT_CONNECT_RETRY_MS = 50;
11
21
  const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
22
+ const DEFAULT_HOST_ROTATION_TIMEOUT_MS = 10000;
12
23
  const PACKAGED_NATIVE_PTY_HOST_ENV = 'FARMING_RUN_NATIVE_PTY_HOST';
13
24
  const RECONNECT_RETRYABLE_METHODS = new Set([
14
25
  'ping',
15
26
  'createSession',
27
+ 'claimSessionController',
28
+ 'renewSessionController',
16
29
  'resizeSession',
17
- 'clearBuffer',
18
30
  'killSession',
31
+ 'getSessionAttachCheckpoint',
19
32
  'getSessionState',
20
33
  'getSessionPreview',
21
34
  'recoverSessions',
35
+ 'serializeTerminalState',
22
36
  'updateSessionMetadata',
23
37
  ]);
24
38
  const PACKAGED_NATIVE_HOST_ENV_KEYS = new Set([
@@ -111,6 +125,13 @@ function hostConnectErrorMessage(error, spawned, logPath) {
111
125
  return `Native PTY host is not reachable${code}.${logHint}`;
112
126
  }
113
127
 
128
+ function runtimeIdentityLabel(value) {
129
+ const identity = normalizeNativePtyHostRuntimeIdentity(value);
130
+ if (!identity) return 'legacy/unknown';
131
+ const version = identity.version ? `v${identity.version} ` : '';
132
+ return `${version}protocol ${identity.protocolVersion} build ${identity.buildId.slice(0, 12)}`;
133
+ }
134
+
114
135
  function nativeHostSpawnCommand(hostScript, env) {
115
136
  const nodeBin = env.FARMING_NODE_BIN || process.execPath;
116
137
  const ldPath = env.FARMING_NODE_LD || '';
@@ -148,7 +169,22 @@ class NativePtyHostClient extends EventEmitter {
148
169
  this.connectRetryMs = options.connectRetryMs || DEFAULT_CONNECT_RETRY_MS;
149
170
  this.requestTimeoutMs = options.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
150
171
  this.hostLogPath = options.hostLogPath || this.defaultHostLogPath();
172
+ this.expectedRuntimeIdentity = options.expectedRuntimeIdentity || nativePtyHostRuntimeIdentity();
173
+ this.controllerIdentity = options.controllerIdentity
174
+ ? {
175
+ id: String(options.controllerIdentity.id || ''),
176
+ generation: positiveGeneration(options.controllerIdentity.generation),
177
+ }
178
+ : {
179
+ id: crypto.randomUUID(),
180
+ generation: 0,
181
+ };
182
+ this.controllerIdentityReady = null;
183
+ this.hostRotationTimeoutMs = options.hostRotationTimeoutMs || DEFAULT_HOST_ROTATION_TIMEOUT_MS;
184
+ this.connectedHostInfo = null;
185
+ this.runtimeRotationInfo = null;
151
186
  this.socket = null;
187
+ this.socketGeneration = 0;
152
188
  this.buffer = '';
153
189
  this.nextRequestId = 1;
154
190
  this.pending = new Map();
@@ -281,8 +317,11 @@ class NativePtyHostClient extends EventEmitter {
281
317
  }
282
318
 
283
319
  async connectWithRetries(options = {}) {
320
+ await this.ensureControllerIdentity();
284
321
  const allowHostStart = options.startHost !== false;
285
322
  let spawned = false;
323
+ let rotatedMismatchedHost = false;
324
+ let pendingRotationInfo = null;
286
325
  let lastError = null;
287
326
 
288
327
  for (let attempt = 0; attempt < this.connectRetries; attempt += 1) {
@@ -291,14 +330,51 @@ class NativePtyHostClient extends EventEmitter {
291
330
  }
292
331
  try {
293
332
  await this.connectOnce();
294
- await this.request('ping', {}, { ensureConnected: false, timeoutMs: 3000 });
333
+ const hostInfo = await this.request('ping', {}, { ensureConnected: false, timeoutMs: 3000 });
334
+ if (!nativePtyHostRuntimeIdentityMatches(this.expectedRuntimeIdentity, hostInfo?.runtimeIdentity)) {
335
+ if (rotatedMismatchedHost) {
336
+ const error = new Error(
337
+ `Native PTY host runtime still mismatches after rotation: expected ` +
338
+ `${runtimeIdentityLabel(this.expectedRuntimeIdentity)}, got ${runtimeIdentityLabel(hostInfo?.runtimeIdentity)}`
339
+ );
340
+ error.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
341
+ throw error;
342
+ }
343
+ const serializedTerminalState = await this.rotateMismatchedHost(hostInfo);
344
+ pendingRotationInfo = {
345
+ rotatedAt: Date.now(),
346
+ previous: normalizeNativePtyHostRuntimeIdentity(hostInfo?.runtimeIdentity),
347
+ current: normalizeNativePtyHostRuntimeIdentity(this.expectedRuntimeIdentity),
348
+ previousPid: Number(hostInfo?.pid) || null,
349
+ serializedTerminalState: typeof serializedTerminalState === 'string'
350
+ ? serializedTerminalState
351
+ : '',
352
+ };
353
+ rotatedMismatchedHost = true;
354
+ spawned = true;
355
+ continue;
356
+ }
357
+ await this.requestOnce('registerController', {
358
+ identity: this.controllerIdentity,
359
+ }, {
360
+ ensureConnected: false,
361
+ retryOnDisconnect: false,
362
+ timeoutMs: 3000,
363
+ });
364
+ this.connectedHostInfo = hostInfo || null;
365
+ if (pendingRotationInfo) {
366
+ this.runtimeRotationInfo = pendingRotationInfo;
367
+ }
295
368
  return;
296
369
  } catch (error) {
297
370
  lastError = error;
371
+ if (error && error.code === 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH') {
372
+ throw error;
373
+ }
298
374
  if (this.hostStartError) {
299
375
  lastError = this.hostStartError;
300
376
  }
301
- if (!allowHostStart && isConnectRetryable(error)) {
377
+ if (!allowHostStart && !rotatedMismatchedHost && isConnectRetryable(error)) {
302
378
  throw error;
303
379
  }
304
380
  if (allowHostStart && !spawned && isConnectRetryable(error)) {
@@ -330,26 +406,217 @@ class NativePtyHostClient extends EventEmitter {
330
406
  throw lastError || new Error('Failed to connect to native pty host');
331
407
  }
332
408
 
409
+ async ensureControllerIdentity() {
410
+ if (this.controllerIdentity.generation > 0) {
411
+ return this.controllerIdentity;
412
+ }
413
+ if (!this.controllerIdentityReady) {
414
+ const configRoot = this.configDir || path.dirname(this.socketPath);
415
+ this.controllerIdentityReady = allocateNativePtyControllerGeneration(configRoot)
416
+ .then((generation) => {
417
+ this.controllerIdentity.generation = generation;
418
+ return this.controllerIdentity;
419
+ })
420
+ .catch((error) => {
421
+ this.controllerIdentityReady = null;
422
+ throw error;
423
+ });
424
+ }
425
+ return this.controllerIdentityReady;
426
+ }
427
+
428
+ async waitForHostRelease() {
429
+ const deadline = Date.now() + this.hostRotationTimeoutMs;
430
+ if (process.platform === 'win32') {
431
+ await delay(Math.min(250, this.hostRotationTimeoutMs));
432
+ return;
433
+ }
434
+ while (Date.now() < deadline) {
435
+ const childReleased = !this.hostChild ||
436
+ this.hostChild.exitCode !== null ||
437
+ this.hostChild.signalCode !== null;
438
+ if (!fs.existsSync(this.socketPath) && childReleased) return;
439
+ await delay(50);
440
+ }
441
+ const error = new Error('Timed out waiting for the previous native PTY host to stop');
442
+ error.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
443
+ throw error;
444
+ }
445
+
446
+ async resumePreparedHostRotation(preparationToken = '') {
447
+ if (!this.socket || this.socket.destroyed) {
448
+ await this.connectOnce();
449
+ await this.requestOnce('registerController', {
450
+ identity: this.controllerIdentity,
451
+ }, {
452
+ ensureConnected: false,
453
+ retryOnDisconnect: false,
454
+ timeoutMs: 1000,
455
+ });
456
+ }
457
+ return this.requestOnce('resumeTerminalState', {
458
+ preparationToken,
459
+ }, {
460
+ ensureConnected: false,
461
+ retryOnDisconnect: false,
462
+ timeoutMs: 1000,
463
+ });
464
+ }
465
+
466
+ async rotateMismatchedHost(hostInfo) {
467
+ const expected = runtimeIdentityLabel(this.expectedRuntimeIdentity);
468
+ const actual = runtimeIdentityLabel(hostInfo && hostInfo.runtimeIdentity);
469
+ console.warn(`Rotating native PTY host runtime: expected ${expected}, connected to ${actual}`);
470
+ this.emit('host-runtime-mismatch', {
471
+ expected: this.expectedRuntimeIdentity,
472
+ actual: hostInfo && hostInfo.runtimeIdentity || null,
473
+ pid: Number(hostInfo && hostInfo.pid) || null,
474
+ });
475
+
476
+ await this.requestOnce('registerController', {
477
+ identity: this.controllerIdentity,
478
+ }, {
479
+ ensureConnected: false,
480
+ retryOnDisconnect: false,
481
+ timeoutMs: 3000,
482
+ });
483
+
484
+ let serializedTerminalState = '';
485
+ let preparationToken = '';
486
+ try {
487
+ const preparation = await this.requestOnce('serializeTerminalState', {}, {
488
+ ensureConnected: false,
489
+ retryOnDisconnect: false,
490
+ timeoutMs: Math.min(5000, this.hostRotationTimeoutMs),
491
+ });
492
+ if (
493
+ preparation &&
494
+ typeof preparation === 'object' &&
495
+ typeof preparation.preparationToken === 'string' &&
496
+ preparation.preparationToken &&
497
+ typeof preparation.serializedTerminalState === 'string'
498
+ ) {
499
+ preparationToken = preparation.preparationToken;
500
+ serializedTerminalState = preparation.serializedTerminalState;
501
+ } else if (typeof preparation === 'string') {
502
+ const recovered = await this.requestOnce('recoverSessions', {}, {
503
+ ensureConnected: false,
504
+ retryOnDisconnect: false,
505
+ timeoutMs: Math.min(3000, this.hostRotationTimeoutMs),
506
+ });
507
+ if (Array.isArray(recovered) && recovered.length === 0) {
508
+ serializedTerminalState = preparation;
509
+ } else {
510
+ throw new Error('The old native PTY host cannot commit a transactional terminal checkpoint');
511
+ }
512
+ } else {
513
+ throw new Error('The native PTY host returned an invalid rotation checkpoint');
514
+ }
515
+ } catch (error) {
516
+ await this.resumePreparedHostRotation(preparationToken).catch(() => {});
517
+ const recovered = await this.requestOnce('recoverSessions', {}, {
518
+ ensureConnected: false,
519
+ retryOnDisconnect: false,
520
+ timeoutMs: Math.min(3000, this.hostRotationTimeoutMs),
521
+ }).catch(() => null);
522
+ if (Array.isArray(recovered) && recovered.length === 0) {
523
+ serializedTerminalState = '';
524
+ preparationToken = '';
525
+ } else {
526
+ const mismatchError = new Error(
527
+ `Cannot rotate incompatible native PTY host (${actual}) without a committed terminal checkpoint`
528
+ );
529
+ mismatchError.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
530
+ mismatchError.cause = error;
531
+ throw mismatchError;
532
+ }
533
+ }
534
+
535
+ const socket = this.socket;
536
+ if (socket) this.suppressedDisconnectSockets.add(socket);
537
+ let shutdownUncertain = false;
538
+ try {
539
+ await this.requestOnce('shutdownHost', {
540
+ controller: this.controllerIdentity,
541
+ preparationToken,
542
+ }, {
543
+ ensureConnected: false,
544
+ retryOnDisconnect: false,
545
+ timeoutMs: Math.min(5000, this.hostRotationTimeoutMs),
546
+ });
547
+ } catch (error) {
548
+ if (!isConnectRetryable(error)) {
549
+ await this.resumePreparedHostRotation(preparationToken).catch(() => {});
550
+ const mismatchError = new Error(
551
+ `Cannot rotate incompatible native PTY host (${actual}); stop the old host and restart Farming`
552
+ );
553
+ mismatchError.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
554
+ mismatchError.cause = error;
555
+ throw mismatchError;
556
+ }
557
+ shutdownUncertain = true;
558
+ } finally {
559
+ if (this.socket === socket) {
560
+ this.socket = null;
561
+ this.buffer = '';
562
+ }
563
+ if (socket && !socket.destroyed) socket.destroy();
564
+ this.connectedHostInfo = null;
565
+ }
566
+
567
+ try {
568
+ await this.waitForHostRelease();
569
+ } catch (error) {
570
+ if (shutdownUncertain) {
571
+ try {
572
+ await this.resumePreparedHostRotation(preparationToken);
573
+ } catch {
574
+ // The old host may have exited after the release timeout. Do not
575
+ // start a second host until the socket and child are actually gone.
576
+ }
577
+ }
578
+ const mismatchError = new Error(
579
+ `Cannot confirm shutdown of incompatible native PTY host (${actual}); restart Farming after the old host exits`
580
+ );
581
+ mismatchError.code = 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH';
582
+ mismatchError.cause = error;
583
+ throw mismatchError;
584
+ }
585
+ this.spawnHost();
586
+ return serializedTerminalState;
587
+ }
588
+
589
+ consumeRuntimeRotation() {
590
+ const rotation = this.runtimeRotationInfo;
591
+ this.runtimeRotationInfo = null;
592
+ return rotation;
593
+ }
594
+
333
595
  attachSocket(socket) {
334
596
  if (this.socket && this.socket !== socket) {
335
597
  this.socket.destroy();
336
598
  }
599
+ const generation = this.socketGeneration + 1;
600
+ this.socketGeneration = generation;
337
601
  this.socket = socket;
338
602
  this.buffer = '';
339
603
 
340
- socket.on('data', chunk => this.handleData(chunk));
341
- socket.on('close', () => this.handleDisconnect(socket));
604
+ socket.on('data', chunk => this.handleData(chunk, socket, generation));
605
+ socket.on('close', () => this.handleDisconnect(socket, generation));
342
606
  socket.on('error', error => {
607
+ if (this.socket !== socket || this.socketGeneration !== generation) return;
343
608
  this.emit('host-error', error);
344
609
  });
345
610
  }
346
611
 
347
- handleDisconnect(socket) {
612
+ handleDisconnect(socket, generation = this.socketGeneration) {
348
613
  if (socket && this.socket && this.socket !== socket) {
349
614
  return;
350
615
  }
616
+ if (generation !== this.socketGeneration) return;
351
617
  this.socket = null;
352
618
  this.buffer = '';
619
+ this.connectedHostInfo = null;
353
620
  if (this.disposed) return;
354
621
  const error = new Error('Native pty host disconnected');
355
622
  error.code = 'ECONNRESET';
@@ -374,7 +641,8 @@ class NativePtyHostClient extends EventEmitter {
374
641
  }
375
642
  }
376
643
 
377
- handleData(chunk) {
644
+ handleData(chunk, socket = this.socket, generation = this.socketGeneration) {
645
+ if (!socket || socket !== this.socket || generation !== this.socketGeneration) return;
378
646
  this.buffer += chunk.toString('utf8');
379
647
  let newline = this.buffer.indexOf('\n');
380
648
  while (newline >= 0) {
@@ -0,0 +1,86 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const NATIVE_PTY_HOST_PROTOCOL_VERSION = 8;
6
+ const NATIVE_PTY_HOST_RUNTIME_FILES = Object.freeze([
7
+ 'agent-env.js',
8
+ 'input-parts.js',
9
+ 'local-session-engine.js',
10
+ 'native-pty-host.js',
11
+ 'shell-busy-integration.js',
12
+ 'terminal-screen-worker-pool.js',
13
+ 'terminal-screen-worker-thread.js',
14
+ 'terminal-screen-worker.js',
15
+ 'terminal-reducer-flow-control.js',
16
+ 'terminal-state-serialization.js',
17
+ 'native-pty-controller-generation.js',
18
+ 'terminal-status.js',
19
+ ]);
20
+
21
+ function readFileForIdentity(filePath) {
22
+ try {
23
+ return fs.readFileSync(filePath);
24
+ } catch (error) {
25
+ return Buffer.from(`missing:${error && error.code ? error.code : 'unknown'}`, 'utf8');
26
+ }
27
+ }
28
+
29
+ function packageVersion(backendDir) {
30
+ try {
31
+ const packageJson = JSON.parse(fs.readFileSync(path.join(backendDir, '..', 'package.json'), 'utf8'));
32
+ return typeof packageJson.version === 'string' ? packageJson.version : '';
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function nativePtyHostRuntimeIdentity(backendDir = __dirname) {
39
+ const hash = crypto.createHash('sha256');
40
+ const version = packageVersion(backendDir);
41
+ hash.update(`protocol:${NATIVE_PTY_HOST_PROTOCOL_VERSION}\n`);
42
+ hash.update(`version:${version}\n`);
43
+ for (const filename of NATIVE_PTY_HOST_RUNTIME_FILES) {
44
+ hash.update(`file:${filename}\n`);
45
+ hash.update(readFileForIdentity(path.join(backendDir, filename)));
46
+ hash.update('\n');
47
+ }
48
+ return Object.freeze({
49
+ protocolVersion: NATIVE_PTY_HOST_PROTOCOL_VERSION,
50
+ buildId: hash.digest('hex'),
51
+ version,
52
+ });
53
+ }
54
+
55
+ function normalizeNativePtyHostRuntimeIdentity(value) {
56
+ if (!value || typeof value !== 'object') return null;
57
+ const protocolVersion = Number(value.protocolVersion);
58
+ const buildId = typeof value.buildId === 'string' ? value.buildId.trim() : '';
59
+ if (!Number.isInteger(protocolVersion) || protocolVersion <= 0 || !/^[a-f0-9]{64}$/i.test(buildId)) {
60
+ return null;
61
+ }
62
+ return {
63
+ protocolVersion,
64
+ buildId: buildId.toLowerCase(),
65
+ version: typeof value.version === 'string' ? value.version : '',
66
+ };
67
+ }
68
+
69
+ function nativePtyHostRuntimeIdentityMatches(expected, actual) {
70
+ const normalizedExpected = normalizeNativePtyHostRuntimeIdentity(expected);
71
+ const normalizedActual = normalizeNativePtyHostRuntimeIdentity(actual);
72
+ return Boolean(
73
+ normalizedExpected &&
74
+ normalizedActual &&
75
+ normalizedExpected.protocolVersion === normalizedActual.protocolVersion &&
76
+ normalizedExpected.buildId === normalizedActual.buildId
77
+ );
78
+ }
79
+
80
+ module.exports = {
81
+ NATIVE_PTY_HOST_PROTOCOL_VERSION,
82
+ NATIVE_PTY_HOST_RUNTIME_FILES,
83
+ nativePtyHostRuntimeIdentity,
84
+ nativePtyHostRuntimeIdentityMatches,
85
+ normalizeNativePtyHostRuntimeIdentity,
86
+ };