nterminal 1.2.78 → 1.2.80

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 (98) hide show
  1. package/SECURITY.md +6 -0
  2. package/dist/client/assets/MarkdownPreview-D2UZ6wJU.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-BkGu6YVq.js → TranscriptMarkdownBody-BFu2FzgT.js} +1 -1
  4. package/dist/client/assets/index-4MeJRcb6.js +57 -0
  5. package/dist/client/assets/{index-Bgqck-vt.js → index-BFs4imuQ.js} +1 -1
  6. package/dist/client/assets/index-rROkcwjD.css +1 -0
  7. package/dist/client/index.html +2 -2
  8. package/dist/scripts/onboarding.js +10 -1
  9. package/dist/scripts/onboarding.js.map +1 -1
  10. package/dist/server/agent/agentProxy.d.ts +7 -2
  11. package/dist/server/agent/agentProxy.js +75 -9
  12. package/dist/server/agent/agentProxy.js.map +1 -1
  13. package/dist/server/agent/agentRoutes.js +159 -33
  14. package/dist/server/agent/agentRoutes.js.map +1 -1
  15. package/dist/server/agent/agentWebSocketProxy.d.ts +10 -1
  16. package/dist/server/agent/agentWebSocketProxy.js +24 -4
  17. package/dist/server/agent/agentWebSocketProxy.js.map +1 -1
  18. package/dist/server/auth/authService.d.ts +18 -4
  19. package/dist/server/auth/authService.js +57 -6
  20. package/dist/server/auth/authService.js.map +1 -1
  21. package/dist/server/eventLoopDelayMonitor.d.ts +11 -0
  22. package/dist/server/eventLoopDelayMonitor.js +37 -0
  23. package/dist/server/eventLoopDelayMonitor.js.map +1 -0
  24. package/dist/server/http.js +11 -4
  25. package/dist/server/http.js.map +1 -1
  26. package/dist/server/index.js +18 -2
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/routes/agentManagementRoutes.d.ts +2 -1
  29. package/dist/server/routes/agentManagementRoutes.js +91 -37
  30. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  31. package/dist/server/routes/authRoutes.js +4 -1
  32. package/dist/server/routes/authRoutes.js.map +1 -1
  33. package/dist/server/routes/authenticatedSocketRegistry.d.ts +25 -0
  34. package/dist/server/routes/authenticatedSocketRegistry.js +171 -0
  35. package/dist/server/routes/authenticatedSocketRegistry.js.map +1 -0
  36. package/dist/server/routes/codeServerRoutes.d.ts +3 -1
  37. package/dist/server/routes/codeServerRoutes.js +139 -26
  38. package/dist/server/routes/codeServerRoutes.js.map +1 -1
  39. package/dist/server/routes/fileRoutes.js +1 -1
  40. package/dist/server/routes/fileRoutes.js.map +1 -1
  41. package/dist/server/routes/httpProxyBody.d.ts +2 -1
  42. package/dist/server/routes/httpProxyBody.js +8 -1
  43. package/dist/server/routes/httpProxyBody.js.map +1 -1
  44. package/dist/server/routes/socketBackpressure.d.ts +2 -0
  45. package/dist/server/routes/socketBackpressure.js +21 -10
  46. package/dist/server/routes/socketBackpressure.js.map +1 -1
  47. package/dist/server/routes/terminalRoutes.d.ts +4 -3
  48. package/dist/server/routes/terminalRoutes.js +62 -14
  49. package/dist/server/routes/terminalRoutes.js.map +1 -1
  50. package/dist/server/routes/terminalSocketMessage.d.ts +34 -0
  51. package/dist/server/routes/terminalSocketMessage.js +188 -0
  52. package/dist/server/routes/terminalSocketMessage.js.map +1 -0
  53. package/dist/server/routes/terminalWebSocket.d.ts +3 -1
  54. package/dist/server/routes/terminalWebSocket.js +191 -116
  55. package/dist/server/routes/terminalWebSocket.js.map +1 -1
  56. package/dist/server/routes/webSocketRelay.d.ts +14 -0
  57. package/dist/server/routes/webSocketRelay.js +188 -18
  58. package/dist/server/routes/webSocketRelay.js.map +1 -1
  59. package/dist/server/routes/webSocketUpgradeAuth.d.ts +5 -0
  60. package/dist/server/routes/webSocketUpgradeAuth.js +21 -0
  61. package/dist/server/routes/webSocketUpgradeAuth.js.map +1 -0
  62. package/dist/server/system/stats.d.ts +1 -1
  63. package/dist/server/system/stats.js +11 -7
  64. package/dist/server/system/stats.js.map +1 -1
  65. package/dist/server/terminal/PtyAdapter.d.ts +25 -8
  66. package/dist/server/terminal/PtyAdapter.js +12 -1
  67. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  68. package/dist/server/terminal/TerminalManager.d.ts +71 -13
  69. package/dist/server/terminal/TerminalManager.js +955 -163
  70. package/dist/server/terminal/TerminalManager.js.map +1 -1
  71. package/dist/server/terminal/TmuxPtyAdapter.d.ts +85 -15
  72. package/dist/server/terminal/TmuxPtyAdapter.js +1364 -403
  73. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  74. package/dist/server/terminal/asyncCommand.d.ts +17 -0
  75. package/dist/server/terminal/asyncCommand.js +60 -0
  76. package/dist/server/terminal/asyncCommand.js.map +1 -0
  77. package/dist/server/terminal/codexTranscriptSource.d.ts +3 -3
  78. package/dist/server/terminal/codexTranscriptSource.js +90 -104
  79. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  80. package/dist/server/terminal/outputBuffer.d.ts +5 -1
  81. package/dist/server/terminal/outputBuffer.js +29 -3
  82. package/dist/server/terminal/outputBuffer.js.map +1 -1
  83. package/dist/server/terminal/procfs.d.ts +7 -8
  84. package/dist/server/terminal/procfs.js +56 -47
  85. package/dist/server/terminal/procfs.js.map +1 -1
  86. package/dist/server/terminal/transcriptHistory.d.ts +1 -1
  87. package/dist/server/terminal/transcriptHistory.js +187 -38
  88. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  89. package/dist/server/terminal/ttlCache.d.ts +1 -0
  90. package/dist/server/terminal/ttlCache.js +3 -0
  91. package/dist/server/terminal/ttlCache.js.map +1 -1
  92. package/dist/shared/protocol.d.ts +24 -0
  93. package/dist/shared/protocol.js +25 -2
  94. package/dist/shared/protocol.js.map +1 -1
  95. package/package.json +1 -1
  96. package/dist/client/assets/MarkdownPreview-D5NthhhG.js +0 -1
  97. package/dist/client/assets/index-B3_mYQzf.css +0 -1
  98. package/dist/client/assets/index-gSePR0Ob.js +0 -57
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import path from 'node:path';
3
+ import { normalizeTerminalTitle, PtySessionUnavailableError } from './PtyAdapter.js';
3
4
  import { resolveProcessCwd } from './cwdResolver.js';
4
5
  import { OutputBuffer } from './outputBuffer.js';
5
6
  import { readTranscriptHistoryPage } from './transcriptHistory.js';
6
7
  import { AgentTranscriptTracker } from './agentTranscriptStatus.js';
8
+ import { mapWithConcurrency } from './asyncCommand.js';
7
9
  // Window for the output-rate component of busy detection. Slightly larger
8
10
  // than the poll interval so a tab that just stopped emitting output
9
11
  // transitions to idle on the very next poll (rather than lagging a tick),
@@ -16,9 +18,16 @@ const BUSY_OUTPUT_WINDOW_MS = 2_500;
16
18
  // before a "command completed" notification reaches the operator at 2s,
17
19
  // which is fast enough to feel responsive without spamming polls.
18
20
  const STATUS_POLL_INTERVAL_MS = 2_000;
21
+ const RESTORE_RETRY_INTERVAL_MS = 2_000;
22
+ const TRANSCRIPT_POLL_CONCURRENCY = 2;
23
+ const TRANSCRIPT_SOURCE_MISS_GRACE_MS = 10_000;
24
+ const TRANSCRIPT_SLOW_WARNING_MS = 3_000;
25
+ const DIAGNOSTIC_REPORT_INTERVAL_MS = 30_000;
26
+ const DESTROY_ALL_CONCURRENCY = 4;
19
27
  const HISTORY_PAGE_LINES = 1000;
20
28
  const HISTORY_PAGE_MAX_LINES = 5000;
21
- const HISTORY_CURSOR_PROBE_LINES = 1;
29
+ const MAX_PENDING_CONTROL_OPERATIONS = 256;
30
+ const MAX_PENDING_CONTROL_BYTES = 16 * 1024 * 1024;
22
31
  export class TerminalManager {
23
32
  config;
24
33
  ptyAdapter;
@@ -26,21 +35,33 @@ export class TerminalManager {
26
35
  resolveCwd;
27
36
  now;
28
37
  onCompletion;
38
+ onDiagnostic;
29
39
  // Captured at construction so every spawn can compare a poll's
30
40
  // pane_current_command against the configured shell to decide "is this
31
41
  // terminal idle at a prompt or busy running something else?".
32
42
  shellBasename;
33
43
  terminalCapabilities;
34
44
  agentTranscriptTracker = new AgentTranscriptTracker();
45
+ transcriptQueue = [];
46
+ lastDiagnosticAt = new Map();
35
47
  completionStates = new Map();
36
48
  statusPollTimer = null;
49
+ restoreRetryTimer = null;
50
+ restoreInflight = null;
37
51
  statusPollInflight = false;
52
+ activeTranscriptWorkers = 0;
53
+ pendingInitializations = new Set();
54
+ terminalResetBlockers = 0;
55
+ terminalMutationGeneration = 0;
56
+ shutdownRequested = false;
57
+ disposed = false;
38
58
  constructor(config, ptyAdapter, options = {}) {
39
59
  this.config = config;
40
60
  this.ptyAdapter = ptyAdapter;
41
61
  this.resolveCwd = options.resolveCwd ?? resolveProcessCwd;
42
62
  this.now = options.now ?? (() => new Date());
43
63
  this.onCompletion = options.onCompletion ?? null;
64
+ this.onDiagnostic = options.onDiagnostic ?? null;
44
65
  this.shellBasename = path.basename(this.config.shell);
45
66
  this.terminalCapabilities =
46
67
  typeof this.ptyAdapter.scrollbackScroll === 'function' &&
@@ -58,86 +79,244 @@ export class TerminalManager {
58
79
  if (this.statusPollInflight)
59
80
  return;
60
81
  this.statusPollInflight = true;
61
- void this.pollOnce().finally(() => {
62
- this.statusPollInflight = false;
63
- });
82
+ void this.runStatusPoll();
64
83
  }, STATUS_POLL_INTERVAL_MS);
65
84
  // Unref so the interval doesn't keep the Node event loop alive on its
66
85
  // own — the HTTP server is what should be holding the process up.
67
86
  this.statusPollTimer.unref?.();
68
87
  }
88
+ async runStatusPoll() {
89
+ try {
90
+ await this.pollOnce();
91
+ }
92
+ catch (error) {
93
+ this.reportDiagnostic({ event: 'terminal.status_poll_failed', error });
94
+ }
95
+ finally {
96
+ this.statusPollInflight = false;
97
+ }
98
+ }
69
99
  async pollOnce() {
70
100
  const poll = this.ptyAdapter.pollSessions;
71
101
  if (!poll)
72
102
  return;
73
- let result;
74
- try {
75
- result = await poll.call(this.ptyAdapter);
76
- }
77
- catch {
78
- return;
79
- }
103
+ const result = await poll.call(this.ptyAdapter);
80
104
  const now = Date.now();
81
- for (const [terminalId, { currentCommand, busy }] of result) {
82
- const record = this.terminals.get(terminalId);
83
- if (!record || record.summary.status !== 'running')
84
- continue;
85
- // Two complementary signals for "is this tab working":
86
- // - kernel run state: catches CPU-bound work like npm install,
87
- // vim :%s, codex executing a tool call locally
88
- // - PTY output rate: catches I/O-bound work like codex streaming
89
- // tokens or claude printing a response — those processes spend
90
- // most of their time sleeping in epoll_wait (state S) but
91
- // output is steadily flowing, which is what the operator
92
- // actually means by "it's working"
93
- // OR them together so either signal alone is enough.
94
- const recentOutput = now - record.lastOutputAt < BUSY_OUTPUT_WINDOW_MS;
95
- const nextCommand = currentCommand === this.shellBasename ? null : currentCommand;
96
- const nextAgentTranscript = await this.resolveAgentTranscript(terminalId, nextCommand);
97
- const nextAgentStatus = nextAgentTranscript?.status;
98
- const nextAgentCache = nextAgentTranscript?.cache;
99
- const nextActivity = activityFromSignals({ busy, recentOutput, agentStatus: nextAgentStatus });
100
- if (record.summary.activity === nextActivity &&
101
- record.summary.currentCommand === nextCommand &&
102
- agentStatusEquals(record.summary.agentStatus, nextAgentStatus) &&
103
- agentCacheEquals(record.summary.agentCache, nextAgentCache)) {
104
- continue;
105
+ for (const [terminalId, snapshot] of result) {
106
+ try {
107
+ this.applyProcessSnapshot(terminalId, snapshot, now);
105
108
  }
106
- record.summary.activity = nextActivity;
107
- record.summary.currentCommand = nextCommand;
108
- if (nextAgentStatus) {
109
- record.summary.agentStatus = nextAgentStatus;
109
+ catch (error) {
110
+ this.reportDiagnostic({ event: 'terminal.process_snapshot_failed', terminalId, error });
110
111
  }
111
- else {
112
- delete record.summary.agentStatus;
112
+ }
113
+ for (const record of this.terminals.values()) {
114
+ if (record.summary.status === 'running' &&
115
+ !record.closing &&
116
+ record.pty === null &&
117
+ !result.has(record.summary.id)) {
118
+ this.markSessionUnavailable(record, null);
113
119
  }
114
- if (nextAgentCache) {
115
- record.summary.agentCache = nextAgentCache;
120
+ }
121
+ }
122
+ applyProcessSnapshot(terminalId, snapshot, now) {
123
+ const record = this.terminals.get(terminalId);
124
+ if (!record || record.summary.status !== 'running' || record.closing)
125
+ return;
126
+ // Kernel run state catches CPU/I/O work; recent PTY output catches agents
127
+ // that spend most of a streaming turn asleep in epoll_wait. These fresh
128
+ // signals take priority over a transcript's previous completed state.
129
+ const recentOutput = now - record.lastOutputAt < BUSY_OUTPUT_WINDOW_MS;
130
+ const nextCommand = snapshot.currentCommand === this.shellBasename ? null : snapshot.currentCommand;
131
+ const commandChanged = record.summary.currentCommand !== nextCommand;
132
+ if (commandChanged) {
133
+ record.transcriptGeneration += 1;
134
+ }
135
+ record.lastKernelBusy = snapshot.busy;
136
+ let summaryChanged = commandChanged;
137
+ record.summary.currentCommand = nextCommand;
138
+ if (!nextCommand) {
139
+ this.agentTranscriptTracker.reset(terminalId);
140
+ record.transcriptSourceMissingSince = null;
141
+ if (record.summary.agentStatus) {
142
+ delete record.summary.agentStatus;
143
+ summaryChanged = true;
116
144
  }
117
- else {
145
+ if (record.summary.agentCache) {
118
146
  delete record.summary.agentCache;
147
+ summaryChanged = true;
119
148
  }
149
+ }
150
+ const nextActivity = activityFromSignals({
151
+ busy: snapshot.busy,
152
+ recentOutput,
153
+ agentStatus: record.summary.agentStatus
154
+ });
155
+ if (record.summary.activity !== nextActivity) {
156
+ record.summary.activity = nextActivity;
157
+ summaryChanged = true;
158
+ }
159
+ if (summaryChanged) {
120
160
  record.summary.updatedAt = this.timestamp();
121
161
  this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
122
162
  this.trackCompletion(record.summary);
123
163
  }
164
+ if (nextCommand) {
165
+ this.scheduleTranscriptRefresh(terminalId);
166
+ }
167
+ else {
168
+ record.transcriptRerun = false;
169
+ this.cancelQueuedTranscriptRefresh(record);
170
+ }
171
+ }
172
+ scheduleTranscriptRefresh(terminalId) {
173
+ if (this.disposed)
174
+ return;
175
+ const record = this.terminals.get(terminalId);
176
+ if (!record || record.summary.status !== 'running' || record.closing || !record.summary.currentCommand)
177
+ return;
178
+ if (record.transcriptRefreshState === 'running') {
179
+ record.transcriptRerun = true;
180
+ return;
181
+ }
182
+ if (record.transcriptRefreshState === 'queued')
183
+ return;
184
+ record.transcriptRefreshState = 'queued';
185
+ this.transcriptQueue.push(terminalId);
186
+ this.pumpTranscriptQueue();
187
+ }
188
+ pumpTranscriptQueue() {
189
+ while (!this.disposed &&
190
+ this.activeTranscriptWorkers < TRANSCRIPT_POLL_CONCURRENCY &&
191
+ this.transcriptQueue.length > 0) {
192
+ const terminalId = this.transcriptQueue.shift();
193
+ const record = this.terminals.get(terminalId);
194
+ if (!record || record.transcriptRefreshState !== 'queued')
195
+ continue;
196
+ record.transcriptRefreshState = 'running';
197
+ this.activeTranscriptWorkers += 1;
198
+ void this.refreshAgentTranscript(terminalId)
199
+ .catch((error) => {
200
+ this.reportDiagnostic({ event: 'terminal.transcript_poll_failed', terminalId, error });
201
+ })
202
+ .finally(() => {
203
+ this.activeTranscriptWorkers -= 1;
204
+ if (this.terminals.get(terminalId) === record) {
205
+ record.transcriptRefreshState = 'idle';
206
+ const rerun = record.transcriptRerun;
207
+ record.transcriptRerun = false;
208
+ if (rerun && record.summary.status === 'running' && !record.closing && record.summary.currentCommand) {
209
+ this.scheduleTranscriptRefresh(terminalId);
210
+ }
211
+ }
212
+ this.pumpTranscriptQueue();
213
+ });
214
+ }
215
+ }
216
+ async refreshAgentTranscript(terminalId) {
217
+ const startedAt = Date.now();
218
+ const slowTimer = setTimeout(() => {
219
+ this.reportDiagnostic({
220
+ event: 'terminal.transcript_poll_slow',
221
+ terminalId,
222
+ queueDepth: this.transcriptQueue.length,
223
+ elapsedMs: Date.now() - startedAt
224
+ });
225
+ }, TRANSCRIPT_SLOW_WARNING_MS);
226
+ slowTimer.unref?.();
227
+ try {
228
+ await this.refreshAgentTranscriptOnce(terminalId);
229
+ }
230
+ finally {
231
+ clearTimeout(slowTimer);
232
+ }
233
+ }
234
+ async refreshAgentTranscriptOnce(terminalId) {
235
+ const record = this.terminals.get(terminalId);
236
+ const currentCommand = record?.summary.currentCommand ?? null;
237
+ if (!record || record.summary.status !== 'running' || record.closing || !currentCommand)
238
+ return;
239
+ const generation = record.transcriptGeneration;
240
+ const isCurrent = () => !this.disposed &&
241
+ this.terminals.get(terminalId) === record &&
242
+ record.summary.status === 'running' &&
243
+ !record.closing &&
244
+ record.transcriptGeneration === generation &&
245
+ record.summary.currentCommand === currentCommand;
246
+ const transcript = await this.resolveAgentTranscript(terminalId, currentCommand, isCurrent);
247
+ if (transcript === null || !isCurrent())
248
+ return;
249
+ const nextAgentStatus = transcript?.status;
250
+ const nextAgentCache = transcript?.cache;
251
+ if (agentSessionChanged(record.summary.agentStatus?.sessionId ?? null, nextAgentStatus?.sessionId ?? null)) {
252
+ record.transcriptGeneration += 1;
253
+ }
254
+ const nextActivity = activityFromSignals({
255
+ busy: record.lastKernelBusy,
256
+ recentOutput: Date.now() - record.lastOutputAt < BUSY_OUTPUT_WINDOW_MS,
257
+ agentStatus: nextAgentStatus
258
+ });
259
+ if (record.summary.activity === nextActivity &&
260
+ agentStatusEquals(record.summary.agentStatus, nextAgentStatus) &&
261
+ agentCacheEquals(record.summary.agentCache, nextAgentCache)) {
262
+ return;
263
+ }
264
+ record.summary.activity = nextActivity;
265
+ if (nextAgentStatus) {
266
+ record.summary.agentStatus = nextAgentStatus;
267
+ }
268
+ else {
269
+ delete record.summary.agentStatus;
270
+ }
271
+ if (nextAgentCache) {
272
+ record.summary.agentCache = nextAgentCache;
273
+ }
274
+ else {
275
+ delete record.summary.agentCache;
276
+ }
277
+ record.summary.updatedAt = this.timestamp();
278
+ this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
279
+ this.trackCompletion(record.summary);
124
280
  }
125
281
  // Allow the process to shut down cleanly without leaking the polling
126
282
  // interval — called by the server's graceful-shutdown path.
127
283
  dispose() {
284
+ this.disposed = true;
285
+ this.shutdownRequested = true;
128
286
  if (this.statusPollTimer) {
129
287
  clearInterval(this.statusPollTimer);
130
288
  this.statusPollTimer = null;
131
289
  }
290
+ this.clearRestoreRetryTimer();
291
+ this.transcriptQueue.length = 0;
292
+ for (const record of this.terminals.values()) {
293
+ record.transcriptRerun = false;
294
+ if (record.transcriptRefreshState === 'queued') {
295
+ record.transcriptRefreshState = 'idle';
296
+ }
297
+ }
132
298
  }
133
299
  async createTerminal(options = {}) {
300
+ if (this.shutdownRequested || this.terminalResetBlockers > 0) {
301
+ throw new Error('terminal manager is not accepting new terminals');
302
+ }
303
+ const initialization = this.createTerminalUntracked(options);
304
+ this.pendingInitializations.add(initialization);
305
+ try {
306
+ return await initialization;
307
+ }
308
+ finally {
309
+ this.pendingInitializations.delete(initialization);
310
+ }
311
+ }
312
+ async createTerminalUntracked(options) {
134
313
  const cwd = await this.resolveInitialCwd(options);
135
314
  const cols = normalizeDimension(options.cols, 80);
136
315
  const rows = normalizeDimension(options.rows, 24);
137
316
  const id = randomUUID();
138
317
  const timestamp = this.timestamp();
139
318
  const initialTitle = this.createTitle(cwd);
140
- const pty = this.ptyAdapter.spawn({
319
+ const pty = await this.ptyAdapter.spawn({
141
320
  sessionId: id,
142
321
  shell: this.config.shell,
143
322
  cwd,
@@ -170,7 +349,19 @@ export class TerminalManager {
170
349
  exitDisposable: { dispose: () => undefined },
171
350
  pauseCount: 0,
172
351
  lastOutputAt: 0,
173
- history: null
352
+ history: null,
353
+ lastKernelBusy: false,
354
+ transcriptGeneration: 0,
355
+ transcriptRefreshState: 'idle',
356
+ transcriptRerun: false,
357
+ transcriptSourceMissingSince: null,
358
+ cwdRefreshGeneration: 0,
359
+ controlTail: Promise.resolve(),
360
+ pendingControlOperations: 0,
361
+ pendingControlBytes: 0,
362
+ pendingScroll: null,
363
+ closePromise: null,
364
+ closing: false
174
365
  };
175
366
  this.wireUpPty(record, pty);
176
367
  this.terminals.set(id, record);
@@ -182,7 +373,57 @@ export class TerminalManager {
182
373
  // reattach to their live shells instead of being silently replaced by
183
374
  // fresh ones. A no-op for adapters without persistence (NodePtyAdapter).
184
375
  async restoreSessions() {
185
- const existing = this.ptyAdapter.listExisting?.() ?? [];
376
+ if (this.shutdownRequested || this.terminalResetBlockers > 0) {
377
+ return;
378
+ }
379
+ if (this.ptyAdapter.listExisting &&
380
+ (!this.ptyAdapter.attachExisting || !this.ptyAdapter.destroySession)) {
381
+ this.reportDiagnostic({
382
+ event: 'terminal.restore_failed',
383
+ error: new Error('persistent adapter must implement attachExisting and destroySession')
384
+ });
385
+ return;
386
+ }
387
+ if (this.restoreInflight) {
388
+ await this.restoreInflight;
389
+ return;
390
+ }
391
+ const initialization = this.restoreSessionsUntracked();
392
+ this.restoreInflight = initialization;
393
+ this.pendingInitializations.add(initialization);
394
+ let restored = false;
395
+ try {
396
+ restored = await initialization;
397
+ }
398
+ finally {
399
+ this.pendingInitializations.delete(initialization);
400
+ if (this.restoreInflight === initialization) {
401
+ this.restoreInflight = null;
402
+ }
403
+ }
404
+ if (restored) {
405
+ this.clearRestoreRetryTimer();
406
+ }
407
+ else {
408
+ this.scheduleRestoreRetry();
409
+ }
410
+ }
411
+ async restoreSessionsUntracked() {
412
+ const restoreGeneration = this.terminalMutationGeneration;
413
+ let existing;
414
+ try {
415
+ existing = await (this.ptyAdapter.listExisting?.() ?? []);
416
+ }
417
+ catch (error) {
418
+ this.reportDiagnostic({ event: 'terminal.restore_failed', error });
419
+ return false;
420
+ }
421
+ // A close/reset that completed while listExisting() was in flight makes
422
+ // that catalog stale. Retry from a fresh adapter snapshot instead of
423
+ // resurrecting a session the operator just deleted.
424
+ if (restoreGeneration !== this.terminalMutationGeneration) {
425
+ return false;
426
+ }
186
427
  const now = this.timestamp();
187
428
  for (const session of existing) {
188
429
  const sessionId = session.sessionId;
@@ -192,12 +433,12 @@ export class TerminalManager {
192
433
  // operator had renamed the tab in a previous run — that's how a
193
434
  // human-readable label survives NTerminal restarts. Fall back to a
194
435
  // distinguishable placeholder when no name was set.
195
- const restoredTitle = session.title?.trim() || `Terminal ${sessionId.slice(0, 6)}`;
436
+ const restoredTitle = normalizeTerminalTitle(session.title?.trim() || `Terminal ${sessionId.slice(0, 6)}`);
196
437
  this.terminals.set(sessionId, {
197
438
  summary: {
198
439
  id: sessionId,
199
440
  title: restoredTitle,
200
- cwd: session.cwd?.trim() || this.config.workspaceRoot,
441
+ cwd: session.cwd || this.config.workspaceRoot,
201
442
  pid: null,
202
443
  cols: 80,
203
444
  rows: 24,
@@ -214,13 +455,53 @@ export class TerminalManager {
214
455
  exitDisposable: { dispose: () => undefined },
215
456
  pauseCount: 0,
216
457
  lastOutputAt: 0,
217
- history: null
458
+ history: null,
459
+ lastKernelBusy: false,
460
+ transcriptGeneration: 0,
461
+ transcriptRefreshState: 'idle',
462
+ transcriptRerun: false,
463
+ transcriptSourceMissingSince: null,
464
+ cwdRefreshGeneration: 0,
465
+ controlTail: Promise.resolve(),
466
+ pendingControlOperations: 0,
467
+ pendingControlBytes: 0,
468
+ pendingScroll: null,
469
+ closePromise: null,
470
+ closing: false
218
471
  });
219
472
  }
473
+ return true;
474
+ }
475
+ scheduleRestoreRetry() {
476
+ if (this.restoreRetryTimer || this.shutdownRequested || this.disposed) {
477
+ return;
478
+ }
479
+ this.restoreRetryTimer = setTimeout(() => {
480
+ this.restoreRetryTimer = null;
481
+ if (this.shutdownRequested || this.disposed) {
482
+ return;
483
+ }
484
+ if (this.terminalResetBlockers > 0) {
485
+ this.scheduleRestoreRetry();
486
+ return;
487
+ }
488
+ void this.restoreSessions();
489
+ }, RESTORE_RETRY_INTERVAL_MS);
490
+ this.restoreRetryTimer.unref?.();
491
+ }
492
+ clearRestoreRetryTimer() {
493
+ if (!this.restoreRetryTimer) {
494
+ return;
495
+ }
496
+ clearTimeout(this.restoreRetryTimer);
497
+ this.restoreRetryTimer = null;
220
498
  }
221
499
  wireUpPty(record, pty) {
222
500
  const id = record.summary.id;
223
501
  record.pty = pty;
502
+ if (record.pauseCount > 0) {
503
+ pty.pause();
504
+ }
224
505
  record.summary.pid = pty.pid;
225
506
  record.summary.status = 'running';
226
507
  record.summary.exitCode = null;
@@ -232,21 +513,111 @@ export class TerminalManager {
232
513
  this.publish(record, { type: 'output', terminalId: id, data });
233
514
  });
234
515
  record.exitDisposable = pty.onExit((event) => {
235
- record.summary.status = 'exited';
236
- record.summary.exitCode = event.exitCode;
516
+ if (record.pty !== pty) {
517
+ return;
518
+ }
519
+ record.dataDisposable.dispose();
520
+ record.exitDisposable.dispose();
521
+ record.transcriptGeneration += 1;
522
+ record.transcriptRerun = false;
523
+ this.cancelQueuedTranscriptRefresh(record);
237
524
  record.summary.pid = null;
238
525
  record.summary.updatedAt = this.timestamp();
239
- delete record.summary.agentCache;
240
526
  record.pty = null;
241
- this.completionStates.delete(id);
242
- this.publish(record, { type: 'exit', terminalId: id, exitCode: event.exitCode });
527
+ this.finishPtyExit(record, event.exitCode);
243
528
  });
244
529
  }
530
+ finishPtyExit(record, exitCode) {
531
+ const id = record.summary.id;
532
+ const persistentState = record.closing ? false : this.persistentSessionState(id);
533
+ if (isPromiseLike(persistentState)) {
534
+ void persistentState.then((state) => {
535
+ this.applyPtyExitState(record, exitCode, state);
536
+ });
537
+ return;
538
+ }
539
+ this.applyPtyExitState(record, exitCode, persistentState);
540
+ }
541
+ applyPtyExitState(record, exitCode, persistentState) {
542
+ const id = record.summary.id;
543
+ if (this.terminals.get(id) !== record ||
544
+ record.pty !== null ||
545
+ record.closing ||
546
+ record.summary.status !== 'running') {
547
+ return;
548
+ }
549
+ if (persistentState !== false) {
550
+ record.summary.status = 'running';
551
+ record.summary.exitCode = null;
552
+ if (record.subscribers.size > 0) {
553
+ try {
554
+ this.reattachToSession(record);
555
+ }
556
+ catch (error) {
557
+ if (error instanceof PtySessionUnavailableError) {
558
+ this.markSessionUnavailable(record, exitCode);
559
+ return;
560
+ }
561
+ // Keep the live backing session lazily reattachable. A later
562
+ // browser reconnect can retry after a transient adapter failure.
563
+ }
564
+ }
565
+ record.summary.updatedAt = this.timestamp();
566
+ this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
567
+ return;
568
+ }
569
+ this.markSessionUnavailable(record, exitCode);
570
+ }
571
+ persistentSessionState(sessionId) {
572
+ if (!this.ptyAdapter.listExisting) {
573
+ return false;
574
+ }
575
+ try {
576
+ const existing = this.ptyAdapter.listExisting();
577
+ if (isPromiseLike(existing)) {
578
+ return existing
579
+ .then((sessions) => sessions.some((session) => session.sessionId === sessionId))
580
+ .catch(() => null);
581
+ }
582
+ return existing.some((session) => session.sessionId === sessionId);
583
+ }
584
+ catch {
585
+ return null;
586
+ }
587
+ }
588
+ markSessionUnavailable(record, exitCode) {
589
+ if (this.terminals.get(record.summary.id) !== record || record.closing) {
590
+ return;
591
+ }
592
+ if (record.summary.status === 'exited') {
593
+ if (record.summary.exitCode === null && exitCode !== null) {
594
+ record.summary.exitCode = exitCode;
595
+ record.summary.updatedAt = this.timestamp();
596
+ }
597
+ return;
598
+ }
599
+ const id = record.summary.id;
600
+ record.summary.pid = null;
601
+ record.summary.status = 'exited';
602
+ record.summary.exitCode = exitCode;
603
+ record.summary.updatedAt = this.timestamp();
604
+ record.history = null;
605
+ delete record.summary.activity;
606
+ delete record.summary.currentCommand;
607
+ delete record.summary.agentStatus;
608
+ delete record.summary.agentCache;
609
+ this.agentTranscriptTracker.reset(id);
610
+ this.completionStates.delete(id);
611
+ this.publish(record, { type: 'exit', terminalId: id, exitCode });
612
+ }
245
613
  // Spawn a fresh local client for a restored session that hasn't been
246
614
  // attached since startup. Called lazily on the first attachTerminal so
247
615
  // sessions the operator never reopens don't consume processes.
248
616
  reattachToSession(record) {
249
- const pty = this.ptyAdapter.spawn({
617
+ if (!this.ptyAdapter.attachExisting) {
618
+ throw new Error('persistent adapter cannot attach an existing session');
619
+ }
620
+ const pty = this.ptyAdapter.attachExisting({
250
621
  sessionId: record.summary.id,
251
622
  shell: this.config.shell,
252
623
  cwd: record.summary.cwd,
@@ -256,23 +627,6 @@ export class TerminalManager {
256
627
  });
257
628
  this.wireUpPty(record, pty);
258
629
  }
259
- captureTerminalHistoryCursor(record) {
260
- const capture = this.ptyAdapter.captureHistory?.(record.summary.id, {
261
- beforeLine: 0,
262
- limit: HISTORY_CURSOR_PROBE_LINES
263
- });
264
- if (!capture) {
265
- return null;
266
- }
267
- if (!capture.hasMore && capture.endLine < capture.startLine) {
268
- return null;
269
- }
270
- return {
271
- startLine: 0,
272
- endLine: 0,
273
- hasMore: capture.hasMore || capture.endLine >= capture.startLine
274
- };
275
- }
276
630
  listTerminals() {
277
631
  return [...this.terminals.values()].map((record) => cloneSummary(record.summary));
278
632
  }
@@ -283,17 +637,28 @@ export class TerminalManager {
283
637
  getOutputSnapshot(id) {
284
638
  return this.terminals.get(id)?.output.snapshot() ?? null;
285
639
  }
286
- getTerminalHistory(id, options = {}) {
640
+ async getTerminalHistory(id, options = {}) {
287
641
  const record = this.terminals.get(id);
288
- if (!record || record.summary.status !== 'running') {
642
+ if (!record || record.summary.status !== 'running' || record.closing) {
643
+ return null;
644
+ }
645
+ if (!record.pty && !this.ensureAttached(record)) {
289
646
  return null;
290
647
  }
291
648
  const limit = normalizeHistoryLimit(options.limit);
292
- const capture = this.ptyAdapter.captureHistory?.(id, {
649
+ const capture = await this.ptyAdapter.captureHistory?.(id, {
293
650
  ...(options.beforeLine !== undefined ? { beforeLine: options.beforeLine } : {}),
651
+ ...(options.snapshotHistorySize !== undefined ? { snapshotHistorySize: options.snapshotHistorySize } : {}),
652
+ ...(options.snapshotHistoryLimit !== undefined ? { snapshotHistoryLimit: options.snapshotHistoryLimit } : {}),
653
+ ...(options.snapshotHistoryToken !== undefined
654
+ ? { snapshotHistoryToken: options.snapshotHistoryToken }
655
+ : {}),
656
+ ...(options.snapshotPaneWidth !== undefined
657
+ ? { snapshotPaneWidth: options.snapshotPaneWidth }
658
+ : {}),
294
659
  limit
295
660
  });
296
- if (!capture) {
661
+ if (!capture || this.terminals.get(id) !== record || record.summary.status !== 'running' || record.closing) {
297
662
  return null;
298
663
  }
299
664
  return {
@@ -301,27 +666,56 @@ export class TerminalManager {
301
666
  startLine: capture.startLine,
302
667
  endLine: capture.endLine,
303
668
  hasMore: capture.hasMore,
669
+ ...(capture.invalidated ? { invalidated: true } : {}),
670
+ ...(capture.snapshotHistorySize !== undefined
671
+ ? { snapshotHistorySize: capture.snapshotHistorySize }
672
+ : {}),
673
+ ...(capture.snapshotHistoryLimit !== undefined
674
+ ? { snapshotHistoryLimit: capture.snapshotHistoryLimit }
675
+ : {}),
676
+ ...(capture.snapshotHistoryToken !== undefined
677
+ ? { snapshotHistoryToken: capture.snapshotHistoryToken }
678
+ : {}),
679
+ ...(capture.snapshotPaneWidth !== undefined
680
+ ? { snapshotPaneWidth: capture.snapshotPaneWidth }
681
+ : {}),
304
682
  output: capture.output
305
683
  };
306
684
  }
307
- getTerminalTranscriptHistory(id, options = {}) {
685
+ async getTerminalTranscriptHistory(id, options = {}) {
308
686
  const record = this.terminals.get(id);
309
- if (!record || record.summary.status !== 'running') {
687
+ if (!record || record.summary.status !== 'running' || record.closing) {
310
688
  return null;
311
689
  }
312
- const source = this.ptyAdapter.resolveTranscriptSource?.(id);
313
- if (!source) {
690
+ const generation = record.transcriptGeneration;
691
+ const source = await this.ptyAdapter.resolveTranscriptSource?.(id);
692
+ if (!source ||
693
+ !transcriptSourceMatchesForeground(record.summary, source.provider) ||
694
+ this.terminals.get(id) !== record ||
695
+ record.summary.status !== 'running' ||
696
+ record.closing ||
697
+ record.transcriptGeneration !== generation) {
314
698
  return null;
315
699
  }
316
- return readTranscriptHistoryPage(id, source, options);
700
+ const page = await readTranscriptHistoryPage(id, source, options);
701
+ return (this.terminals.get(id) === record &&
702
+ record.summary.status === 'running' &&
703
+ !record.closing &&
704
+ record.transcriptGeneration === generation) ? page : null;
317
705
  }
318
- getTerminalTranscriptSource(id) {
706
+ async getTerminalTranscriptSource(id) {
319
707
  const record = this.terminals.get(id);
320
- if (!record || record.summary.status !== 'running') {
708
+ if (!record || record.summary.status !== 'running' || record.closing) {
321
709
  return null;
322
710
  }
323
- const source = this.ptyAdapter.resolveTranscriptSource?.(id);
324
- if (!source) {
711
+ const generation = record.transcriptGeneration;
712
+ const source = await this.ptyAdapter.resolveTranscriptSource?.(id);
713
+ if (!source ||
714
+ !transcriptSourceMatchesForeground(record.summary, source.provider) ||
715
+ this.terminals.get(id) !== record ||
716
+ record.summary.status !== 'running' ||
717
+ record.closing ||
718
+ record.transcriptGeneration !== generation) {
325
719
  return null;
326
720
  }
327
721
  return {
@@ -334,25 +728,58 @@ export class TerminalManager {
334
728
  if (!record || !this.ensureAttached(record)) {
335
729
  return null;
336
730
  }
337
- return this.safeResolveCwd(record.pty.pid);
731
+ const pty = record.pty;
732
+ const cwd = await this.safeResolveCwd(pty.pid);
733
+ return (cwd &&
734
+ this.terminals.get(id) === record &&
735
+ !record.closing &&
736
+ record.summary.status === 'running' &&
737
+ record.pty === pty) ? cwd : null;
338
738
  }
339
739
  // Push a new human title to the record and the persistence adapter so the
340
740
  // operator's rename of a tab survives both detach (the title shows up on
341
741
  // the pill) and NTerminal restart (tmux holds the title as its window
342
- // name). Returns the updated summary, or null when the id is unknown.
343
- renameTerminal(id, title) {
742
+ // name). The adapter write and in-memory update share the terminal's control
743
+ // queue so rename, input, scrollback, and close cannot overtake each other.
744
+ async renameTerminal(id, title) {
344
745
  const record = this.terminals.get(id);
345
746
  if (!record)
346
- return null;
347
- const trimmed = title.trim();
348
- if (!trimmed || trimmed === record.summary.title) {
349
- return cloneSummary(record.summary);
747
+ return { status: 'not_found' };
748
+ if (record.closing)
749
+ return { status: 'closing' };
750
+ const normalizedTitle = normalizeTerminalTitle(title);
751
+ if (normalizedTitle === record.summary.title && record.pendingControlOperations === 0) {
752
+ return { status: 'renamed', terminal: cloneSummary(record.summary) };
350
753
  }
351
- record.summary.title = trimmed;
352
- record.summary.updatedAt = this.timestamp();
353
- this.ptyAdapter.setSessionTitle?.(id, trimmed);
354
- this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
355
- return cloneSummary(record.summary);
754
+ this.sealPendingScroll(record);
755
+ if (!this.canEnqueueControl(record)) {
756
+ return { status: 'queue_full' };
757
+ }
758
+ return await this.enqueueControl(record, async () => {
759
+ if (this.terminals.get(id) !== record)
760
+ return { status: 'not_found' };
761
+ if (record.closing)
762
+ return { status: 'closing' };
763
+ if (record.summary.title === normalizedTitle) {
764
+ return { status: 'renamed', terminal: cloneSummary(record.summary) };
765
+ }
766
+ try {
767
+ await this.ptyAdapter.setSessionTitle?.(id, normalizedTitle);
768
+ }
769
+ catch {
770
+ return { status: 'persistence_failed' };
771
+ }
772
+ if (this.terminals.get(id) !== record)
773
+ return { status: 'not_found' };
774
+ // Persistence already succeeded. Publish the same value even if a
775
+ // close request arrived while the adapter call was in flight; if that
776
+ // close later fails, memory and tmux must not diverge.
777
+ record.summary.title = normalizedTitle;
778
+ record.summary.updatedAt = this.timestamp();
779
+ const terminal = cloneSummary(record.summary);
780
+ this.publish(record, { type: 'terminal_updated', terminal });
781
+ return { status: 'renamed', terminal };
782
+ });
356
783
  }
357
784
  async refreshTerminalCwd(id) {
358
785
  const record = this.terminals.get(id);
@@ -360,11 +787,13 @@ export class TerminalManager {
360
787
  return null;
361
788
  }
362
789
  await this.refreshCwd(record);
363
- return cloneSummary(record.summary);
790
+ return this.terminals.get(id) === record && !record.closing
791
+ ? cloneSummary(record.summary)
792
+ : null;
364
793
  }
365
794
  attachTerminal(id, subscriber, options = {}) {
366
795
  const record = this.terminals.get(id);
367
- if (!record) {
796
+ if (!record || record.closing) {
368
797
  return null;
369
798
  }
370
799
  // Lazy attach for restored sessions: if the record exists in memory but
@@ -372,11 +801,13 @@ export class TerminalManager {
372
801
  // at startup but haven't reconnected yet), spawn one now. From the
373
802
  // client's perspective this is indistinguishable from a normal attach
374
803
  // — tmux replays the current screen on connect.
375
- if (!this.ensureAttached(record) && record.summary.status === 'running') {
804
+ if (record.summary.status === 'running' && !this.ensureAttached(record)) {
376
805
  return null;
377
806
  }
378
807
  if (record.summary.status === 'running') {
379
- record.history = this.captureTerminalHistoryCursor(record);
808
+ record.history = typeof this.ptyAdapter.captureHistory === 'function'
809
+ ? { startLine: 0, endLine: 0, hasMore: true }
810
+ : null;
380
811
  }
381
812
  const output = record.output.snapshot();
382
813
  record.subscribers.add(subscriber);
@@ -416,35 +847,120 @@ export class TerminalManager {
416
847
  }
417
848
  };
418
849
  }
419
- writeToTerminal(id, data, options = {}) {
850
+ async writeToTerminal(id, data, options = {}) {
420
851
  const record = this.terminals.get(id);
421
- if (!record?.pty || record.summary.status !== 'running') {
852
+ const inputBytes = Buffer.byteLength(data, 'utf8');
853
+ if (!controlOperationAllowed(options)) {
422
854
  return false;
423
855
  }
424
- if (options.cancelScrollbackMode && !this.cancelTerminalScrollbackMode(id)) {
856
+ if (record && !record.pty && record.summary.status === 'running' && !record.closing) {
857
+ this.ensureAttached(record);
858
+ }
859
+ if (!record?.pty ||
860
+ record.summary.status !== 'running' ||
861
+ record.closing ||
862
+ !this.canEnqueueControl(record, inputBytes)) {
425
863
  return false;
426
864
  }
427
- record.pty.write(data);
428
- record.summary.updatedAt = this.timestamp();
429
- return true;
865
+ this.sealPendingScroll(record);
866
+ return await this.enqueueControl(record, async () => {
867
+ if (!controlOperationAllowed(options) ||
868
+ this.terminals.get(id) !== record ||
869
+ !record.pty ||
870
+ record.summary.status !== 'running' ||
871
+ record.closing) {
872
+ return false;
873
+ }
874
+ if (options.cancelScrollbackMode &&
875
+ this.ptyAdapter.cancelScrollbackMode &&
876
+ !await this.ptyAdapter.cancelScrollbackMode(id)) {
877
+ return false;
878
+ }
879
+ if (!controlOperationAllowed(options) ||
880
+ this.terminals.get(id) !== record ||
881
+ !record.pty ||
882
+ record.summary.status !== 'running' ||
883
+ record.closing) {
884
+ return false;
885
+ }
886
+ record.pty.write(data);
887
+ record.summary.updatedAt = this.timestamp();
888
+ return true;
889
+ }, inputBytes);
430
890
  }
431
- scrollTerminalScrollback(id, lines) {
891
+ scrollTerminalScrollback(id, lines, options = {}) {
432
892
  const record = this.terminals.get(id);
433
- if (!record || record.summary.status !== 'running' || !this.ensureAttached(record)) {
434
- return false;
893
+ const scroll = this.ptyAdapter.scrollbackScroll;
894
+ if (!controlOperationAllowed(options) ||
895
+ !record ||
896
+ record.summary.status !== 'running' ||
897
+ record.closing ||
898
+ !this.ensureAttached(record) ||
899
+ !scroll) {
900
+ return Promise.resolve(false);
901
+ }
902
+ const normalizedLines = clampScrollLines(lines);
903
+ const pending = record.pendingScroll;
904
+ if (pending) {
905
+ addPendingScrollDelta(pending.groups, options.canExecute, normalizedLines);
906
+ return pending.promise;
907
+ }
908
+ if (!this.canEnqueueControl(record)) {
909
+ return Promise.resolve(false);
435
910
  }
436
- return this.ptyAdapter.scrollbackScroll?.(id, lines) ?? false;
911
+ const groups = new Map();
912
+ addPendingScrollDelta(groups, options.canExecute, normalizedLines);
913
+ const next = { groups, promise: Promise.resolve(false) };
914
+ next.promise = this.enqueueControl(record, async () => {
915
+ if (record.pendingScroll === next) {
916
+ record.pendingScroll = null;
917
+ }
918
+ if (this.terminals.get(id) !== record || record.summary.status !== 'running' || record.closing) {
919
+ return false;
920
+ }
921
+ let combinedLines = 0;
922
+ let hasExecutableRequest = false;
923
+ for (const [canExecute, groupLines] of next.groups) {
924
+ if (!controlCallbackAllowed(canExecute)) {
925
+ continue;
926
+ }
927
+ hasExecutableRequest = true;
928
+ combinedLines += groupLines;
929
+ }
930
+ if (!hasExecutableRequest) {
931
+ return false;
932
+ }
933
+ return await scroll.call(this.ptyAdapter, id, clampScrollLines(combinedLines));
934
+ });
935
+ record.pendingScroll = next;
936
+ return next.promise;
437
937
  }
438
- cancelTerminalScrollbackMode(id) {
938
+ async cancelTerminalScrollbackMode(id, options = {}) {
439
939
  const record = this.terminals.get(id);
440
- if (!record || record.summary.status !== 'running' || !this.ensureAttached(record)) {
940
+ if (!controlOperationAllowed(options) ||
941
+ !record ||
942
+ record.summary.status !== 'running' ||
943
+ record.closing ||
944
+ !this.ensureAttached(record) ||
945
+ !this.canEnqueueControl(record)) {
441
946
  return false;
442
947
  }
443
- return this.ptyAdapter.cancelScrollbackMode?.(id) ?? true;
948
+ this.sealPendingScroll(record);
949
+ return await this.enqueueControl(record, async () => {
950
+ if (!controlOperationAllowed(options) ||
951
+ this.terminals.get(id) !== record ||
952
+ record.summary.status !== 'running' ||
953
+ record.closing) {
954
+ return false;
955
+ }
956
+ return this.ptyAdapter.cancelScrollbackMode
957
+ ? await this.ptyAdapter.cancelScrollbackMode(id)
958
+ : true;
959
+ });
444
960
  }
445
961
  resizeTerminal(id, cols, rows) {
446
962
  const record = this.terminals.get(id);
447
- if (!record?.pty || record.summary.status !== 'running') {
963
+ if (!record?.pty || record.summary.status !== 'running' || record.closing) {
448
964
  return false;
449
965
  }
450
966
  const normalizedCols = normalizeDimension(cols, record.summary.cols);
@@ -459,47 +975,157 @@ export class TerminalManager {
459
975
  this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
460
976
  return true;
461
977
  }
462
- closeTerminal(id) {
978
+ async closeTerminal(id) {
463
979
  const record = this.terminals.get(id);
464
980
  if (!record) {
465
981
  return false;
466
982
  }
467
- if (!record.pty && record.summary.status === 'running') {
468
- this.ptyAdapter.destroySession?.(id);
983
+ if (this.ptyAdapter.listExisting && !this.ptyAdapter.destroySession) {
984
+ return false;
469
985
  }
470
- this.disposeRecord(record);
471
- this.terminals.delete(id);
472
- return true;
986
+ if (record.closePromise)
987
+ return record.closePromise;
988
+ this.markClosing(record);
989
+ const queuedClose = this.enqueueControl(record, async () => {
990
+ if (this.terminals.get(id) !== record)
991
+ return false;
992
+ if (this.ptyAdapter.destroySession) {
993
+ let destroyed = false;
994
+ try {
995
+ destroyed = await this.ptyAdapter.destroySession(id);
996
+ }
997
+ catch (error) {
998
+ this.reportDiagnostic({ event: 'terminal.destroy_failed', terminalId: id, error });
999
+ }
1000
+ if (!destroyed) {
1001
+ return await this.recoverOrFinalizeFailedClose(record);
1002
+ }
1003
+ }
1004
+ this.disposeRecord(record);
1005
+ this.terminals.delete(id);
1006
+ return true;
1007
+ }, 0, true);
1008
+ const close = queuedClose.catch(async (error) => {
1009
+ let finalized = false;
1010
+ if (this.terminals.get(id) === record) {
1011
+ finalized = await this.recoverOrFinalizeFailedClose(record);
1012
+ }
1013
+ this.reportDiagnostic({ event: 'terminal.close_failed', terminalId: id, error });
1014
+ return finalized;
1015
+ });
1016
+ record.closePromise = close;
1017
+ void close.finally(() => {
1018
+ if (record.closePromise === close) {
1019
+ record.closePromise = null;
1020
+ }
1021
+ }).catch(() => undefined);
1022
+ return close;
473
1023
  }
474
- closeAll() {
475
- // Differs from closeTerminal: we tear down our in-memory bookkeeping but
476
- // DO NOT call pty.kill() when the adapter is persistent (tmux). Killing
477
- // here would destroy every tmux session on NTerminal shutdown — defeating
478
- // the entire across-restart persistence design. For non-persistent
479
- // adapters (NodePtyAdapter), the child shells get SIGHUP via process
480
- // tree exit anyway, so explicitly killing is just being polite.
481
- const persistent = typeof this.ptyAdapter.listExisting === 'function';
482
- for (const record of this.terminals.values()) {
483
- record.dataDisposable.dispose();
484
- record.exitDisposable.dispose();
485
- record.subscribers.clear();
486
- if (!persistent && record.pty && record.summary.status === 'running') {
487
- record.pty.kill();
1024
+ sealPendingScroll(record) {
1025
+ record.pendingScroll = null;
1026
+ }
1027
+ markClosing(record) {
1028
+ if (record.closing)
1029
+ return;
1030
+ record.closing = true;
1031
+ this.terminalMutationGeneration += 1;
1032
+ record.transcriptGeneration += 1;
1033
+ this.sealPendingScroll(record);
1034
+ record.transcriptRerun = false;
1035
+ this.cancelQueuedTranscriptRefresh(record);
1036
+ }
1037
+ async recoverOrFinalizeFailedClose(record) {
1038
+ record.closing = false;
1039
+ if (record.pty) {
1040
+ return false;
1041
+ }
1042
+ const persistentState = await this.persistentSessionState(record.summary.id);
1043
+ if (persistentState === false) {
1044
+ this.disposeRecord(record);
1045
+ this.terminals.delete(record.summary.id);
1046
+ return true;
1047
+ }
1048
+ record.summary.status = 'running';
1049
+ record.summary.exitCode = null;
1050
+ record.summary.updatedAt = this.timestamp();
1051
+ if (record.subscribers.size > 0) {
1052
+ try {
1053
+ this.reattachToSession(record);
1054
+ }
1055
+ catch (error) {
1056
+ if (error instanceof PtySessionUnavailableError) {
1057
+ this.markSessionUnavailable(record, null);
1058
+ return false;
1059
+ }
1060
+ // A later browser attach can retry after the transient failure.
488
1061
  }
489
- record.pty = null;
490
1062
  }
491
- this.terminals.clear();
1063
+ this.publish(record, {
1064
+ type: 'terminal_updated',
1065
+ terminal: cloneSummary(record.summary)
1066
+ });
1067
+ return false;
492
1068
  }
493
- destroyAllTerminals() {
494
- for (const record of this.terminals.values()) {
495
- if (record.summary.status === 'running' && this.ptyAdapter.destroySession) {
496
- this.ptyAdapter.destroySession(record.summary.id);
1069
+ async closeAll() {
1070
+ this.shutdownRequested = true;
1071
+ this.clearRestoreRetryTimer();
1072
+ await this.awaitPendingInitializations();
1073
+ // PtyProcess.kill() disposes only the local client. Persistent backing
1074
+ // sessions are destroyed exclusively by destroySession(), so detaching
1075
+ // every local client here preserves tmux sessions across restart. An
1076
+ // explicit close already accepted by the control queue is different:
1077
+ // let it finish first so shutdown cannot erase the record and silently
1078
+ // turn a requested deletion into a detach.
1079
+ while (true) {
1080
+ const pendingCloses = [...this.terminals.values()]
1081
+ .map((record) => record.closePromise)
1082
+ .filter((close) => close !== null);
1083
+ if (pendingCloses.length === 0) {
1084
+ break;
497
1085
  }
1086
+ await Promise.allSettled(pendingCloses);
1087
+ }
1088
+ for (const record of this.terminals.values()) {
498
1089
  this.disposeRecord(record);
499
1090
  }
500
1091
  this.terminals.clear();
1092
+ this.completionStates.clear();
1093
+ }
1094
+ async destroyAllTerminals() {
1095
+ this.terminalResetBlockers += 1;
1096
+ try {
1097
+ await this.awaitPendingInitializations();
1098
+ const records = [...this.terminals.values()];
1099
+ for (const record of records) {
1100
+ if (this.terminals.get(record.summary.id) === record) {
1101
+ this.markClosing(record);
1102
+ }
1103
+ }
1104
+ const results = await mapWithConcurrency(records, DESTROY_ALL_CONCURRENCY, async (record) => {
1105
+ if (this.terminals.get(record.summary.id) !== record)
1106
+ return false;
1107
+ return await this.closeTerminal(record.summary.id);
1108
+ });
1109
+ const destroyed = results.filter(Boolean).length;
1110
+ return {
1111
+ requested: records.length,
1112
+ destroyed,
1113
+ failed: records.length - destroyed
1114
+ };
1115
+ }
1116
+ finally {
1117
+ this.terminalResetBlockers = Math.max(0, this.terminalResetBlockers - 1);
1118
+ }
1119
+ }
1120
+ async awaitPendingInitializations() {
1121
+ while (this.pendingInitializations.size > 0) {
1122
+ await Promise.allSettled([...this.pendingInitializations]);
1123
+ }
501
1124
  }
502
1125
  ensureAttached(record) {
1126
+ if (record.closing) {
1127
+ return false;
1128
+ }
503
1129
  if (record.pty) {
504
1130
  return true;
505
1131
  }
@@ -509,7 +1135,10 @@ export class TerminalManager {
509
1135
  try {
510
1136
  this.reattachToSession(record);
511
1137
  }
512
- catch {
1138
+ catch (error) {
1139
+ if (error instanceof PtySessionUnavailableError) {
1140
+ this.markSessionUnavailable(record, null);
1141
+ }
513
1142
  return false;
514
1143
  }
515
1144
  return record.pty !== null;
@@ -540,18 +1169,52 @@ export class TerminalManager {
540
1169
  if (!pty || record.summary.status !== 'running') {
541
1170
  return;
542
1171
  }
1172
+ const refreshGeneration = ++record.cwdRefreshGeneration;
543
1173
  const cwd = await this.safeResolveCwd(pty.pid);
544
- if (!cwd || record.pty !== pty || record.summary.status !== 'running' || cwd === record.summary.cwd) {
1174
+ if (!cwd ||
1175
+ this.terminals.get(record.summary.id) !== record ||
1176
+ record.closing ||
1177
+ record.pty !== pty ||
1178
+ record.summary.status !== 'running' ||
1179
+ record.cwdRefreshGeneration !== refreshGeneration ||
1180
+ cwd === record.summary.cwd) {
545
1181
  return;
546
1182
  }
547
- const previousAutoTitle = this.createTitle(record.summary.cwd);
548
- const shouldAutoRename = record.summary.title === previousAutoTitle;
549
- record.summary.cwd = cwd;
550
- if (shouldAutoRename) {
551
- record.summary.title = this.createTitle(cwd);
1183
+ this.sealPendingScroll(record);
1184
+ if (!this.canEnqueueControl(record)) {
1185
+ return;
552
1186
  }
553
- record.summary.updatedAt = this.timestamp();
554
- this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
1187
+ await this.enqueueControl(record, async () => {
1188
+ if (this.terminals.get(record.summary.id) !== record ||
1189
+ record.closing ||
1190
+ record.pty !== pty ||
1191
+ record.summary.status !== 'running' ||
1192
+ record.cwdRefreshGeneration !== refreshGeneration ||
1193
+ cwd === record.summary.cwd) {
1194
+ return;
1195
+ }
1196
+ const previousAutoTitle = this.createTitle(record.summary.cwd);
1197
+ const shouldAutoRename = record.summary.title === previousAutoTitle;
1198
+ const nextAutoTitle = this.createTitle(cwd);
1199
+ let autoTitlePersisted = shouldAutoRename;
1200
+ if (shouldAutoRename && this.ptyAdapter.setSessionTitle) {
1201
+ try {
1202
+ await this.ptyAdapter.setSessionTitle(record.summary.id, nextAutoTitle);
1203
+ }
1204
+ catch {
1205
+ autoTitlePersisted = false;
1206
+ }
1207
+ }
1208
+ if (this.terminals.get(record.summary.id) !== record) {
1209
+ return;
1210
+ }
1211
+ record.summary.cwd = cwd;
1212
+ if (autoTitlePersisted) {
1213
+ record.summary.title = nextAutoTitle;
1214
+ }
1215
+ record.summary.updatedAt = this.timestamp();
1216
+ this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
1217
+ }).catch(() => undefined);
555
1218
  }
556
1219
  publish(record, message) {
557
1220
  for (const subscriber of record.subscribers) {
@@ -607,26 +1270,72 @@ export class TerminalManager {
607
1270
  }
608
1271
  void Promise.resolve(this.onCompletion(event)).catch(() => undefined);
609
1272
  }
610
- async resolveAgentTranscript(terminalId, currentCommand) {
1273
+ async resolveAgentTranscript(terminalId, currentCommand, isCurrent = () => true) {
611
1274
  if (!currentCommand) {
612
1275
  this.agentTranscriptTracker.reset(terminalId);
1276
+ const record = this.terminals.get(terminalId);
1277
+ if (record)
1278
+ record.transcriptSourceMissingSince = null;
613
1279
  return undefined;
614
1280
  }
1281
+ const record = this.terminals.get(terminalId);
615
1282
  const inferredProvider = inferAgentProviderFromCommand(currentCommand);
616
- const source = this.ptyAdapter.resolveTranscriptSource?.(terminalId);
617
- if (!source) {
618
- this.agentTranscriptTracker.reset(terminalId);
619
- return inferredProvider ? { status: { provider: inferredProvider, state: 'unknown' }, cache: null } : undefined;
1283
+ const graceProvider = expectedAgentProvider(currentCommand, record?.summary.agentStatus?.provider);
1284
+ const source = await this.ptyAdapter.resolveTranscriptSource?.(terminalId);
1285
+ if (!isCurrent())
1286
+ return null;
1287
+ const providerMismatch = Boolean(source && graceProvider && source.provider !== graceProvider);
1288
+ if (!source || providerMismatch) {
1289
+ const now = Date.now();
1290
+ const missingSince = record?.transcriptSourceMissingSince ?? now;
1291
+ if (record)
1292
+ record.transcriptSourceMissingSince = missingSince;
1293
+ const existing = record?.summary;
1294
+ if (now - missingSince <= TRANSCRIPT_SOURCE_MISS_GRACE_MS &&
1295
+ graceProvider &&
1296
+ existing?.agentStatus &&
1297
+ existing.agentStatus.provider === graceProvider) {
1298
+ return {
1299
+ status: { ...existing.agentStatus },
1300
+ cache: existing.agentCache ? { ...existing.agentCache, source: { ...existing.agentCache.source } } : null
1301
+ };
1302
+ }
1303
+ // A node wrapper cannot identify the provider itself. Once the grace
1304
+ // window outlives the adapter's short resolution cache, a consistently
1305
+ // different resolved provider is evidence of a real in-pane switch.
1306
+ if (source && providerMismatch && inferredProvider === null) {
1307
+ if (record)
1308
+ record.transcriptSourceMissingSince = null;
1309
+ }
1310
+ else {
1311
+ this.agentTranscriptTracker.reset(terminalId);
1312
+ // A generic node process is only an ambiguous wrapper during the
1313
+ // bounded grace window above. Keeping the previous provider after
1314
+ // that window would misclassify an unrelated node application as an
1315
+ // agent indefinitely.
1316
+ return inferredProvider
1317
+ ? { status: { provider: inferredProvider, state: 'unknown' }, cache: null }
1318
+ : undefined;
1319
+ }
620
1320
  }
1321
+ if (record)
1322
+ record.transcriptSourceMissingSince = null;
621
1323
  try {
622
- return await this.agentTranscriptTracker.readSnapshot(terminalId, source);
1324
+ const snapshot = await this.agentTranscriptTracker.readSnapshot(terminalId, source);
1325
+ return isCurrent() ? snapshot : null;
623
1326
  }
624
1327
  catch {
625
- return { status: { provider: source.provider, state: 'unknown' }, cache: null };
1328
+ return isCurrent() ? { status: { provider: source.provider, state: 'unknown' }, cache: null } : null;
626
1329
  }
627
1330
  }
628
1331
  disposeRecord(record) {
1332
+ record.transcriptGeneration += 1;
629
1333
  this.agentTranscriptTracker.reset(record.summary.id);
1334
+ this.completionStates.delete(record.summary.id);
1335
+ record.transcriptSourceMissingSince = null;
1336
+ record.transcriptRerun = false;
1337
+ this.cancelQueuedTranscriptRefresh(record);
1338
+ record.pendingScroll = null;
630
1339
  record.dataDisposable.dispose();
631
1340
  record.exitDisposable.dispose();
632
1341
  record.subscribers.clear();
@@ -636,11 +1345,59 @@ export class TerminalManager {
636
1345
  record.pty = null;
637
1346
  }
638
1347
  createTitle(cwd) {
639
- return path.basename(cwd) || 'Terminal';
1348
+ return normalizeTerminalTitle(path.basename(cwd) || 'Terminal');
1349
+ }
1350
+ cancelQueuedTranscriptRefresh(record) {
1351
+ if (record.transcriptRefreshState !== 'queued') {
1352
+ return;
1353
+ }
1354
+ record.transcriptRefreshState = 'idle';
1355
+ const terminalId = record.summary.id;
1356
+ for (let index = this.transcriptQueue.length - 1; index >= 0; index -= 1) {
1357
+ if (this.transcriptQueue[index] === terminalId) {
1358
+ this.transcriptQueue.splice(index, 1);
1359
+ }
1360
+ }
640
1361
  }
641
1362
  timestamp() {
642
1363
  return this.now().toISOString();
643
1364
  }
1365
+ reportDiagnostic(diagnostic) {
1366
+ const now = Date.now();
1367
+ const lastReportedAt = this.lastDiagnosticAt.get(diagnostic.event);
1368
+ if (lastReportedAt !== undefined && now - lastReportedAt < DIAGNOSTIC_REPORT_INTERVAL_MS)
1369
+ return;
1370
+ this.lastDiagnosticAt.set(diagnostic.event, now);
1371
+ try {
1372
+ this.onDiagnostic?.(diagnostic);
1373
+ }
1374
+ catch {
1375
+ // Diagnostics must never turn a contained background failure into a
1376
+ // process-level unhandled rejection.
1377
+ }
1378
+ }
1379
+ canEnqueueControl(record, bytes = 0) {
1380
+ return (Number.isSafeInteger(bytes) &&
1381
+ bytes >= 0 &&
1382
+ record.pendingControlOperations < MAX_PENDING_CONTROL_OPERATIONS &&
1383
+ record.pendingControlBytes + bytes <= MAX_PENDING_CONTROL_BYTES);
1384
+ }
1385
+ enqueueControl(record, operation, bytes = 0, force = false) {
1386
+ if (!force && !this.canEnqueueControl(record, bytes)) {
1387
+ return Promise.reject(new Error('terminal control queue is full'));
1388
+ }
1389
+ record.pendingControlOperations += 1;
1390
+ record.pendingControlBytes += bytes;
1391
+ const previous = record.controlTail;
1392
+ const result = previous.then(operation, operation);
1393
+ const tracked = result.finally(() => {
1394
+ record.pendingControlOperations = Math.max(0, record.pendingControlOperations - 1);
1395
+ record.pendingControlBytes = Math.max(0, record.pendingControlBytes - bytes);
1396
+ });
1397
+ const tail = tracked.then(() => undefined, () => undefined);
1398
+ record.controlTail = tail;
1399
+ return tracked;
1400
+ }
644
1401
  }
645
1402
  function agentCompletionOutcome(state) {
646
1403
  if (state === 'done')
@@ -649,6 +1406,20 @@ function agentCompletionOutcome(state) {
649
1406
  return 'stopped';
650
1407
  return null;
651
1408
  }
1409
+ function controlOperationAllowed(options) {
1410
+ return controlCallbackAllowed(options.canExecute);
1411
+ }
1412
+ function controlCallbackAllowed(canExecute) {
1413
+ try {
1414
+ return canExecute?.() !== false;
1415
+ }
1416
+ catch {
1417
+ return false;
1418
+ }
1419
+ }
1420
+ function addPendingScrollDelta(groups, canExecute, lines) {
1421
+ groups.set(canExecute, (groups.get(canExecute) ?? 0) + lines);
1422
+ }
652
1423
  function agentSessionChanged(previous, current) {
653
1424
  return previous !== null && current !== null && previous !== current;
654
1425
  }
@@ -661,6 +1432,11 @@ function normalizeHistoryLimit(value) {
661
1432
  }
662
1433
  return Math.min(value, HISTORY_PAGE_MAX_LINES);
663
1434
  }
1435
+ function clampScrollLines(lines) {
1436
+ if (!Number.isFinite(lines))
1437
+ return 0;
1438
+ return Math.max(-240, Math.min(240, Math.trunc(lines)));
1439
+ }
664
1440
  function cloneSummary(summary) {
665
1441
  return {
666
1442
  ...summary,
@@ -670,15 +1446,14 @@ function cloneSummary(summary) {
670
1446
  };
671
1447
  }
672
1448
  function activityFromSignals(options) {
673
- if (options.agentStatus?.state === 'working') {
1449
+ if (options.busy || options.recentOutput || options.agentStatus?.state === 'working') {
674
1450
  return 'busy';
675
1451
  }
676
1452
  if (options.agentStatus &&
677
- options.agentStatus.state !== 'unknown' &&
678
- !options.recentOutput) {
1453
+ options.agentStatus.state !== 'unknown') {
679
1454
  return 'idle';
680
1455
  }
681
- return options.busy || options.recentOutput ? 'busy' : 'idle';
1456
+ return 'idle';
682
1457
  }
683
1458
  function agentStatusEquals(left, right) {
684
1459
  return left?.provider === right?.provider && left?.sessionId === right?.sessionId && left?.state === right?.state;
@@ -705,6 +1480,20 @@ function inferAgentProviderFromCommand(command) {
705
1480
  }
706
1481
  return null;
707
1482
  }
1483
+ function isAgentRuntimeWrapperCommand(command) {
1484
+ const base = path.basename(command).toLowerCase();
1485
+ return base === 'node' || base === 'nodejs';
1486
+ }
1487
+ function expectedAgentProvider(command, existingProvider) {
1488
+ return inferAgentProviderFromCommand(command) ?? (isAgentRuntimeWrapperCommand(command) ? existingProvider ?? null : null);
1489
+ }
1490
+ function transcriptSourceMatchesForeground(summary, sourceProvider) {
1491
+ if (!summary.currentCommand) {
1492
+ return false;
1493
+ }
1494
+ const expected = expectedAgentProvider(summary.currentCommand, summary.agentStatus?.provider);
1495
+ return expected === null || expected === sourceProvider;
1496
+ }
708
1497
  function terminalEnv() {
709
1498
  const env = {};
710
1499
  const allowedKeys = ['HOME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LOGNAME', 'PATH', 'SHELL', 'TMPDIR', 'USER'];
@@ -739,4 +1528,7 @@ function ensureUtf8TerminalLocale(env) {
739
1528
  function isUtf8Locale(value) {
740
1529
  return value !== undefined && /utf-?8/i.test(value);
741
1530
  }
1531
+ function isPromiseLike(value) {
1532
+ return typeof value?.then === 'function';
1533
+ }
742
1534
  //# sourceMappingURL=TerminalManager.js.map