nterminal 1.2.79 → 1.2.81

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