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.
- package/SECURITY.md +6 -0
- package/dist/client/assets/MarkdownPreview-Dq1fT9EP.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-9-cPRj-d.js → TranscriptMarkdownBody-DDFnUWuE.js} +1 -1
- package/dist/client/assets/{index-bffyLU7T.js → index-D7w92nCI.js} +1 -1
- package/dist/client/assets/index-Di6LPXrS.js +57 -0
- package/dist/client/assets/index-hoeidjW4.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 +199 -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 +120 -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 +113 -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 +87 -14
- package/dist/server/terminal/TerminalManager.js +1049 -168
- package/dist/server/terminal/TerminalManager.js.map +1 -1
- package/dist/server/terminal/TmuxPtyAdapter.d.ts +91 -15
- package/dist/server/terminal/TmuxPtyAdapter.js +1392 -404
- 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 +4 -4
- package/dist/server/terminal/codexTranscriptSource.js +91 -105
- 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/subagentSources.d.ts +15 -0
- package/dist/server/terminal/subagentSources.js +801 -0
- package/dist/server/terminal/subagentSources.js.map +1 -0
- package/dist/server/terminal/transcriptHistory.d.ts +2 -1
- package/dist/server/terminal/transcriptHistory.js +207 -49
- 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 +42 -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,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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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;
|
|
110
|
+
for (const [terminalId, snapshot] of result) {
|
|
111
|
+
try {
|
|
112
|
+
this.applyProcessSnapshot(terminalId, snapshot, now);
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (nextAgentStatus) {
|
|
109
|
-
record.summary.agentStatus = nextAgentStatus;
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.reportDiagnostic({ event: 'terminal.process_snapshot_failed', terminalId, error });
|
|
110
116
|
}
|
|
111
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
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
|
|
689
|
+
return { record, generation, source };
|
|
317
690
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
324
|
-
if (!
|
|
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.
|
|
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
|
-
|
|
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).
|
|
343
|
-
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
return
|
|
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.
|
|
808
|
+
if (!record || !await this.ensureAttachedAsync(record)) {
|
|
360
809
|
return null;
|
|
361
810
|
}
|
|
362
811
|
await this.refreshCwd(record);
|
|
363
|
-
return
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
891
|
+
const inputBytes = Buffer.byteLength(data, 'utf8');
|
|
892
|
+
if (!controlOperationAllowed(options)) {
|
|
422
893
|
return false;
|
|
423
894
|
}
|
|
424
|
-
if (
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
468
|
-
|
|
1026
|
+
if (this.ptyAdapter.listExisting && !this.ptyAdapter.destroySession) {
|
|
1027
|
+
return false;
|
|
469
1028
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (shouldAutoRename) {
|
|
551
|
-
record.summary.title = this.createTitle(cwd);
|
|
1272
|
+
this.sealPendingScroll(record);
|
|
1273
|
+
if (!this.canEnqueueControl(record)) {
|
|
1274
|
+
return;
|
|
552
1275
|
}
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|