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
@@ -16,6 +16,9 @@ class SessionEngineBridge extends EventEmitter {
16
16
  engine.on('session-output', (payload) => {
17
17
  this.emit('session-output', { engineName, ...payload });
18
18
  });
19
+ engine.on('session-transition', (payload) => {
20
+ this.emit('session-transition', { engineName, ...payload });
21
+ });
19
22
  engine.on('session-sync', (payload) => {
20
23
  this.emit('session-sync', { engineName, ...payload });
21
24
  });
@@ -54,10 +57,10 @@ class SessionEngineBridge extends EventEmitter {
54
57
  return resolution;
55
58
  }
56
59
 
57
- async sendInput(engineName, sessionId, input) {
60
+ async sendInput(engineName, sessionId, input, options = {}) {
58
61
  const engine = this.getEngine(engineName);
59
62
  if (!engine) return;
60
- return engine.sendInput(sessionId, input);
63
+ return engine.sendInput(sessionId, input, options);
61
64
  }
62
65
 
63
66
  async resizeSession(engineName, sessionId, cols, rows) {
@@ -84,6 +87,12 @@ class SessionEngineBridge extends EventEmitter {
84
87
  return engine.getSessionState(sessionId);
85
88
  }
86
89
 
90
+ async getSessionAttachCheckpoint(engineName, sessionId) {
91
+ const engine = this.getEngine(engineName);
92
+ if (!engine || !engine.getSessionAttachCheckpoint) return null;
93
+ return engine.getSessionAttachCheckpoint(sessionId);
94
+ }
95
+
87
96
  async getSessionPreview(engineName, sessionId) {
88
97
  const engine = this.getEngine(engineName);
89
98
  if (!engine) return '';
@@ -102,6 +111,16 @@ class SessionEngineBridge extends EventEmitter {
102
111
  return recovered;
103
112
  }
104
113
 
114
+ consumeRuntimeRotations() {
115
+ const rotations = [];
116
+ for (const [engineName, engine] of Object.entries(this.router.engines)) {
117
+ if (!engine || typeof engine.consumeRuntimeRotation !== 'function') continue;
118
+ const rotation = engine.consumeRuntimeRotation();
119
+ if (rotation) rotations.push({ engineName, ...rotation });
120
+ }
121
+ return rotations;
122
+ }
123
+
105
124
  dispose(options = {}) {
106
125
  return this.router.dispose(options);
107
126
  }
@@ -10,7 +10,7 @@ class SessionEngineRouter {
10
10
  : process.env.FARMING_NATIVE_PTY_HOST_PERSIST !== '0';
11
11
  this.engines = {
12
12
  native: new NativeSessionEngine({ configDir, preserveHostOnDispose: preserveNativeHost }),
13
- local: new LocalSessionEngine()
13
+ local: new LocalSessionEngine({ configDir })
14
14
  };
15
15
  this.overrideEngineName = process.env.FARMING_SESSION_ENGINE || '';
16
16
  this.defaultEngineName = options.defaultEngineName || this.overrideEngineName || 'native';
@@ -9,7 +9,7 @@ class SessionEngine extends EventEmitter {
9
9
  throw new Error('sendInput() must be implemented by subclasses');
10
10
  }
11
11
 
12
- async interruptSession(_sessionId, _input) {
12
+ async interruptSession(_sessionId, _input, _options = {}) {
13
13
  throw new Error('interruptSession() must be implemented by subclasses');
14
14
  }
15
15
 
@@ -0,0 +1,185 @@
1
+ function finiteNumber(value) {
2
+ return Number.isFinite(value) ? value : undefined;
3
+ }
4
+
5
+ function nonEmptyString(value) {
6
+ return typeof value === 'string' && value ? value : undefined;
7
+ }
8
+
9
+ function transitionKind(value) {
10
+ return value === 'resize' || value === 'clear' ? value : 'output';
11
+ }
12
+
13
+ function transitionChunks(stream, data, runtimeEpoch, outputSeq, stateRevision) {
14
+ if (Array.isArray(stream.chunks)) {
15
+ return stream.chunks.map(chunk => ({
16
+ kind: transitionKind(chunk.kind),
17
+ data: typeof chunk.data === 'string' ? chunk.data : String(chunk.data || ''),
18
+ runtimeEpoch: nonEmptyString(chunk.runtimeEpoch),
19
+ outputSeq: finiteNumber(chunk.outputSeq),
20
+ stateRevision: finiteNumber(chunk.stateRevision),
21
+ cols: finiteNumber(chunk.cols),
22
+ rows: finiteNumber(chunk.rows),
23
+ }));
24
+ }
25
+ if (stream.replace === true || !runtimeEpoch || outputSeq === undefined || stateRevision === undefined) {
26
+ return undefined;
27
+ }
28
+ return [{
29
+ kind: transitionKind(stream.kind),
30
+ data,
31
+ runtimeEpoch,
32
+ outputSeq,
33
+ stateRevision,
34
+ cols: finiteNumber(stream.cols),
35
+ rows: finiteNumber(stream.rows),
36
+ }];
37
+ }
38
+
39
+ function normalizeSessionStream(stream) {
40
+ const data = typeof stream.data === 'string' ? stream.data : String(stream.data || '');
41
+ const runtimeEpoch = nonEmptyString(stream.runtimeEpoch);
42
+ const outputSeq = finiteNumber(stream.outputSeq);
43
+ const stateRevision = finiteNumber(stream.stateRevision);
44
+ return {
45
+ ...stream,
46
+ kind: transitionKind(stream.kind),
47
+ data,
48
+ replace: stream.replace === true,
49
+ runtimeEpoch,
50
+ outputSeq,
51
+ stateRevision,
52
+ cols: finiteNumber(stream.cols),
53
+ rows: finiteNumber(stream.rows),
54
+ chunks: transitionChunks(stream, data, runtimeEpoch, outputSeq, stateRevision),
55
+ };
56
+ }
57
+
58
+ function coalesceSessionStream(existingStream, incomingStream) {
59
+ const incoming = normalizeSessionStream(incomingStream);
60
+ if (!existingStream) {
61
+ return incoming;
62
+ }
63
+
64
+ const existing = normalizeSessionStream(existingStream);
65
+ if (incoming.replace) {
66
+ if (
67
+ !existing.runtimeEpoch ||
68
+ !incoming.runtimeEpoch ||
69
+ existing.runtimeEpoch !== incoming.runtimeEpoch
70
+ ) {
71
+ return incoming;
72
+ }
73
+ const existingRevision = finiteNumber(existing.stateRevision);
74
+ const incomingRevision = finiteNumber(incoming.stateRevision);
75
+ if (
76
+ existing.replace &&
77
+ existingRevision !== undefined &&
78
+ incomingRevision !== undefined &&
79
+ existingRevision > incomingRevision
80
+ ) {
81
+ return existing;
82
+ }
83
+ if (incomingRevision === undefined) return incoming;
84
+ const uncoveredChunks = (existing.chunks || []).filter(chunk => (
85
+ chunk.runtimeEpoch === incoming.runtimeEpoch &&
86
+ Number.isFinite(chunk.stateRevision) &&
87
+ chunk.stateRevision > incomingRevision
88
+ ));
89
+ return {
90
+ ...incoming,
91
+ chunks: uncoveredChunks.length > 0 ? uncoveredChunks : undefined,
92
+ };
93
+ }
94
+
95
+ if (
96
+ !existing.runtimeEpoch ||
97
+ !incoming.runtimeEpoch ||
98
+ existing.runtimeEpoch !== incoming.runtimeEpoch
99
+ ) {
100
+ return incoming;
101
+ }
102
+
103
+ const incomingHasProof = incoming.outputSeq !== undefined && incoming.stateRevision !== undefined;
104
+ if (!incomingHasProof) {
105
+ // Do not let unproved bytes inherit a previous checkpoint's proof. The
106
+ // browser will enter its checkpoint barrier when it receives this stream.
107
+ return incoming;
108
+ }
109
+
110
+ if (existing.replace) {
111
+ return {
112
+ ...existing,
113
+ data: existing.data,
114
+ replace: true,
115
+ cols: existing.cols,
116
+ rows: existing.rows,
117
+ chunks: [
118
+ ...(existing.chunks || []),
119
+ ...(incoming.chunks || []),
120
+ ],
121
+ };
122
+ }
123
+
124
+ return {
125
+ ...existing,
126
+ ...incoming,
127
+ data: `${existing.data}${incoming.data}`,
128
+ replace: false,
129
+ runtimeEpoch: incoming.runtimeEpoch,
130
+ outputSeq: incoming.outputSeq,
131
+ stateRevision: incoming.stateRevision,
132
+ cols: incoming.cols ?? existing.cols,
133
+ rows: incoming.rows ?? existing.rows,
134
+ chunks: [
135
+ ...(existing.chunks || []),
136
+ ...(incoming.chunks || []),
137
+ ],
138
+ };
139
+ }
140
+
141
+ function shouldBroadcastSessionStreamImmediately(options = {}) {
142
+ const pendingCount = Number.isFinite(options.pendingCount) ? options.pendingCount : 0;
143
+ const lastBroadcastAt = Number.isFinite(options.lastBroadcastAt) ? options.lastBroadcastAt : 0;
144
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
145
+ const intervalMs = Number.isFinite(options.intervalMs) ? Math.max(0, options.intervalMs) : 0;
146
+ return pendingCount === 0 && (lastBroadcastAt === 0 || now - lastBroadcastAt >= intervalMs);
147
+ }
148
+
149
+ function deliverSessionStreamToClients(clients, stream, options = {}) {
150
+ const openState = options.openState ?? 1;
151
+ const maxBufferedAmount = Number.isFinite(options.maxBufferedAmount)
152
+ ? options.maxBufferedAmount
153
+ : 4 * 1024 * 1024;
154
+ const message = options.message || JSON.stringify({
155
+ type: 'session-output',
156
+ stream,
157
+ });
158
+ const result = { sent: 0, closed: 0, skipped: 0 };
159
+
160
+ for (const client of clients || []) {
161
+ if (!client || client.readyState !== openState) {
162
+ result.skipped += 1;
163
+ continue;
164
+ }
165
+ if (client.streamScope === 'focused' && client.focusedAgentId !== stream.agentId) {
166
+ result.skipped += 1;
167
+ continue;
168
+ }
169
+ if (client.bufferedAmount > maxBufferedAmount) {
170
+ client.close(1013, 'terminal stream backpressure');
171
+ result.closed += 1;
172
+ continue;
173
+ }
174
+ client.send(message);
175
+ result.sent += 1;
176
+ }
177
+ return result;
178
+ }
179
+
180
+ module.exports = {
181
+ coalesceSessionStream,
182
+ deliverSessionStreamToClients,
183
+ normalizeSessionStream,
184
+ shouldBroadcastSessionStreamImmediately,
185
+ };
@@ -22,6 +22,10 @@ function sessionsDir(configDir) {
22
22
  return path.join(configDir, 'sessions');
23
23
  }
24
24
 
25
+ function acpCheckpointsDir(configDir) {
26
+ return path.join(sessionsDir(configDir), 'acp-checkpoints');
27
+ }
28
+
25
29
  function historyDir(configDir) {
26
30
  return path.join(configDir, 'history');
27
31
  }
@@ -58,6 +62,22 @@ function nativePtyHostLogFile(configDir) {
58
62
  return path.join(configDir, 'native-pty-host.log');
59
63
  }
60
64
 
65
+ function nativePtyControllerGenerationFile(configDir) {
66
+ return path.join(configDir, 'native-pty-controller-generation');
67
+ }
68
+
69
+ function nativePtyControllerGenerationLockDir(configDir) {
70
+ return path.join(configDir, '.native-pty-controller-generation.lock');
71
+ }
72
+
73
+ function nativePtyRuntimeGenerationFile(configDir) {
74
+ return path.join(configDir, 'native-pty-runtime-generation');
75
+ }
76
+
77
+ function nativePtyRuntimeGenerationLockDir(configDir) {
78
+ return path.join(configDir, '.native-pty-runtime-generation.lock');
79
+ }
80
+
61
81
  function updateStateFile(configDir) {
62
82
  return path.join(configDir, 'farming-update.json');
63
83
  }
@@ -66,6 +86,30 @@ function updateLogFile(configDir) {
66
86
  return path.join(configDir, 'farming-update.log');
67
87
  }
68
88
 
89
+ function farmingNetInstancesFile(configDir) {
90
+ return path.join(configDir, 'instances.json');
91
+ }
92
+
93
+ function farmingNetServerStateFile(configDir) {
94
+ return path.join(configDir, 'farming-net-server.json');
95
+ }
96
+
97
+ function farmingNetServerLogFile(configDir) {
98
+ return path.join(configDir, 'farming-net-server.log');
99
+ }
100
+
101
+ function farmingNetSigningPrivateKeyFile(configDir) {
102
+ return path.join(configDir, 'signing-private-key.pem');
103
+ }
104
+
105
+ function farmingNetSigningPublicKeyFile(configDir) {
106
+ return path.join(configDir, 'signing-public-key.pem');
107
+ }
108
+
109
+ function farmingNetTrustFile(configDir) {
110
+ return path.join(configDir, 'farming-net-trust.json');
111
+ }
112
+
69
113
  function codexAppServerRuntimeDir(configDir) {
70
114
  return path.join(configDir, 'c');
71
115
  }
@@ -80,11 +124,22 @@ function codexAppServerAgentHome(configDir, agentId) {
80
124
  }
81
125
 
82
126
  module.exports = {
127
+ acpCheckpointsDir,
83
128
  codexAppServerAgentHome,
84
129
  codexAppServerRuntimeDir,
130
+ farmingNetInstancesFile,
131
+ farmingNetServerLogFile,
132
+ farmingNetServerStateFile,
133
+ farmingNetSigningPrivateKeyFile,
134
+ farmingNetSigningPublicKeyFile,
135
+ farmingNetTrustFile,
85
136
  farmingConfigDir,
86
137
  historyDir,
138
+ nativePtyControllerGenerationFile,
139
+ nativePtyControllerGenerationLockDir,
87
140
  nativePtyHostLogFile,
141
+ nativePtyRuntimeGenerationFile,
142
+ nativePtyRuntimeGenerationLockDir,
88
143
  runHistoryFile,
89
144
  reviewSessionsFile,
90
145
  reviewStateFile,
@@ -0,0 +1,74 @@
1
+ const DEFAULT_TERMINAL_ATTACH_CHECKPOINT_TIMEOUT_MS = 2000;
2
+
3
+ function finiteNonNegativeInteger(value) {
4
+ const parsed = Number(value);
5
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : null;
6
+ }
7
+
8
+ function finitePositiveInteger(value) {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
11
+ }
12
+
13
+ async function captureTerminalAttachCheckpoint(session, options = {}) {
14
+ if (session?.finalCheckpoint) {
15
+ return { ...session.finalCheckpoint };
16
+ }
17
+ if (
18
+ !session
19
+ || !session.screenWorker
20
+ || session.stateProofAvailable === false
21
+ || typeof session.runtimeEpoch !== 'string'
22
+ || !session.runtimeEpoch
23
+ ) {
24
+ return null;
25
+ }
26
+
27
+ const timeoutMs = Number.isFinite(options.timeoutMs)
28
+ ? Math.max(1, Math.floor(options.timeoutMs))
29
+ : DEFAULT_TERMINAL_ATTACH_CHECKPOINT_TIMEOUT_MS;
30
+ const state = await session.screenWorker.getState({ timeoutMs }).catch(() => null);
31
+ const outputSeq = finiteNonNegativeInteger(state?.outputSeq);
32
+ const stateRevision = finiteNonNegativeInteger(state?.stateRevision);
33
+ const currentOutputSeq = finiteNonNegativeInteger(session.outputSeq);
34
+ const currentStateRevision = finiteNonNegativeInteger(session.stateRevision);
35
+ const cols = finitePositiveInteger(state?.cols);
36
+ const rows = finitePositiveInteger(state?.rows);
37
+ const requireCurrentCut = options.requireCurrentCut === true;
38
+
39
+ if (
40
+ !state
41
+ || state.runtimeEpoch !== session.runtimeEpoch
42
+ || outputSeq === null
43
+ || stateRevision === null
44
+ || currentOutputSeq === null
45
+ || currentStateRevision === null
46
+ || stateRevision < outputSeq
47
+ || outputSeq > currentOutputSeq
48
+ || stateRevision > currentStateRevision
49
+ || (requireCurrentCut && outputSeq !== currentOutputSeq)
50
+ || (requireCurrentCut && stateRevision !== currentStateRevision)
51
+ || cols === null
52
+ || rows === null
53
+ || typeof state.renderOutput !== 'string'
54
+ ) {
55
+ return null;
56
+ }
57
+
58
+ return {
59
+ runtimeEpoch: state.runtimeEpoch,
60
+ renderOutput: state.renderOutput,
61
+ outputSeq,
62
+ stateRevision,
63
+ cols,
64
+ rows,
65
+ previewText: typeof state.previewText === 'string' ? state.previewText : '',
66
+ previewSnapshot: state.previewSnapshot || null,
67
+ title: typeof state.title === 'string' ? state.title : '',
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ DEFAULT_TERMINAL_ATTACH_CHECKPOINT_TIMEOUT_MS,
73
+ captureTerminalAttachCheckpoint,
74
+ };
@@ -0,0 +1,39 @@
1
+ const DEFAULT_TERMINAL_EXIT_DATA_FLUSH_MS = 250;
2
+
3
+ function delay(ms) {
4
+ return new Promise(resolve => setTimeout(resolve, ms));
5
+ }
6
+
7
+ function acceptTerminalExitData(session) {
8
+ if (!session || session.exitDataClosed === true) return false;
9
+ if (session.exitFinalizing === true) {
10
+ session.exitDataGeneration = (session.exitDataGeneration || 0) + 1;
11
+ }
12
+ return true;
13
+ }
14
+
15
+ async function waitForTerminalExitDataQuiescence(session, options = {}) {
16
+ const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
17
+ const flushMs = Number.isFinite(options.flushMs)
18
+ ? Math.max(1, Math.floor(options.flushMs))
19
+ : DEFAULT_TERMINAL_EXIT_DATA_FLUSH_MS;
20
+ session.exitFinalizing = true;
21
+ session.exitDataGeneration = session.exitDataGeneration || 0;
22
+
23
+ for (;;) {
24
+ const observedGeneration = session.exitDataGeneration;
25
+ await delay(flushMs);
26
+ if (!isCurrent()) return false;
27
+ if (session.exitDataGeneration !== observedGeneration) continue;
28
+ // This synchronous cut closes late-data admission before the reducer is
29
+ // drained and its final checkpoint is captured.
30
+ session.exitDataClosed = true;
31
+ return true;
32
+ }
33
+ }
34
+
35
+ module.exports = {
36
+ DEFAULT_TERMINAL_EXIT_DATA_FLUSH_MS,
37
+ acceptTerminalExitData,
38
+ waitForTerminalExitDataQuiescence,
39
+ };
@@ -0,0 +1,97 @@
1
+ const DEFAULT_REDUCER_HIGH_WATERMARK_BYTES = 512 * 1024;
2
+ const DEFAULT_REDUCER_LOW_WATERMARK_BYTES = 64 * 1024;
3
+
4
+ function positiveInteger(value, fallback) {
5
+ const parsed = Math.floor(Number(value));
6
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
7
+ }
8
+
9
+ function createTerminalReducerFlowControl(options = {}) {
10
+ const highWatermarkBytes = positiveInteger(
11
+ options.highWatermarkBytes,
12
+ DEFAULT_REDUCER_HIGH_WATERMARK_BYTES,
13
+ );
14
+ const lowWatermarkBytes = Math.min(
15
+ highWatermarkBytes,
16
+ positiveInteger(options.lowWatermarkBytes, DEFAULT_REDUCER_LOW_WATERMARK_BYTES),
17
+ );
18
+ return {
19
+ pendingBytes: 0,
20
+ paused: false,
21
+ reducerBlocked: false,
22
+ externalBlocked: false,
23
+ highWatermarkBytes,
24
+ lowWatermarkBytes,
25
+ };
26
+ }
27
+
28
+ function ensureTerminalReducerFlowControl(session, options = {}) {
29
+ if (!session || typeof session !== 'object') {
30
+ return createTerminalReducerFlowControl(options);
31
+ }
32
+ if (!session.reducerFlowControl || typeof session.reducerFlowControl !== 'object') {
33
+ session.reducerFlowControl = createTerminalReducerFlowControl(options);
34
+ }
35
+ return session.reducerFlowControl;
36
+ }
37
+
38
+ function terminalReducerDataBytes(data) {
39
+ return Buffer.byteLength(String(data || ''), 'utf8');
40
+ }
41
+
42
+ function reconcileTerminalFlowControl(control, process) {
43
+ const shouldPause = control.reducerBlocked || control.externalBlocked;
44
+ if (shouldPause === control.paused) return null;
45
+ const method = shouldPause ? 'pause' : 'resume';
46
+ if (!process || typeof process[method] !== 'function') {
47
+ return new Error(`Native PTY does not support terminal flow-control ${method}`);
48
+ }
49
+ try {
50
+ process[method]();
51
+ control.paused = shouldPause;
52
+ return null;
53
+ } catch (error) {
54
+ return error instanceof Error ? error : new Error(String(error));
55
+ }
56
+ }
57
+
58
+ function setTerminalExternalFlowControlBlocked(control, process, blocked) {
59
+ control.externalBlocked = blocked === true;
60
+ return reconcileTerminalFlowControl(control, process);
61
+ }
62
+
63
+ function enqueueTerminalReducerData(control, process, data) {
64
+ const bytes = terminalReducerDataBytes(data);
65
+ control.pendingBytes += bytes;
66
+ if (!control.reducerBlocked && control.pendingBytes > control.highWatermarkBytes) {
67
+ control.reducerBlocked = true;
68
+ }
69
+ return { bytes, error: reconcileTerminalFlowControl(control, process) };
70
+ }
71
+
72
+ function acknowledgeTerminalReducerData(control, process, bytes) {
73
+ const acknowledgedBytes = Math.max(0, Math.floor(Number(bytes) || 0));
74
+ control.pendingBytes = Math.max(0, control.pendingBytes - acknowledgedBytes);
75
+ if (control.reducerBlocked && control.pendingBytes < control.lowWatermarkBytes) {
76
+ control.reducerBlocked = false;
77
+ }
78
+ return reconcileTerminalFlowControl(control, process);
79
+ }
80
+
81
+ function resetTerminalReducerFlowControl(control, process) {
82
+ control.pendingBytes = 0;
83
+ control.reducerBlocked = false;
84
+ return reconcileTerminalFlowControl(control, process);
85
+ }
86
+
87
+ module.exports = {
88
+ DEFAULT_REDUCER_HIGH_WATERMARK_BYTES,
89
+ DEFAULT_REDUCER_LOW_WATERMARK_BYTES,
90
+ acknowledgeTerminalReducerData,
91
+ createTerminalReducerFlowControl,
92
+ ensureTerminalReducerFlowControl,
93
+ enqueueTerminalReducerData,
94
+ resetTerminalReducerFlowControl,
95
+ setTerminalExternalFlowControlBlocked,
96
+ terminalReducerDataBytes,
97
+ };
@@ -141,6 +141,7 @@ class TerminalScreenState {
141
141
  this.renderOutput = '';
142
142
  this.previewText = '';
143
143
  this.previewSnapshot = null;
144
+ this.previewDirty = false;
144
145
  this.renderOutputDirty = true;
145
146
  this.pendingWrite = Promise.resolve();
146
147
 
@@ -156,6 +157,7 @@ class TerminalScreenState {
156
157
  this.previewSnapshot = this.includePreviewSnapshot
157
158
  ? collectViewportSnapshot(this.terminal)
158
159
  : null;
160
+ this.previewDirty = false;
159
161
  this.renderOutputDirty = true;
160
162
  }
161
163
 
@@ -185,8 +187,12 @@ class TerminalScreenState {
185
187
  () =>
186
188
  new Promise((resolve) => {
187
189
  this.terminal.write(data, () => {
188
- this.refreshPreview();
189
- resolve(this.getState({ includeRenderOutput: false }));
190
+ this.previewDirty = true;
191
+ this.renderOutputDirty = true;
192
+ resolve(this.getState({
193
+ includeRenderOutput: false,
194
+ refreshPreview: false,
195
+ }));
190
196
  });
191
197
  }),
192
198
  );
@@ -215,6 +221,9 @@ class TerminalScreenState {
215
221
 
216
222
  getState(options = {}) {
217
223
  const includeRenderOutput = options.includeRenderOutput !== false;
224
+ if (options.refreshPreview !== false && this.previewDirty) {
225
+ this.refreshPreview();
226
+ }
218
227
  if (includeRenderOutput && this.renderOutputDirty) {
219
228
  this.refreshRenderOutput();
220
229
  }