nterminal 1.2.79 → 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.
- package/SECURITY.md +6 -0
- package/dist/client/assets/MarkdownPreview-D2UZ6wJU.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-9-cPRj-d.js → TranscriptMarkdownBody-BFu2FzgT.js} +1 -1
- package/dist/client/assets/index-4MeJRcb6.js +57 -0
- package/dist/client/assets/{index-bffyLU7T.js → index-BFs4imuQ.js} +1 -1
- package/dist/client/assets/index-rROkcwjD.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/scripts/onboarding.js +10 -1
- package/dist/scripts/onboarding.js.map +1 -1
- package/dist/server/agent/agentProxy.d.ts +7 -2
- package/dist/server/agent/agentProxy.js +75 -9
- package/dist/server/agent/agentProxy.js.map +1 -1
- package/dist/server/agent/agentRoutes.js +159 -33
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/agent/agentWebSocketProxy.d.ts +10 -1
- package/dist/server/agent/agentWebSocketProxy.js +24 -4
- package/dist/server/agent/agentWebSocketProxy.js.map +1 -1
- package/dist/server/auth/authService.d.ts +18 -4
- package/dist/server/auth/authService.js +57 -6
- package/dist/server/auth/authService.js.map +1 -1
- package/dist/server/eventLoopDelayMonitor.d.ts +11 -0
- package/dist/server/eventLoopDelayMonitor.js +37 -0
- package/dist/server/eventLoopDelayMonitor.js.map +1 -0
- package/dist/server/http.js +11 -4
- package/dist/server/http.js.map +1 -1
- package/dist/server/index.js +18 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/agentManagementRoutes.d.ts +2 -1
- package/dist/server/routes/agentManagementRoutes.js +91 -37
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/server/routes/authRoutes.js +4 -1
- package/dist/server/routes/authRoutes.js.map +1 -1
- package/dist/server/routes/authenticatedSocketRegistry.d.ts +25 -0
- package/dist/server/routes/authenticatedSocketRegistry.js +171 -0
- package/dist/server/routes/authenticatedSocketRegistry.js.map +1 -0
- package/dist/server/routes/codeServerRoutes.d.ts +3 -1
- package/dist/server/routes/codeServerRoutes.js +139 -26
- package/dist/server/routes/codeServerRoutes.js.map +1 -1
- package/dist/server/routes/fileRoutes.js +1 -1
- package/dist/server/routes/fileRoutes.js.map +1 -1
- package/dist/server/routes/httpProxyBody.d.ts +2 -1
- package/dist/server/routes/httpProxyBody.js +8 -1
- package/dist/server/routes/httpProxyBody.js.map +1 -1
- package/dist/server/routes/socketBackpressure.d.ts +2 -0
- package/dist/server/routes/socketBackpressure.js +21 -10
- package/dist/server/routes/socketBackpressure.js.map +1 -1
- package/dist/server/routes/terminalRoutes.d.ts +4 -3
- package/dist/server/routes/terminalRoutes.js +62 -14
- package/dist/server/routes/terminalRoutes.js.map +1 -1
- package/dist/server/routes/terminalSocketMessage.d.ts +34 -0
- package/dist/server/routes/terminalSocketMessage.js +188 -0
- package/dist/server/routes/terminalSocketMessage.js.map +1 -0
- package/dist/server/routes/terminalWebSocket.d.ts +3 -1
- package/dist/server/routes/terminalWebSocket.js +191 -116
- package/dist/server/routes/terminalWebSocket.js.map +1 -1
- package/dist/server/routes/webSocketRelay.d.ts +14 -0
- package/dist/server/routes/webSocketRelay.js +188 -18
- package/dist/server/routes/webSocketRelay.js.map +1 -1
- package/dist/server/routes/webSocketUpgradeAuth.d.ts +5 -0
- package/dist/server/routes/webSocketUpgradeAuth.js +21 -0
- package/dist/server/routes/webSocketUpgradeAuth.js.map +1 -0
- package/dist/server/system/stats.d.ts +1 -1
- package/dist/server/system/stats.js +11 -7
- package/dist/server/system/stats.js.map +1 -1
- package/dist/server/terminal/PtyAdapter.d.ts +25 -8
- package/dist/server/terminal/PtyAdapter.js +12 -1
- package/dist/server/terminal/PtyAdapter.js.map +1 -1
- package/dist/server/terminal/TerminalManager.d.ts +71 -13
- package/dist/server/terminal/TerminalManager.js +955 -163
- package/dist/server/terminal/TerminalManager.js.map +1 -1
- package/dist/server/terminal/TmuxPtyAdapter.d.ts +85 -15
- package/dist/server/terminal/TmuxPtyAdapter.js +1364 -403
- package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
- package/dist/server/terminal/asyncCommand.d.ts +17 -0
- package/dist/server/terminal/asyncCommand.js +60 -0
- package/dist/server/terminal/asyncCommand.js.map +1 -0
- package/dist/server/terminal/codexTranscriptSource.d.ts +3 -3
- package/dist/server/terminal/codexTranscriptSource.js +90 -104
- package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
- package/dist/server/terminal/outputBuffer.d.ts +5 -1
- package/dist/server/terminal/outputBuffer.js +29 -3
- package/dist/server/terminal/outputBuffer.js.map +1 -1
- package/dist/server/terminal/procfs.d.ts +7 -8
- package/dist/server/terminal/procfs.js +56 -47
- package/dist/server/terminal/procfs.js.map +1 -1
- package/dist/server/terminal/transcriptHistory.d.ts +1 -1
- package/dist/server/terminal/transcriptHistory.js +187 -38
- package/dist/server/terminal/transcriptHistory.js.map +1 -1
- package/dist/server/terminal/ttlCache.d.ts +1 -0
- package/dist/server/terminal/ttlCache.js +3 -0
- package/dist/server/terminal/ttlCache.js.map +1 -1
- package/dist/shared/protocol.d.ts +24 -0
- package/dist/shared/protocol.js +25 -2
- package/dist/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/MarkdownPreview-BBppqaO6.js +0 -1
- package/dist/client/assets/index-B3_mYQzf.css +0 -1
- package/dist/client/assets/index-DAjEhbeV.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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
if (nextAgentStatus) {
|
|
109
|
-
record.summary.agentStatus = nextAgentStatus;
|
|
109
|
+
catch (error) {
|
|
110
|
+
this.reportDiagnostic({ event: 'terminal.process_snapshot_failed', terminalId, error });
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
324
|
-
|
|
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
|
-
|
|
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).
|
|
343
|
-
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
852
|
+
const inputBytes = Buffer.byteLength(data, 'utf8');
|
|
853
|
+
if (!controlOperationAllowed(options)) {
|
|
422
854
|
return false;
|
|
423
855
|
}
|
|
424
|
-
if (
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
468
|
-
|
|
983
|
+
if (this.ptyAdapter.listExisting && !this.ptyAdapter.destroySession) {
|
|
984
|
+
return false;
|
|
469
985
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
1063
|
+
this.publish(record, {
|
|
1064
|
+
type: 'terminal_updated',
|
|
1065
|
+
terminal: cloneSummary(record.summary)
|
|
1066
|
+
});
|
|
1067
|
+
return false;
|
|
492
1068
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 ||
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (shouldAutoRename) {
|
|
551
|
-
record.summary.title = this.createTitle(cwd);
|
|
1183
|
+
this.sealPendingScroll(record);
|
|
1184
|
+
if (!this.canEnqueueControl(record)) {
|
|
1185
|
+
return;
|
|
552
1186
|
}
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|