nterminal 1.2.78 → 1.2.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +6 -0
- package/dist/client/assets/MarkdownPreview-D2UZ6wJU.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-BkGu6YVq.js → TranscriptMarkdownBody-BFu2FzgT.js} +1 -1
- package/dist/client/assets/index-4MeJRcb6.js +57 -0
- package/dist/client/assets/{index-Bgqck-vt.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-D5NthhhG.js +0 -1
- package/dist/client/assets/index-B3_mYQzf.css +0 -1
- package/dist/client/assets/index-gSePR0Ob.js +0 -57
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { access, open as openFile, readFile, readdir, realpath, stat } from 'node:fs/promises';
|
|
3
5
|
import os from 'node:os';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import * as nodePty from 'node-pty';
|
|
6
8
|
import path from 'node:path';
|
|
9
|
+
import { normalizeTerminalTitle, PtySessionUnavailableError } from './PtyAdapter.js';
|
|
7
10
|
import { resolveCodexTranscriptFromOpenFds, resolveCodexTranscriptFromProcessLogs } from './codexTranscriptSource.js';
|
|
8
11
|
import { isSessionId } from './sessionId.js';
|
|
9
|
-
import {
|
|
12
|
+
import { processStartTicksToSecondsAsync, readProcessCwdAsync, readProcessEnvValuesAsync, readProcessStartTicksAsync } from './procfs.js';
|
|
10
13
|
import { TtlCache } from './ttlCache.js';
|
|
14
|
+
import { KeyedSingleFlight, mapWithConcurrency, runCommand } from './asyncCommand.js';
|
|
11
15
|
// Dedicated socket name so NTerminal's tmux server is completely isolated from
|
|
12
16
|
// any tmux the operator runs themselves (different `-L` = different daemon).
|
|
13
17
|
// Their `tmux ls` won't show our sessions, and our sessions don't pick up
|
|
@@ -22,9 +26,43 @@ const TRANSCRIPT_FILE_NEGATIVE_CACHE_TTL_MS = 20_000;
|
|
|
22
26
|
// letting a history-paging burst (several page requests within seconds)
|
|
23
27
|
// reuse one resolution instead of re-forking tmux/ps per page.
|
|
24
28
|
const TRANSCRIPT_RESOLUTION_CACHE_TTL_MS = 3_000;
|
|
29
|
+
const PROCESS_TREE_CACHE_TTL_MS = 1_000;
|
|
25
30
|
const CLAUDE_TRANSCRIPT_START_SKEW_MS = 10_000;
|
|
26
31
|
const CLAUDE_TRANSCRIPT_SAMPLE_BYTES = 512 * 1024;
|
|
32
|
+
const CLAUDE_TRANSCRIPT_CACHE_LIMIT = 1_024;
|
|
33
|
+
const TMUX_SYNC_TIMEOUT_MS = 750;
|
|
34
|
+
const TMUX_SYNC_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
const TMUX_CREATE_TIMEOUT_MS = 5_000;
|
|
36
|
+
const TMUX_HISTORY_TIMEOUT_MS = 5_000;
|
|
37
|
+
const TMUX_HISTORY_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
38
|
+
const TMUX_HISTORY_CAPTURE_ATTEMPTS = 3;
|
|
39
|
+
const TMUX_HISTORY_TOKEN_LINES = 128;
|
|
40
|
+
const TMUX_SESSION_METADATA_CONCURRENCY = 8;
|
|
41
|
+
const PENDING_CREATION_SESSION_PREFIX = 'nterminal-pending-';
|
|
42
|
+
const CREATION_MARKER_OPTION = '@nterminal_creation_state';
|
|
43
|
+
const PENDING_CREATION_MARKER_PREFIX = 'pending:';
|
|
44
|
+
const CREATION_MARKER_NOT_OWNED_OUTPUT = 'nterminal-marker-not-owned';
|
|
27
45
|
const transcriptFileCache = new TtlCache();
|
|
46
|
+
const confirmedClaudeTranscripts = new Map();
|
|
47
|
+
export function isMissingTmuxSessionError(error) {
|
|
48
|
+
const rawStderr = error?.stderr;
|
|
49
|
+
const stderr = typeof rawStderr === 'string'
|
|
50
|
+
? rawStderr
|
|
51
|
+
: Buffer.isBuffer(rawStderr)
|
|
52
|
+
? rawStderr.toString()
|
|
53
|
+
: '';
|
|
54
|
+
const message = error instanceof Error ? error.message : '';
|
|
55
|
+
return /(?:can't find (?:session|pane)(?::|\s*$)|no server running|no sessions?\b)/i.test(`${stderr}\n${message}`);
|
|
56
|
+
}
|
|
57
|
+
export function isCommandOutputLimitError(error) {
|
|
58
|
+
return error?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
|
|
59
|
+
}
|
|
60
|
+
function commandMayHaveCompleted(error) {
|
|
61
|
+
const commandError = error;
|
|
62
|
+
return commandError?.killed === true ||
|
|
63
|
+
typeof commandError?.signal === 'string' ||
|
|
64
|
+
commandError?.code === 'ETIMEDOUT';
|
|
65
|
+
}
|
|
28
66
|
// Wraps each PTY in a tmux session so shells survive `nterminalctl restart`
|
|
29
67
|
// and NTerminal version updates without losing state. The operator never
|
|
30
68
|
// interacts with tmux directly — the bundled `nterminal-tmux.conf` strips the
|
|
@@ -33,10 +71,26 @@ const transcriptFileCache = new TtlCache();
|
|
|
33
71
|
export class TmuxPtyAdapter {
|
|
34
72
|
configPath;
|
|
35
73
|
socket;
|
|
74
|
+
commandRunner;
|
|
75
|
+
ptySpawner;
|
|
36
76
|
transcriptResolutionCache = new TtlCache();
|
|
77
|
+
transcriptResolutionInflight = new KeyedSingleFlight();
|
|
78
|
+
processTreeCache = new TtlCache();
|
|
79
|
+
processTreeInflight = new KeyedSingleFlight();
|
|
80
|
+
historyTokenGeneration = randomBytes(16).toString('hex');
|
|
81
|
+
historyOutputGenerationBySession = new Map();
|
|
82
|
+
historyOutputUnreliableSessions = new Set();
|
|
83
|
+
panePidBySession = new Map();
|
|
84
|
+
inflightCreationSessions = new Set();
|
|
85
|
+
inflightFinalCreationMarkers = new Map();
|
|
86
|
+
pendingCreationCleanup = new Set();
|
|
87
|
+
pendingMarkerCleanup = new Map();
|
|
88
|
+
sessionCatalogLoaded = false;
|
|
37
89
|
constructor(options = {}) {
|
|
38
90
|
this.configPath = options.configPath ?? defaultConfigPath();
|
|
39
91
|
this.socket = options.socket ?? TMUX_SOCKET;
|
|
92
|
+
this.commandRunner = options.commandRunner ?? runCommand;
|
|
93
|
+
this.ptySpawner = options.ptySpawner ?? nodePty.spawn;
|
|
40
94
|
}
|
|
41
95
|
// Returns an instance only when `tmux` is on PATH and the bundled config
|
|
42
96
|
// file actually exists. The config-existence check prevents a confusing
|
|
@@ -46,7 +100,12 @@ export class TmuxPtyAdapter {
|
|
|
46
100
|
// tmux (or with the config missing) still works — it just loses the
|
|
47
101
|
// across-restart persistence.
|
|
48
102
|
static detect(options = {}) {
|
|
49
|
-
const probe = spawnSync('sh', ['-c', 'command -v tmux'], {
|
|
103
|
+
const probe = spawnSync('sh', ['-c', 'command -v tmux'], {
|
|
104
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
105
|
+
timeout: TMUX_SYNC_TIMEOUT_MS,
|
|
106
|
+
killSignal: 'SIGKILL',
|
|
107
|
+
maxBuffer: TMUX_SYNC_MAX_BUFFER_BYTES
|
|
108
|
+
});
|
|
50
109
|
if (probe.status !== 0) {
|
|
51
110
|
return null;
|
|
52
111
|
}
|
|
@@ -56,91 +115,511 @@ export class TmuxPtyAdapter {
|
|
|
56
115
|
}
|
|
57
116
|
return new TmuxPtyAdapter({ ...options, configPath });
|
|
58
117
|
}
|
|
59
|
-
spawn(options) {
|
|
118
|
+
async spawn(options) {
|
|
60
119
|
if (!options.sessionId) {
|
|
61
120
|
throw new Error('TmuxPtyAdapter.spawn requires a sessionId');
|
|
62
121
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
// -n <title> sets the window name only on the create path. On attach it
|
|
70
|
-
// is ignored — we don't want a re-attach to overwrite a name the
|
|
71
|
-
// operator already chose via the rename UI in a previous session.
|
|
122
|
+
const creationToken = randomBytes(8).toString('hex');
|
|
123
|
+
const pendingSessionId = `${PENDING_CREATION_SESSION_PREFIX}${options.sessionId}-${creationToken}`;
|
|
124
|
+
const pendingMarker = `${PENDING_CREATION_MARKER_PREFIX}${creationToken}`;
|
|
125
|
+
const committedMarker = `committed:${creationToken}`;
|
|
126
|
+
this.inflightCreationSessions.add(pendingSessionId);
|
|
72
127
|
const args = [
|
|
73
128
|
'-L',
|
|
74
129
|
this.socket,
|
|
75
130
|
'-f',
|
|
76
131
|
this.configPath,
|
|
77
132
|
'new-session',
|
|
78
|
-
'-
|
|
133
|
+
'-d',
|
|
79
134
|
'-s',
|
|
80
|
-
|
|
135
|
+
pendingSessionId,
|
|
81
136
|
'-c',
|
|
82
137
|
options.cwd
|
|
83
138
|
];
|
|
84
139
|
if (options.title) {
|
|
85
|
-
args.push('-n',
|
|
140
|
+
args.push('-n', normalizeTerminalTitle(options.title));
|
|
86
141
|
}
|
|
87
142
|
args.push(options.shell);
|
|
88
|
-
|
|
143
|
+
try {
|
|
144
|
+
await this.commandRunner('tmux', args, {
|
|
145
|
+
cwd: options.cwd,
|
|
146
|
+
env: options.env,
|
|
147
|
+
timeoutMs: TMUX_CREATE_TIMEOUT_MS,
|
|
148
|
+
maxBuffer: TMUX_SYNC_MAX_BUFFER_BYTES
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (commandMayHaveCompleted(error)) {
|
|
153
|
+
await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], error);
|
|
154
|
+
}
|
|
155
|
+
this.inflightCreationSessions.delete(pendingSessionId);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await this.setCreationMarker(pendingSessionId, pendingMarker);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], error);
|
|
163
|
+
}
|
|
164
|
+
const panePid = await this.readPanePidAsync(pendingSessionId);
|
|
165
|
+
if (panePid === null) {
|
|
166
|
+
return await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], new Error('tmux pane pid unavailable after session creation'));
|
|
167
|
+
}
|
|
168
|
+
this.inflightFinalCreationMarkers.set(options.sessionId, pendingMarker);
|
|
169
|
+
try {
|
|
170
|
+
await this.commandRunner('tmux', [
|
|
171
|
+
'-L',
|
|
172
|
+
this.socket,
|
|
173
|
+
'-f',
|
|
174
|
+
this.configPath,
|
|
175
|
+
'rename-session',
|
|
176
|
+
'-t',
|
|
177
|
+
exactSessionTarget(pendingSessionId),
|
|
178
|
+
options.sessionId
|
|
179
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const finalMarkerClaims = new Map();
|
|
183
|
+
if (commandMayHaveCompleted(error)) {
|
|
184
|
+
const marker = await this.readCreationMarker(options.sessionId);
|
|
185
|
+
if (marker.kind === 'found' && marker.value === pendingMarker) {
|
|
186
|
+
finalMarkerClaims.set(options.sessionId, creationToken);
|
|
187
|
+
}
|
|
188
|
+
else if (marker.kind === 'missing' || marker.kind === 'unavailable') {
|
|
189
|
+
// A timed-out tmux client may have handed the rename request to the
|
|
190
|
+
// server just before it was killed. Keep the ownership claim even
|
|
191
|
+
// when the immediate probe sees no final name; a later catalog pass
|
|
192
|
+
// verifies the marker before hiding or deleting anything.
|
|
193
|
+
finalMarkerClaims.set(options.sessionId, creationToken);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.inflightFinalCreationMarkers.delete(options.sessionId);
|
|
197
|
+
return await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], error, finalMarkerClaims);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await this.setCreationMarker(options.sessionId, committedMarker);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], error, new Map([[options.sessionId, creationToken]]));
|
|
204
|
+
}
|
|
205
|
+
let pty;
|
|
206
|
+
try {
|
|
207
|
+
pty = this.attachClient(options, panePid);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
return await this.cleanupSpawnedSessionsOrThrow([pendingSessionId], error, new Map([[options.sessionId, creationToken]]));
|
|
211
|
+
}
|
|
212
|
+
this.inflightCreationSessions.delete(pendingSessionId);
|
|
213
|
+
this.inflightFinalCreationMarkers.delete(options.sessionId);
|
|
214
|
+
this.pendingCreationCleanup.delete(pendingSessionId);
|
|
215
|
+
this.panePidBySession.delete(pendingSessionId);
|
|
216
|
+
this.panePidBySession.set(options.sessionId, panePid);
|
|
217
|
+
return pty;
|
|
218
|
+
}
|
|
219
|
+
async setCreationMarker(sessionId, marker) {
|
|
220
|
+
await this.commandRunner('tmux', [
|
|
221
|
+
'-L',
|
|
222
|
+
this.socket,
|
|
223
|
+
'-f',
|
|
224
|
+
this.configPath,
|
|
225
|
+
'set-option',
|
|
226
|
+
'-t',
|
|
227
|
+
exactWindowTarget(sessionId),
|
|
228
|
+
CREATION_MARKER_OPTION,
|
|
229
|
+
marker
|
|
230
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
231
|
+
}
|
|
232
|
+
async readCreationMarker(sessionId) {
|
|
233
|
+
try {
|
|
234
|
+
const result = await this.commandRunner('tmux', [
|
|
235
|
+
'-L',
|
|
236
|
+
this.socket,
|
|
237
|
+
'-f',
|
|
238
|
+
this.configPath,
|
|
239
|
+
'display-message',
|
|
240
|
+
'-p',
|
|
241
|
+
'-t',
|
|
242
|
+
exactWindowTarget(sessionId),
|
|
243
|
+
`#{${CREATION_MARKER_OPTION}}`
|
|
244
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
245
|
+
return { kind: 'found', value: result.stdout.trim() };
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return isMissingTmuxSessionError(error)
|
|
249
|
+
? { kind: 'missing' }
|
|
250
|
+
: { kind: 'unavailable' };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async readTmuxSessionInternalId(sessionId) {
|
|
254
|
+
try {
|
|
255
|
+
const result = await this.commandRunner('tmux', [
|
|
256
|
+
'-L',
|
|
257
|
+
this.socket,
|
|
258
|
+
'-f',
|
|
259
|
+
this.configPath,
|
|
260
|
+
'display-message',
|
|
261
|
+
'-p',
|
|
262
|
+
'-t',
|
|
263
|
+
exactWindowTarget(sessionId),
|
|
264
|
+
'#{session_id}'
|
|
265
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
266
|
+
const value = result.stdout.trim();
|
|
267
|
+
if (!/^\$\d+$/.test(value)) {
|
|
268
|
+
return {
|
|
269
|
+
kind: 'unavailable',
|
|
270
|
+
error: new Error(`tmux returned an invalid internal session id for ${sessionId}`)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { kind: 'found', value };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return isMissingTmuxSessionError(error)
|
|
277
|
+
? { kind: 'missing' }
|
|
278
|
+
: { kind: 'unavailable', error };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async destroySessionIfCreationMarkerOwned(sessionId, creationToken) {
|
|
282
|
+
if (!isCreationToken(creationToken)) {
|
|
283
|
+
return { kind: 'not-owned' };
|
|
284
|
+
}
|
|
285
|
+
const internalId = await this.readTmuxSessionInternalId(sessionId);
|
|
286
|
+
if (internalId.kind !== 'found') {
|
|
287
|
+
return internalId;
|
|
288
|
+
}
|
|
289
|
+
const markerMatches = `#{||:#{==:#{${CREATION_MARKER_OPTION}},${PENDING_CREATION_MARKER_PREFIX}${creationToken}},#{==:#{${CREATION_MARKER_OPTION}},committed:${creationToken}}}`;
|
|
290
|
+
try {
|
|
291
|
+
const result = await this.commandRunner('tmux', [
|
|
292
|
+
'-L',
|
|
293
|
+
this.socket,
|
|
294
|
+
'-f',
|
|
295
|
+
this.configPath,
|
|
296
|
+
'if-shell',
|
|
297
|
+
'-F',
|
|
298
|
+
'-t',
|
|
299
|
+
`${internalId.value}:0`,
|
|
300
|
+
markerMatches,
|
|
301
|
+
`kill-session -t ${internalId.value}`,
|
|
302
|
+
`display-message -p ${CREATION_MARKER_NOT_OWNED_OUTPUT}`
|
|
303
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
304
|
+
return {
|
|
305
|
+
kind: result.stdout.trim() === CREATION_MARKER_NOT_OWNED_OUTPUT
|
|
306
|
+
? 'not-owned'
|
|
307
|
+
: 'destroyed'
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
return isMissingTmuxSessionError(error)
|
|
312
|
+
? { kind: 'missing' }
|
|
313
|
+
: { kind: 'unavailable', error };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async cleanupSpawnedSessionsOrThrow(sessionIds, cause, markerClaims = new Map()) {
|
|
317
|
+
const cleanupErrors = [];
|
|
318
|
+
const unresolved = new Set(sessionIds);
|
|
319
|
+
const unresolvedMarkerClaims = new Map(markerClaims);
|
|
320
|
+
for (const sessionId of unresolved) {
|
|
321
|
+
this.inflightCreationSessions.delete(sessionId);
|
|
322
|
+
this.pendingCreationCleanup.add(sessionId);
|
|
323
|
+
}
|
|
324
|
+
for (const [sessionId, creationToken] of unresolvedMarkerClaims) {
|
|
325
|
+
this.inflightFinalCreationMarkers.delete(sessionId);
|
|
326
|
+
this.pendingMarkerCleanup.set(sessionId, creationToken);
|
|
327
|
+
}
|
|
328
|
+
for (const sessionId of [...unresolved]) {
|
|
329
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
330
|
+
try {
|
|
331
|
+
await this.commandRunner('tmux', [
|
|
332
|
+
'-L',
|
|
333
|
+
this.socket,
|
|
334
|
+
'-f',
|
|
335
|
+
this.configPath,
|
|
336
|
+
'kill-session',
|
|
337
|
+
'-t',
|
|
338
|
+
exactSessionTarget(sessionId)
|
|
339
|
+
], { timeoutMs: TMUX_CREATE_TIMEOUT_MS });
|
|
340
|
+
unresolved.delete(sessionId);
|
|
341
|
+
this.clearSessionCaches(sessionId);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (isMissingTmuxSessionError(error)) {
|
|
346
|
+
unresolved.delete(sessionId);
|
|
347
|
+
this.clearSessionCaches(sessionId);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
cleanupErrors.push(error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [sessionId, creationToken] of [...unresolvedMarkerClaims]) {
|
|
355
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
356
|
+
const cleanup = await this.destroySessionIfCreationMarkerOwned(sessionId, creationToken);
|
|
357
|
+
if (cleanup.kind === 'missing') {
|
|
358
|
+
// The final name may appear after a timed-out rename client exits.
|
|
359
|
+
// Keep the marker claim for a later catalog pass, but preserve the
|
|
360
|
+
// original creation error instead of replacing it with a cleanup
|
|
361
|
+
// error when ownership cannot be checked yet.
|
|
362
|
+
unresolvedMarkerClaims.delete(sessionId);
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (cleanup.kind === 'unavailable') {
|
|
366
|
+
cleanupErrors.push(cleanup.error);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (cleanup.kind === 'not-owned') {
|
|
370
|
+
unresolvedMarkerClaims.delete(sessionId);
|
|
371
|
+
this.pendingMarkerCleanup.delete(sessionId);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (cleanup.kind === 'destroyed') {
|
|
375
|
+
unresolvedMarkerClaims.delete(sessionId);
|
|
376
|
+
this.clearSessionCaches(sessionId);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (unresolved.size === 0 && unresolvedMarkerClaims.size === 0) {
|
|
382
|
+
throw cause;
|
|
383
|
+
}
|
|
384
|
+
const quarantined = [...unresolved, ...unresolvedMarkerClaims.keys()];
|
|
385
|
+
throw new AggregateError([cause, ...cleanupErrors], `tmux sessions ${quarantined.join(', ')} are quarantined pending cleanup after failed creation`);
|
|
386
|
+
}
|
|
387
|
+
clearSessionCaches(sessionId) {
|
|
388
|
+
this.panePidBySession.delete(sessionId);
|
|
389
|
+
this.pendingCreationCleanup.delete(sessionId);
|
|
390
|
+
this.pendingMarkerCleanup.delete(sessionId);
|
|
391
|
+
this.historyOutputGenerationBySession.delete(sessionId);
|
|
392
|
+
this.historyOutputUnreliableSessions.delete(sessionId);
|
|
393
|
+
this.transcriptResolutionCache.delete(sessionId);
|
|
394
|
+
}
|
|
395
|
+
isInflightFinalCreation(sessionId, marker) {
|
|
396
|
+
const inflightMarker = this.inflightFinalCreationMarkers.get(sessionId);
|
|
397
|
+
return inflightMarker !== undefined &&
|
|
398
|
+
creationMarkerBelongsToToken(marker, creationTokenFromMarker(inflightMarker));
|
|
399
|
+
}
|
|
400
|
+
attachExisting(options) {
|
|
401
|
+
const panePid = this.panePidBySession.get(options.sessionId);
|
|
402
|
+
if (panePid === undefined) {
|
|
403
|
+
if (this.sessionCatalogLoaded) {
|
|
404
|
+
throw new PtySessionUnavailableError(options.sessionId);
|
|
405
|
+
}
|
|
406
|
+
throw new Error('tmux session catalog is not available yet');
|
|
407
|
+
}
|
|
408
|
+
return this.attachClient(options, panePid);
|
|
409
|
+
}
|
|
410
|
+
attachClient(options, panePid) {
|
|
411
|
+
const pty = this.ptySpawner('tmux', [
|
|
412
|
+
'-L',
|
|
413
|
+
this.socket,
|
|
414
|
+
'-f',
|
|
415
|
+
this.configPath,
|
|
416
|
+
'attach-session',
|
|
417
|
+
'-t',
|
|
418
|
+
exactSessionTarget(options.sessionId)
|
|
419
|
+
], {
|
|
89
420
|
name: 'xterm-256color',
|
|
90
421
|
cols: options.cols,
|
|
91
422
|
rows: options.rows,
|
|
92
423
|
cwd: options.cwd,
|
|
93
424
|
env: options.env
|
|
94
425
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
426
|
+
this.panePidBySession.set(options.sessionId, panePid);
|
|
427
|
+
this.historyOutputUnreliableSessions.delete(options.sessionId);
|
|
428
|
+
this.bumpHistoryOutputGeneration(options.sessionId);
|
|
429
|
+
return wrapTmuxPty(pty, panePid, () => this.bumpHistoryOutputGeneration(options.sessionId), () => {
|
|
430
|
+
this.historyOutputUnreliableSessions.add(options.sessionId);
|
|
431
|
+
this.bumpHistoryOutputGeneration(options.sessionId);
|
|
432
|
+
}, () => {
|
|
433
|
+
this.historyOutputUnreliableSessions.delete(options.sessionId);
|
|
434
|
+
this.bumpHistoryOutputGeneration(options.sessionId);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
bumpHistoryOutputGeneration(sessionId) {
|
|
438
|
+
const generation = this.historyOutputGenerationBySession.get(sessionId) ?? 0;
|
|
439
|
+
this.historyOutputGenerationBySession.set(sessionId, generation + 1);
|
|
440
|
+
}
|
|
441
|
+
async listExisting() {
|
|
442
|
+
// Keep variable strings out of the catalog: tabs and newlines are legal in
|
|
443
|
+
// cwd components. The stable tmux pane id ties subsequent metadata reads to
|
|
444
|
+
// the cataloged pane even if another session reuses the same visible name.
|
|
445
|
+
let stdout;
|
|
446
|
+
try {
|
|
447
|
+
const result = await this.commandRunner('tmux', [
|
|
448
|
+
'-L',
|
|
449
|
+
this.socket,
|
|
450
|
+
'-f',
|
|
451
|
+
this.configPath,
|
|
452
|
+
'list-panes',
|
|
453
|
+
'-a',
|
|
454
|
+
'-F',
|
|
455
|
+
`#{session_name}\t#{pane_pid}\t#{pane_id}\t#{pid}\t#{session_created}\t#{${CREATION_MARKER_OPTION}}`
|
|
456
|
+
], {
|
|
457
|
+
timeoutMs: TMUX_SYNC_TIMEOUT_MS,
|
|
458
|
+
maxBuffer: TMUX_SYNC_MAX_BUFFER_BYTES
|
|
459
|
+
});
|
|
460
|
+
stdout = result.stdout;
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (isMissingTmuxSessionError(error)) {
|
|
464
|
+
this.sessionCatalogLoaded = true;
|
|
465
|
+
this.clearUnavailableSessionCaches();
|
|
466
|
+
return [];
|
|
125
467
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
const listedSessions = parseFixedTmuxSessionCatalog(stdout);
|
|
471
|
+
if (!listedSessions) {
|
|
472
|
+
throw new Error('tmux returned an invalid fixed-field session catalog');
|
|
473
|
+
}
|
|
474
|
+
const liveSessionIds = new Set();
|
|
475
|
+
const restorableSessions = [];
|
|
476
|
+
for (const session of listedSessions) {
|
|
477
|
+
liveSessionIds.add(session.sessionId);
|
|
478
|
+
if (isPendingCreationSessionId(session.sessionId)) {
|
|
479
|
+
if (!this.inflightCreationSessions.has(session.sessionId)) {
|
|
480
|
+
this.pendingCreationCleanup.add(session.sessionId);
|
|
481
|
+
}
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (this.isInflightFinalCreation(session.sessionId, session.creationMarker)) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (session.creationMarker.startsWith(PENDING_CREATION_MARKER_PREFIX)) {
|
|
488
|
+
this.pendingMarkerCleanup.set(session.sessionId, creationTokenFromMarker(session.creationMarker));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
restorableSessions.push(session);
|
|
492
|
+
}
|
|
493
|
+
for (const sessionId of this.panePidBySession.keys()) {
|
|
494
|
+
if (!liveSessionIds.has(sessionId)) {
|
|
495
|
+
if (!this.inflightCreationSessions.has(sessionId)) {
|
|
496
|
+
this.clearSessionCaches(sessionId);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
await this.reconcilePendingCreationCleanup(liveSessionIds);
|
|
501
|
+
await this.reconcilePendingMarkerCleanup(liveSessionIds);
|
|
502
|
+
const hiddenSessions = new Set([
|
|
503
|
+
...this.inflightCreationSessions,
|
|
504
|
+
...this.pendingCreationCleanup,
|
|
505
|
+
...this.pendingMarkerCleanup.keys()
|
|
506
|
+
]);
|
|
507
|
+
const visibleSessions = restorableSessions.filter((session) => liveSessionIds.has(session.sessionId) &&
|
|
508
|
+
!hiddenSessions.has(session.sessionId));
|
|
509
|
+
const restoredSessions = await mapWithConcurrency(visibleSessions, TMUX_SESSION_METADATA_CONCURRENCY, async (session) => {
|
|
510
|
+
if (session.title !== undefined && session.cwd !== undefined) {
|
|
511
|
+
return {
|
|
512
|
+
listed: session,
|
|
513
|
+
persisted: toPersistedSession(session.sessionId, session.title, session.cwd)
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
listed: session,
|
|
518
|
+
persisted: await this.readPersistedSessionMetadata(session)
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
const sessions = [];
|
|
522
|
+
for (const restored of restoredSessions) {
|
|
523
|
+
if (!restored.persisted) {
|
|
524
|
+
if (this.panePidBySession.get(restored.listed.sessionId) ===
|
|
525
|
+
restored.listed.panePid) {
|
|
526
|
+
this.clearSessionCaches(restored.listed.sessionId);
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
this.panePidBySession.set(restored.listed.sessionId, restored.listed.panePid);
|
|
531
|
+
sessions.push(restored.persisted);
|
|
532
|
+
}
|
|
533
|
+
this.sessionCatalogLoaded = true;
|
|
534
|
+
return sessions;
|
|
535
|
+
}
|
|
536
|
+
async readPersistedSessionMetadata(session) {
|
|
537
|
+
const paneId = session.paneId;
|
|
538
|
+
if (!paneId) {
|
|
539
|
+
return toPersistedSession(session.sessionId, '', '');
|
|
540
|
+
}
|
|
541
|
+
const readField = async (format) => {
|
|
542
|
+
const result = await this.commandRunner('tmux', [
|
|
543
|
+
'-L',
|
|
544
|
+
this.socket,
|
|
545
|
+
'-f',
|
|
546
|
+
this.configPath,
|
|
547
|
+
'display-message',
|
|
548
|
+
'-p',
|
|
549
|
+
'-t',
|
|
550
|
+
exactPaneIdTarget(paneId),
|
|
551
|
+
`#{pid}\t#{session_created}\t#{pane_pid}\t#{${CREATION_MARKER_OPTION}}\t#{session_name}\t${format}`
|
|
552
|
+
], {
|
|
553
|
+
timeoutMs: TMUX_SYNC_TIMEOUT_MS,
|
|
554
|
+
maxBuffer: TMUX_SYNC_MAX_BUFFER_BYTES
|
|
555
|
+
});
|
|
556
|
+
const output = removeTmuxOutputTerminator(result.stdout);
|
|
557
|
+
const fields = splitFixedPrefix(output, 5);
|
|
558
|
+
if (!fields) {
|
|
559
|
+
throw new Error('tmux returned invalid pane metadata');
|
|
560
|
+
}
|
|
561
|
+
const [serverPidRaw = '', sessionCreatedRaw = '', panePidRaw = '', creationMarker = '', sessionId = '', value = ''] = fields;
|
|
562
|
+
const serverPid = Number(serverPidRaw);
|
|
563
|
+
const sessionCreated = Number(sessionCreatedRaw);
|
|
564
|
+
const panePid = Number(panePidRaw);
|
|
565
|
+
if (!Number.isInteger(serverPid) ||
|
|
566
|
+
serverPid <= 0 ||
|
|
567
|
+
!Number.isInteger(sessionCreated) ||
|
|
568
|
+
sessionCreated <= 0 ||
|
|
569
|
+
!Number.isInteger(panePid) ||
|
|
570
|
+
panePid <= 0) {
|
|
571
|
+
throw new Error('tmux returned invalid pane identity');
|
|
132
572
|
}
|
|
133
573
|
return {
|
|
134
574
|
sessionId,
|
|
135
|
-
|
|
136
|
-
|
|
575
|
+
panePid,
|
|
576
|
+
serverPid,
|
|
577
|
+
sessionCreated,
|
|
578
|
+
creationMarker,
|
|
579
|
+
value
|
|
137
580
|
};
|
|
138
|
-
}
|
|
139
|
-
|
|
581
|
+
};
|
|
582
|
+
const [title, cwd] = await Promise.allSettled([
|
|
583
|
+
readField('#{window_name}'),
|
|
584
|
+
readField('#{pane_current_path}')
|
|
585
|
+
]);
|
|
586
|
+
for (const result of [title, cwd]) {
|
|
587
|
+
if (result.status !== 'rejected') {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (isMissingTmuxSessionError(result.reason)) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
throw result.reason;
|
|
594
|
+
}
|
|
595
|
+
if (title.status !== 'fulfilled' || cwd.status !== 'fulfilled') {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
if (!tmuxMetadataMatchesCatalog(title.value, session) ||
|
|
599
|
+
!tmuxMetadataMatchesCatalog(cwd.value, session)) {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
return toPersistedSession(session.sessionId, title.value.value, cwd.value.value);
|
|
140
603
|
}
|
|
141
|
-
destroySession(sessionId) {
|
|
142
|
-
|
|
143
|
-
|
|
604
|
+
async destroySession(sessionId) {
|
|
605
|
+
try {
|
|
606
|
+
await this.commandRunner('tmux', [
|
|
607
|
+
'-L',
|
|
608
|
+
this.socket,
|
|
609
|
+
'-f',
|
|
610
|
+
this.configPath,
|
|
611
|
+
'kill-session',
|
|
612
|
+
'-t',
|
|
613
|
+
exactSessionTarget(sessionId)
|
|
614
|
+
]);
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (!isMissingTmuxSessionError(error)) {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
this.clearSessionCaches(sessionId);
|
|
622
|
+
return true;
|
|
144
623
|
}
|
|
145
624
|
async pollSessions() {
|
|
146
625
|
// list-panes -a returns every pane across every session in one call.
|
|
@@ -149,21 +628,36 @@ export class TmuxPtyAdapter {
|
|
|
149
628
|
// foreground in that pane (the shell when at a prompt, otherwise vim
|
|
150
629
|
// / node / etc.). `pane_pid` is the root of that pane's process
|
|
151
630
|
// subtree — we walk its descendants below to decide busy vs idle.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
631
|
+
let panesOutput;
|
|
632
|
+
let tree;
|
|
633
|
+
try {
|
|
634
|
+
const [panes, processTree] = await Promise.all([
|
|
635
|
+
this.commandRunner('tmux', [
|
|
636
|
+
'-L',
|
|
637
|
+
this.socket,
|
|
638
|
+
'-f',
|
|
639
|
+
this.configPath,
|
|
640
|
+
'list-panes',
|
|
641
|
+
'-a',
|
|
642
|
+
'-F',
|
|
643
|
+
`#{session_name}\t#{pane_pid}\t#{pane_current_command}\t#{${CREATION_MARKER_OPTION}}`
|
|
644
|
+
]),
|
|
645
|
+
this.readProcessTreeSnapshot()
|
|
646
|
+
]);
|
|
647
|
+
panesOutput = panes.stdout;
|
|
648
|
+
tree = processTree;
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
if (isMissingTmuxSessionError(error)) {
|
|
652
|
+
this.sessionCatalogLoaded = true;
|
|
653
|
+
this.clearUnavailableSessionCaches();
|
|
654
|
+
return new Map();
|
|
655
|
+
}
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
162
658
|
const out = new Map();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const tree = readProcessTree();
|
|
166
|
-
for (const line of result.stdout.toString().split('\n')) {
|
|
659
|
+
const rows = [];
|
|
660
|
+
for (const line of panesOutput.split('\n')) {
|
|
167
661
|
const trimmed = line.trim();
|
|
168
662
|
if (!trimmed)
|
|
169
663
|
continue;
|
|
@@ -173,124 +667,365 @@ export class TmuxPtyAdapter {
|
|
|
173
667
|
const sessionId = parts[0];
|
|
174
668
|
const panePid = Number(parts[1]);
|
|
175
669
|
const currentCommand = parts[2].trim();
|
|
670
|
+
const creationMarker = parts[3]?.trim() ?? '';
|
|
176
671
|
if (!sessionId || !currentCommand || !Number.isFinite(panePid))
|
|
177
672
|
continue;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
673
|
+
if (isPendingCreationSessionId(sessionId) && !this.inflightCreationSessions.has(sessionId)) {
|
|
674
|
+
this.pendingCreationCleanup.add(sessionId);
|
|
675
|
+
}
|
|
676
|
+
if (!isPendingCreationSessionId(sessionId) &&
|
|
677
|
+
creationMarker.startsWith(PENDING_CREATION_MARKER_PREFIX)) {
|
|
678
|
+
if (!this.isInflightFinalCreation(sessionId, creationMarker)) {
|
|
679
|
+
this.pendingMarkerCleanup.set(sessionId, creationTokenFromMarker(creationMarker));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
rows.push({ sessionId, panePid, currentCommand, creationMarker });
|
|
683
|
+
}
|
|
684
|
+
const liveSessionIds = new Set();
|
|
685
|
+
for (const row of rows) {
|
|
686
|
+
liveSessionIds.add(row.sessionId);
|
|
687
|
+
this.panePidBySession.set(row.sessionId, row.panePid);
|
|
688
|
+
}
|
|
689
|
+
await this.reconcilePendingCreationCleanup(liveSessionIds);
|
|
690
|
+
await this.reconcilePendingMarkerCleanup(liveSessionIds);
|
|
691
|
+
const hiddenSessions = new Set([
|
|
692
|
+
...this.inflightCreationSessions,
|
|
693
|
+
...this.pendingCreationCleanup,
|
|
694
|
+
...this.pendingMarkerCleanup.keys()
|
|
695
|
+
]);
|
|
696
|
+
const visibleRows = rows.filter((row) => liveSessionIds.has(row.sessionId) &&
|
|
697
|
+
!isPendingCreationSessionId(row.sessionId) &&
|
|
698
|
+
!row.creationMarker.startsWith(PENDING_CREATION_MARKER_PREFIX) &&
|
|
699
|
+
!this.isInflightFinalCreation(row.sessionId, row.creationMarker) &&
|
|
700
|
+
!hiddenSessions.has(row.sessionId));
|
|
701
|
+
const normalizedCommands = tree
|
|
702
|
+
? await mapWithConcurrency(visibleRows, 8, async (row) => await normalizeTmuxCurrentCommandAsync(row.currentCommand, tree, row.panePid))
|
|
703
|
+
: visibleRows.map((row) => row.currentCommand);
|
|
704
|
+
for (let index = 0; index < visibleRows.length; index += 1) {
|
|
705
|
+
const row = visibleRows[index];
|
|
706
|
+
out.set(row.sessionId, {
|
|
707
|
+
currentCommand: normalizedCommands[index] ?? row.currentCommand,
|
|
708
|
+
busy: tree ? hasRunningDescendant(tree, row.panePid) : false
|
|
709
|
+
});
|
|
181
710
|
}
|
|
711
|
+
for (const sessionId of this.panePidBySession.keys()) {
|
|
712
|
+
if (!liveSessionIds.has(sessionId)) {
|
|
713
|
+
if (!this.inflightCreationSessions.has(sessionId)) {
|
|
714
|
+
this.clearSessionCaches(sessionId);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
this.sessionCatalogLoaded = true;
|
|
182
719
|
return out;
|
|
183
720
|
}
|
|
184
|
-
|
|
721
|
+
clearUnavailableSessionCaches() {
|
|
722
|
+
for (const sessionId of new Set([
|
|
723
|
+
...this.panePidBySession.keys(),
|
|
724
|
+
...this.pendingCreationCleanup,
|
|
725
|
+
...this.pendingMarkerCleanup.keys()
|
|
726
|
+
])) {
|
|
727
|
+
// A concurrent new-session can observe "no server" immediately before
|
|
728
|
+
// it creates the first tmux session. Its quarantine state belongs to
|
|
729
|
+
// spawn(), not this catalog snapshot.
|
|
730
|
+
if (!this.inflightCreationSessions.has(sessionId)) {
|
|
731
|
+
if (this.pendingMarkerCleanup.has(sessionId)) {
|
|
732
|
+
// An uncertain rename can become visible after the transient
|
|
733
|
+
// no-server observation. Preserve its marker claim until a future
|
|
734
|
+
// catalog can prove whether the final name is ours.
|
|
735
|
+
this.panePidBySession.delete(sessionId);
|
|
736
|
+
this.pendingCreationCleanup.delete(sessionId);
|
|
737
|
+
this.transcriptResolutionCache.delete(sessionId);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
this.clearSessionCaches(sessionId);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async reconcilePendingCreationCleanup(liveSessionIds) {
|
|
746
|
+
for (const sessionId of [...this.pendingCreationCleanup]) {
|
|
747
|
+
if (this.inflightCreationSessions.has(sessionId)) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (!liveSessionIds.has(sessionId)) {
|
|
751
|
+
this.clearSessionCaches(sessionId);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (await this.destroySession(sessionId)) {
|
|
755
|
+
liveSessionIds.delete(sessionId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async reconcilePendingMarkerCleanup(liveSessionIds) {
|
|
760
|
+
for (const [sessionId, creationToken] of [...this.pendingMarkerCleanup]) {
|
|
761
|
+
const inflightMarker = this.inflightFinalCreationMarkers.get(sessionId);
|
|
762
|
+
if (inflightMarker !== undefined &&
|
|
763
|
+
creationMarkerBelongsToToken(inflightMarker, creationToken)) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (!liveSessionIds.has(sessionId)) {
|
|
767
|
+
this.clearSessionCaches(sessionId);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const cleanup = await this.destroySessionIfCreationMarkerOwned(sessionId, creationToken);
|
|
771
|
+
if (cleanup.kind === 'unavailable') {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (cleanup.kind === 'destroyed') {
|
|
775
|
+
liveSessionIds.delete(sessionId);
|
|
776
|
+
this.clearSessionCaches(sessionId);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (cleanup.kind === 'missing') {
|
|
780
|
+
const currentMarker = await this.readCreationMarker(sessionId);
|
|
781
|
+
if (currentMarker.kind === 'unavailable') {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
if (currentMarker.kind === 'missing') {
|
|
785
|
+
liveSessionIds.delete(sessionId);
|
|
786
|
+
this.clearSessionCaches(sessionId);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (creationMarkerBelongsToToken(currentMarker.value, creationToken)) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
this.pendingMarkerCleanup.delete(sessionId);
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
// The name was reused or another actor committed the session. Drop only
|
|
796
|
+
// our quarantine claim; its live pane/cache belongs to that session.
|
|
797
|
+
this.pendingMarkerCleanup.delete(sessionId);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async setSessionTitle(sessionId, title) {
|
|
185
801
|
// Single-window-per-session in our setup, so renaming the window is the
|
|
186
802
|
// visible "session title" the operator perceives via the tab/pill label.
|
|
187
803
|
// tmux's rename-session is also possible but session names are how we
|
|
188
804
|
// address sessions in spawn/kill — those must stay stable, so we change
|
|
189
805
|
// window name instead.
|
|
190
|
-
|
|
806
|
+
await this.commandRunner('tmux', [
|
|
191
807
|
'-L',
|
|
192
808
|
this.socket,
|
|
193
809
|
'-f',
|
|
194
810
|
this.configPath,
|
|
195
811
|
'rename-window',
|
|
196
812
|
'-t',
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
]
|
|
813
|
+
exactWindowTarget(sessionId),
|
|
814
|
+
normalizeTerminalTitle(title)
|
|
815
|
+
]);
|
|
200
816
|
}
|
|
201
|
-
scrollbackScroll(sessionId, lines) {
|
|
817
|
+
async scrollbackScroll(sessionId, lines) {
|
|
202
818
|
const count = normalizeScrollLines(lines);
|
|
203
819
|
if (count === 0) {
|
|
204
820
|
return true;
|
|
205
821
|
}
|
|
206
822
|
if (lines < 0) {
|
|
207
|
-
|
|
208
|
-
|
|
823
|
+
try {
|
|
824
|
+
await this.commandRunner('tmux', [
|
|
825
|
+
'-L',
|
|
826
|
+
this.socket,
|
|
827
|
+
'-f',
|
|
828
|
+
this.configPath,
|
|
829
|
+
'copy-mode',
|
|
830
|
+
'-t',
|
|
831
|
+
exactPaneTarget(sessionId)
|
|
832
|
+
]);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
209
835
|
return false;
|
|
210
836
|
}
|
|
211
|
-
return this.sendCopyModeCommand(sessionId, 'scroll-up', count);
|
|
837
|
+
return await this.sendCopyModeCommand(sessionId, 'scroll-up', count);
|
|
212
838
|
}
|
|
213
|
-
const inMode = this.readPaneInMode(sessionId);
|
|
839
|
+
const inMode = await this.readPaneInMode(sessionId);
|
|
214
840
|
if (inMode === null) {
|
|
215
841
|
return false;
|
|
216
842
|
}
|
|
217
843
|
if (!inMode) {
|
|
218
844
|
return true;
|
|
219
845
|
}
|
|
220
|
-
if (!this.sendCopyModeCommand(sessionId, 'scroll-down', count)) {
|
|
846
|
+
if (!await this.sendCopyModeCommand(sessionId, 'scroll-down', count)) {
|
|
221
847
|
return false;
|
|
222
848
|
}
|
|
223
|
-
if (this.readScrollPosition(sessionId) === 0) {
|
|
224
|
-
return this.sendCopyModeCommand(sessionId, 'cancel', 1);
|
|
849
|
+
if (await this.readScrollPosition(sessionId) === 0) {
|
|
850
|
+
return await this.sendCopyModeCommand(sessionId, 'cancel', 1);
|
|
225
851
|
}
|
|
226
852
|
return true;
|
|
227
853
|
}
|
|
228
|
-
cancelScrollbackMode(sessionId) {
|
|
229
|
-
const inMode = this.readPaneInMode(sessionId);
|
|
854
|
+
async cancelScrollbackMode(sessionId) {
|
|
855
|
+
const inMode = await this.readPaneInMode(sessionId);
|
|
230
856
|
if (inMode === null) {
|
|
231
857
|
return false;
|
|
232
858
|
}
|
|
233
859
|
if (!inMode) {
|
|
234
860
|
return true;
|
|
235
861
|
}
|
|
236
|
-
return this.sendCopyModeCommand(sessionId, 'cancel', 1);
|
|
862
|
+
return await this.sendCopyModeCommand(sessionId, 'cancel', 1);
|
|
237
863
|
}
|
|
238
|
-
captureHistory(sessionId, options) {
|
|
864
|
+
async captureHistory(sessionId, options) {
|
|
239
865
|
const limit = normalizeHistoryLimit(options.limit);
|
|
240
|
-
|
|
866
|
+
let pageLimit = limit;
|
|
867
|
+
for (let attempt = 0; attempt < TMUX_HISTORY_CAPTURE_ATTEMPTS; attempt += 1) {
|
|
868
|
+
const before = await this.readPaneHistorySnapshot(sessionId);
|
|
869
|
+
if (!before) {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
const oldestLine = -before.metrics.historySize;
|
|
873
|
+
const newestLine = Math.max(0, before.metrics.paneHeight - 1);
|
|
874
|
+
const anchoredEnd = options.beforeLine === undefined
|
|
875
|
+
? newestLine
|
|
876
|
+
: anchoredHistoryEndLine(options.beforeLine, options.snapshotHistorySize, before.metrics.historySize, {
|
|
877
|
+
...(options.snapshotHistoryLimit !== undefined
|
|
878
|
+
? { snapshotHistoryLimit: options.snapshotHistoryLimit }
|
|
879
|
+
: {}),
|
|
880
|
+
currentHistoryLimit: before.metrics.historyLimit,
|
|
881
|
+
...(options.snapshotHistoryToken !== undefined
|
|
882
|
+
? { snapshotHistoryToken: options.snapshotHistoryToken }
|
|
883
|
+
: {}),
|
|
884
|
+
currentHistoryToken: before.token,
|
|
885
|
+
...(options.snapshotPaneWidth !== undefined
|
|
886
|
+
? { snapshotPaneWidth: options.snapshotPaneWidth }
|
|
887
|
+
: {}),
|
|
888
|
+
currentPaneWidth: before.metrics.paneWidth
|
|
889
|
+
});
|
|
890
|
+
if (anchoredEnd === null) {
|
|
891
|
+
return invalidHistoryCapture(before);
|
|
892
|
+
}
|
|
893
|
+
const endLine = Math.min(newestLine, anchoredEnd);
|
|
894
|
+
if (endLine < oldestLine) {
|
|
895
|
+
if (before.metrics.historySize === 0 &&
|
|
896
|
+
options.beforeLine === 0 &&
|
|
897
|
+
options.snapshotHistorySize === undefined) {
|
|
898
|
+
const after = await this.readPaneHistorySnapshot(sessionId);
|
|
899
|
+
if (!after) {
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
if (!historyCaptureIsStable(before, after)) {
|
|
903
|
+
if (attempt === TMUX_HISTORY_CAPTURE_ATTEMPTS - 1) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
return emptyHistoryCapture(after);
|
|
909
|
+
}
|
|
910
|
+
return invalidHistoryCapture(before);
|
|
911
|
+
}
|
|
912
|
+
const startLine = Math.max(oldestLine, endLine - pageLimit + 1);
|
|
913
|
+
let text;
|
|
914
|
+
try {
|
|
915
|
+
const result = await this.commandRunner('tmux', [
|
|
916
|
+
'-L',
|
|
917
|
+
this.socket,
|
|
918
|
+
'-f',
|
|
919
|
+
this.configPath,
|
|
920
|
+
'capture-pane',
|
|
921
|
+
'-p',
|
|
922
|
+
'-e',
|
|
923
|
+
'-J',
|
|
924
|
+
'-S',
|
|
925
|
+
String(startLine),
|
|
926
|
+
'-E',
|
|
927
|
+
String(endLine),
|
|
928
|
+
'-t',
|
|
929
|
+
exactPaneTarget(sessionId)
|
|
930
|
+
], {
|
|
931
|
+
timeoutMs: TMUX_HISTORY_TIMEOUT_MS,
|
|
932
|
+
maxBuffer: TMUX_HISTORY_MAX_BUFFER_BYTES
|
|
933
|
+
});
|
|
934
|
+
text = normalizeCapturedPane(result.stdout);
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
if (isCommandOutputLimitError(error) && pageLimit > 1) {
|
|
938
|
+
pageLimit = Math.max(1, Math.floor(pageLimit / 2));
|
|
939
|
+
attempt -= 1;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
const after = await this.readPaneHistorySnapshot(sessionId);
|
|
945
|
+
if (!after) {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
if (!historyCaptureIsStable(before, after)) {
|
|
949
|
+
if (attempt === TMUX_HISTORY_CAPTURE_ATTEMPTS - 1) {
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
startLine,
|
|
956
|
+
endLine,
|
|
957
|
+
hasMore: startLine > oldestLine,
|
|
958
|
+
snapshotHistorySize: after.metrics.historySize,
|
|
959
|
+
snapshotHistoryLimit: after.metrics.historyLimit,
|
|
960
|
+
snapshotHistoryToken: after.token,
|
|
961
|
+
snapshotPaneWidth: after.metrics.paneWidth,
|
|
962
|
+
output: text ? [text] : []
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
async readPaneHistorySnapshot(sessionId) {
|
|
968
|
+
if (this.historyOutputUnreliableSessions.has(sessionId)) {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
const metrics = await this.readPaneMetricsAsync(sessionId);
|
|
241
972
|
if (!metrics) {
|
|
242
973
|
return null;
|
|
243
974
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return { startLine: oldestLine, endLine: oldestLine - 1, hasMore: false, output: [] };
|
|
975
|
+
if (metrics.historySize === 0) {
|
|
976
|
+
return {
|
|
977
|
+
metrics,
|
|
978
|
+
token: historySnapshotToken(metrics, '', this.historyTokenGeneration, this.historyOutputGenerationBySession.get(sessionId) ?? 0)
|
|
979
|
+
};
|
|
250
980
|
}
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
this.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
981
|
+
const anchorStart = -metrics.historySize;
|
|
982
|
+
const anchorEnd = Math.min(-1, anchorStart + TMUX_HISTORY_TOKEN_LINES - 1);
|
|
983
|
+
try {
|
|
984
|
+
const anchor = await this.commandRunner('tmux', [
|
|
985
|
+
'-L',
|
|
986
|
+
this.socket,
|
|
987
|
+
'-f',
|
|
988
|
+
this.configPath,
|
|
989
|
+
'capture-pane',
|
|
990
|
+
'-p',
|
|
991
|
+
'-e',
|
|
992
|
+
'-J',
|
|
993
|
+
'-S',
|
|
994
|
+
String(anchorStart),
|
|
995
|
+
'-E',
|
|
996
|
+
String(anchorEnd),
|
|
997
|
+
'-t',
|
|
998
|
+
exactPaneTarget(sessionId)
|
|
999
|
+
], {
|
|
1000
|
+
timeoutMs: TMUX_HISTORY_TIMEOUT_MS,
|
|
1001
|
+
maxBuffer: TMUX_HISTORY_MAX_BUFFER_BYTES
|
|
1002
|
+
});
|
|
1003
|
+
return {
|
|
1004
|
+
metrics,
|
|
1005
|
+
token: historySnapshotToken(metrics, anchor.stdout, this.historyTokenGeneration, this.historyOutputGenerationBySession.get(sessionId) ?? 0)
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
269
1009
|
return null;
|
|
270
1010
|
}
|
|
271
|
-
const text = normalizeCapturedPane(result.stdout.toString());
|
|
272
|
-
return {
|
|
273
|
-
startLine,
|
|
274
|
-
endLine,
|
|
275
|
-
hasMore: startLine > oldestLine,
|
|
276
|
-
output: text ? [text] : []
|
|
277
|
-
};
|
|
278
1011
|
}
|
|
279
|
-
resolveTranscriptSource(sessionId) {
|
|
1012
|
+
async resolveTranscriptSource(sessionId) {
|
|
280
1013
|
const cached = this.transcriptResolutionCache.get(sessionId);
|
|
281
1014
|
if (cached) {
|
|
282
1015
|
return cached.value;
|
|
283
1016
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1017
|
+
return this.transcriptResolutionInflight.run(sessionId, async () => {
|
|
1018
|
+
const source = await this.resolveTranscriptSourceUncached(sessionId);
|
|
1019
|
+
this.transcriptResolutionCache.set(sessionId, source, TRANSCRIPT_RESOLUTION_CACHE_TTL_MS);
|
|
1020
|
+
return source;
|
|
1021
|
+
});
|
|
287
1022
|
}
|
|
288
|
-
resolveTranscriptSourceUncached(sessionId) {
|
|
289
|
-
const panePid = this.
|
|
1023
|
+
async resolveTranscriptSourceUncached(sessionId) {
|
|
1024
|
+
const panePid = this.panePidBySession.get(sessionId) ?? await this.readPanePidAsync(sessionId);
|
|
290
1025
|
if (panePid === null) {
|
|
291
1026
|
return null;
|
|
292
1027
|
}
|
|
293
|
-
const tree =
|
|
1028
|
+
const tree = await this.readProcessTreeSnapshot();
|
|
294
1029
|
if (!tree) {
|
|
295
1030
|
return null;
|
|
296
1031
|
}
|
|
@@ -298,13 +1033,13 @@ export class TmuxPtyAdapter {
|
|
|
298
1033
|
const processDepthByPid = new Map();
|
|
299
1034
|
// The pane root itself is a candidate too: `exec claude` replaces the
|
|
300
1035
|
// shell, leaving the agent as pane_pid with no descendants.
|
|
301
|
-
|
|
302
|
-
const args =
|
|
1036
|
+
await mapWithConcurrency(listProcessPidsWithDepth(tree, panePid), 8, async ({ pid, depth }) => {
|
|
1037
|
+
const args = await readProcessArgsAsync(pid);
|
|
303
1038
|
if (args.length > 0) {
|
|
304
1039
|
argsByPid.set(pid, args);
|
|
305
1040
|
processDepthByPid.set(pid, depth);
|
|
306
1041
|
}
|
|
307
|
-
}
|
|
1042
|
+
});
|
|
308
1043
|
let entries = [];
|
|
309
1044
|
let claudeSessionPending = false;
|
|
310
1045
|
for (const [pid, args] of argsByPid) {
|
|
@@ -313,7 +1048,7 @@ export class TmuxPtyAdapter {
|
|
|
313
1048
|
continue;
|
|
314
1049
|
}
|
|
315
1050
|
if (findCommandArg(args, 'claude') !== -1) {
|
|
316
|
-
const claude =
|
|
1051
|
+
const claude = await resolveClaudeCandidateForPidAsync(pid, args);
|
|
317
1052
|
if (claude.pending) {
|
|
318
1053
|
claudeSessionPending = true;
|
|
319
1054
|
}
|
|
@@ -331,7 +1066,7 @@ export class TmuxPtyAdapter {
|
|
|
331
1066
|
// The append-open rollout is the authoritative owner signal. Check
|
|
332
1067
|
// it before descendant suppression: recent Codex releases spawn
|
|
333
1068
|
// helpers such as codex-code-mode-host beneath the real process.
|
|
334
|
-
const live = resolveCodexTranscriptFromOpenFds(pid);
|
|
1069
|
+
const live = await resolveCodexTranscriptFromOpenFds(pid);
|
|
335
1070
|
if (live) {
|
|
336
1071
|
entries.push({ candidate: live, rank: 'live', processDepth });
|
|
337
1072
|
continue;
|
|
@@ -345,7 +1080,7 @@ export class TmuxPtyAdapter {
|
|
|
345
1080
|
}
|
|
346
1081
|
// Process logs reflect the thread the process wrote to most
|
|
347
1082
|
// recently, so they outrank launch args, which freeze at startup.
|
|
348
|
-
const stated = resolveCodexTranscriptFromProcessLogs(pid) ?? extractCodexArgsCandidate(args);
|
|
1083
|
+
const stated = (await resolveCodexTranscriptFromProcessLogs(pid)) ?? extractCodexArgsCandidate(args);
|
|
349
1084
|
if (stated) {
|
|
350
1085
|
entries.push({ candidate: stated, rank: 'stated', processDepth });
|
|
351
1086
|
}
|
|
@@ -362,114 +1097,244 @@ export class TmuxPtyAdapter {
|
|
|
362
1097
|
if (!candidate) {
|
|
363
1098
|
return null;
|
|
364
1099
|
}
|
|
365
|
-
const matches = candidate.filePath ? [candidate.filePath] :
|
|
1100
|
+
const matches = candidate.filePath ? [candidate.filePath] : await findTranscriptFilesAsync(candidate.provider, candidate.sessionId);
|
|
366
1101
|
if (matches.length !== 1) {
|
|
367
1102
|
return null;
|
|
368
1103
|
}
|
|
369
1104
|
return { provider: candidate.provider, sessionId: candidate.sessionId, filePath: matches[0] };
|
|
370
1105
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
'
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
'
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1106
|
+
async readPaneMetricsAsync(sessionId) {
|
|
1107
|
+
try {
|
|
1108
|
+
const result = await this.commandRunner('tmux', [
|
|
1109
|
+
'-L', this.socket, '-f', this.configPath,
|
|
1110
|
+
'display-message', '-p', '-t', exactPaneTarget(sessionId),
|
|
1111
|
+
'#{history_size}\t#{history_limit}\t#{pane_height}\t#{pane_width}\t#{history_bytes}\t#{pane_id}\t#{session_created}'
|
|
1112
|
+
]);
|
|
1113
|
+
const [historyRaw, limitRaw, heightRaw, widthRaw, bytesRaw, paneId, sessionCreatedRaw] = result.stdout.trim().split('\t');
|
|
1114
|
+
const historySize = Number(historyRaw);
|
|
1115
|
+
const historyLimit = Number(limitRaw);
|
|
1116
|
+
const paneHeight = Number(heightRaw);
|
|
1117
|
+
const paneWidth = Number(widthRaw);
|
|
1118
|
+
const historyBytes = Number(bytesRaw);
|
|
1119
|
+
const sessionCreated = Number(sessionCreatedRaw);
|
|
1120
|
+
return Number.isInteger(historySize) &&
|
|
1121
|
+
historySize >= 0 &&
|
|
1122
|
+
Number.isInteger(historyLimit) &&
|
|
1123
|
+
historyLimit > 0 &&
|
|
1124
|
+
Number.isInteger(paneHeight) &&
|
|
1125
|
+
paneHeight > 0 &&
|
|
1126
|
+
Number.isInteger(paneWidth) &&
|
|
1127
|
+
paneWidth > 0 &&
|
|
1128
|
+
Number.isSafeInteger(historyBytes) &&
|
|
1129
|
+
historyBytes >= 0 &&
|
|
1130
|
+
typeof paneId === 'string' &&
|
|
1131
|
+
/^%[0-9]+$/.test(paneId) &&
|
|
1132
|
+
Number.isSafeInteger(sessionCreated) &&
|
|
1133
|
+
sessionCreated > 0
|
|
1134
|
+
? { historySize, historyLimit, paneHeight, paneWidth, historyBytes, paneId, sessionCreated }
|
|
1135
|
+
: null;
|
|
385
1136
|
}
|
|
386
|
-
|
|
387
|
-
const historySize = Number(historyRaw);
|
|
388
|
-
const paneHeight = Number(heightRaw);
|
|
389
|
-
if (!Number.isInteger(historySize) || historySize < 0 || !Number.isInteger(paneHeight) || paneHeight <= 0) {
|
|
1137
|
+
catch {
|
|
390
1138
|
return null;
|
|
391
1139
|
}
|
|
392
|
-
return { historySize, paneHeight };
|
|
393
1140
|
}
|
|
394
|
-
readPaneInMode(sessionId) {
|
|
395
|
-
|
|
396
|
-
'
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
1141
|
+
async readPaneInMode(sessionId) {
|
|
1142
|
+
try {
|
|
1143
|
+
const result = await this.commandRunner('tmux', [
|
|
1144
|
+
'-L',
|
|
1145
|
+
this.socket,
|
|
1146
|
+
'-f',
|
|
1147
|
+
this.configPath,
|
|
1148
|
+
'display-message',
|
|
1149
|
+
'-p',
|
|
1150
|
+
'-t',
|
|
1151
|
+
exactPaneTarget(sessionId),
|
|
1152
|
+
'#{pane_in_mode}'
|
|
1153
|
+
]);
|
|
1154
|
+
return result.stdout.trim() === '1';
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
407
1157
|
return null;
|
|
408
1158
|
}
|
|
409
|
-
return result.stdout.toString().trim() === '1';
|
|
410
1159
|
}
|
|
411
|
-
readScrollPosition(sessionId) {
|
|
412
|
-
|
|
413
|
-
'
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
1160
|
+
async readScrollPosition(sessionId) {
|
|
1161
|
+
try {
|
|
1162
|
+
const result = await this.commandRunner('tmux', [
|
|
1163
|
+
'-L',
|
|
1164
|
+
this.socket,
|
|
1165
|
+
'-f',
|
|
1166
|
+
this.configPath,
|
|
1167
|
+
'display-message',
|
|
1168
|
+
'-p',
|
|
1169
|
+
'-t',
|
|
1170
|
+
exactPaneTarget(sessionId),
|
|
1171
|
+
'#{scroll_position}'
|
|
1172
|
+
]);
|
|
1173
|
+
const position = Number(result.stdout.trim());
|
|
1174
|
+
return Number.isInteger(position) && position >= 0 ? position : null;
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
424
1177
|
return null;
|
|
425
1178
|
}
|
|
426
|
-
const position = Number(result.stdout.toString().trim());
|
|
427
|
-
return Number.isInteger(position) && position >= 0 ? position : null;
|
|
428
1179
|
}
|
|
429
|
-
sendCopyModeCommand(sessionId, command, repeatCount) {
|
|
1180
|
+
async sendCopyModeCommand(sessionId, command, repeatCount) {
|
|
430
1181
|
const args = ['-L', this.socket, '-f', this.configPath, 'send-keys', '-X'];
|
|
431
1182
|
if (repeatCount > 1) {
|
|
432
1183
|
args.push('-N', String(repeatCount));
|
|
433
1184
|
}
|
|
434
|
-
args.push('-t', sessionId, command);
|
|
435
|
-
|
|
436
|
-
|
|
1185
|
+
args.push('-t', exactPaneTarget(sessionId), command);
|
|
1186
|
+
try {
|
|
1187
|
+
await this.commandRunner('tmux', args);
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
437
1193
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
'
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
'#{pane_pid}'
|
|
449
|
-
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
450
|
-
if (result.status !== 0) {
|
|
1194
|
+
async readPanePidAsync(sessionId) {
|
|
1195
|
+
try {
|
|
1196
|
+
const result = await this.commandRunner('tmux', [
|
|
1197
|
+
'-L', this.socket, '-f', this.configPath,
|
|
1198
|
+
'display-message', '-p', '-t', exactPaneTarget(sessionId), '#{pane_pid}'
|
|
1199
|
+
]);
|
|
1200
|
+
const panePid = Number(result.stdout.trim());
|
|
1201
|
+
return Number.isInteger(panePid) && panePid > 0 ? panePid : null;
|
|
1202
|
+
}
|
|
1203
|
+
catch {
|
|
451
1204
|
return null;
|
|
452
1205
|
}
|
|
453
|
-
const panePid = Number(result.stdout.toString().trim());
|
|
454
|
-
return Number.isInteger(panePid) && panePid > 0 ? panePid : null;
|
|
455
1206
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
1207
|
+
readProcessTreeSnapshot() {
|
|
1208
|
+
const cached = this.processTreeCache.get('all');
|
|
1209
|
+
if (cached)
|
|
1210
|
+
return Promise.resolve(cached.value);
|
|
1211
|
+
return this.processTreeInflight.run('all', async () => {
|
|
1212
|
+
const tree = await readProcessTreeAsync();
|
|
1213
|
+
this.processTreeCache.set('all', tree, PROCESS_TREE_CACHE_TTL_MS);
|
|
1214
|
+
return tree;
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
export function anchoredHistoryEndLine(beforeLine, snapshotHistorySize, currentHistorySize, rollover = {}) {
|
|
1219
|
+
if (snapshotHistorySize === undefined) {
|
|
1220
|
+
// beforeLine=0 is the initial tail request emitted before a snapshot has
|
|
1221
|
+
// supplied cursor metadata. Any older coordinate without an anchor came
|
|
1222
|
+
// from a mixed-version client and cannot be shifted safely.
|
|
1223
|
+
return beforeLine === 0 ? -1 : null;
|
|
1224
|
+
}
|
|
1225
|
+
if (rollover.snapshotPaneWidth === undefined ||
|
|
1226
|
+
rollover.currentPaneWidth === undefined ||
|
|
1227
|
+
rollover.snapshotPaneWidth !== rollover.currentPaneWidth) {
|
|
1228
|
+
// tmux reflows scrollback when pane width changes, so line coordinates
|
|
1229
|
+
// from the previous page no longer identify the same text.
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
if (currentHistorySize < snapshotHistorySize) {
|
|
1233
|
+
// clear-history/session reset invalidated the relative tmux coordinates.
|
|
463
1234
|
return null;
|
|
464
1235
|
}
|
|
1236
|
+
const currentLimit = rollover.currentHistoryLimit;
|
|
1237
|
+
if (currentLimit !== undefined && currentHistorySize >= currentLimit) {
|
|
1238
|
+
// Once tmux reaches history-limit, history_size no longer reports how
|
|
1239
|
+
// many oldest lines were evicted. Continue only when the previous page
|
|
1240
|
+
// was captured at the same limit and the oldest scrollback anchor is
|
|
1241
|
+
// unchanged.
|
|
1242
|
+
if (rollover.snapshotHistoryLimit !== currentLimit ||
|
|
1243
|
+
snapshotHistorySize !== currentHistorySize ||
|
|
1244
|
+
rollover.snapshotHistoryToken === undefined ||
|
|
1245
|
+
rollover.currentHistoryToken === undefined ||
|
|
1246
|
+
rollover.snapshotHistoryToken !== rollover.currentHistoryToken) {
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return beforeLine - (currentHistorySize - snapshotHistorySize) - 1;
|
|
465
1251
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
1252
|
+
export function historyCaptureIsStable(before, after) {
|
|
1253
|
+
return before.metrics.historySize === after.metrics.historySize &&
|
|
1254
|
+
before.metrics.historyLimit === after.metrics.historyLimit &&
|
|
1255
|
+
before.metrics.paneHeight === after.metrics.paneHeight &&
|
|
1256
|
+
before.metrics.paneWidth === after.metrics.paneWidth &&
|
|
1257
|
+
before.metrics.historyBytes === after.metrics.historyBytes &&
|
|
1258
|
+
before.metrics.paneId === after.metrics.paneId &&
|
|
1259
|
+
before.metrics.sessionCreated === after.metrics.sessionCreated &&
|
|
1260
|
+
before.token === after.token;
|
|
1261
|
+
}
|
|
1262
|
+
function invalidHistoryCapture(snapshot) {
|
|
1263
|
+
const oldestLine = -snapshot.metrics.historySize;
|
|
1264
|
+
return {
|
|
1265
|
+
startLine: oldestLine,
|
|
1266
|
+
endLine: oldestLine - 1,
|
|
1267
|
+
hasMore: false,
|
|
1268
|
+
invalidated: true,
|
|
1269
|
+
snapshotHistorySize: snapshot.metrics.historySize,
|
|
1270
|
+
snapshotHistoryLimit: snapshot.metrics.historyLimit,
|
|
1271
|
+
snapshotHistoryToken: snapshot.token,
|
|
1272
|
+
snapshotPaneWidth: snapshot.metrics.paneWidth,
|
|
1273
|
+
output: []
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function emptyHistoryCapture(snapshot) {
|
|
1277
|
+
return {
|
|
1278
|
+
startLine: 0,
|
|
1279
|
+
endLine: -1,
|
|
1280
|
+
hasMore: false,
|
|
1281
|
+
snapshotHistorySize: 0,
|
|
1282
|
+
snapshotHistoryLimit: snapshot.metrics.historyLimit,
|
|
1283
|
+
snapshotHistoryToken: snapshot.token,
|
|
1284
|
+
snapshotPaneWidth: snapshot.metrics.paneWidth,
|
|
1285
|
+
output: []
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function historySnapshotToken(metrics, oldestAnchor, generation, outputGeneration) {
|
|
1289
|
+
return createHash('sha256')
|
|
1290
|
+
.update(generation)
|
|
1291
|
+
.update('\0')
|
|
1292
|
+
.update(metrics.paneId)
|
|
1293
|
+
.update('\0')
|
|
1294
|
+
.update(String(metrics.sessionCreated))
|
|
1295
|
+
.update('\0')
|
|
1296
|
+
.update(String(metrics.historySize))
|
|
1297
|
+
.update('\0')
|
|
1298
|
+
.update(String(metrics.historyLimit))
|
|
1299
|
+
.update('\0')
|
|
1300
|
+
.update(String(metrics.paneWidth))
|
|
1301
|
+
.update('\0')
|
|
1302
|
+
.update(String(metrics.historyBytes))
|
|
1303
|
+
.update('\0')
|
|
1304
|
+
.update(String(outputGeneration))
|
|
1305
|
+
.update('\0')
|
|
1306
|
+
.update(oldestAnchor)
|
|
1307
|
+
.digest('hex');
|
|
1308
|
+
}
|
|
1309
|
+
export function exactSessionTarget(sessionId) {
|
|
1310
|
+
return `=${sessionId}`;
|
|
1311
|
+
}
|
|
1312
|
+
export function exactWindowTarget(sessionId) {
|
|
1313
|
+
return `=${sessionId}:0`;
|
|
1314
|
+
}
|
|
1315
|
+
export function exactPaneTarget(sessionId) {
|
|
1316
|
+
return `=${sessionId}:0.0`;
|
|
1317
|
+
}
|
|
1318
|
+
function isPendingCreationSessionId(sessionId) {
|
|
1319
|
+
return sessionId.startsWith(PENDING_CREATION_SESSION_PREFIX);
|
|
1320
|
+
}
|
|
1321
|
+
function creationTokenFromMarker(marker) {
|
|
1322
|
+
if (marker.startsWith(PENDING_CREATION_MARKER_PREFIX)) {
|
|
1323
|
+
const token = marker.slice(PENDING_CREATION_MARKER_PREFIX.length);
|
|
1324
|
+
return isCreationToken(token) ? token : '';
|
|
1325
|
+
}
|
|
1326
|
+
if (marker.startsWith('committed:')) {
|
|
1327
|
+
const token = marker.slice('committed:'.length);
|
|
1328
|
+
return isCreationToken(token) ? token : '';
|
|
1329
|
+
}
|
|
1330
|
+
return '';
|
|
1331
|
+
}
|
|
1332
|
+
function creationMarkerBelongsToToken(marker, creationToken) {
|
|
1333
|
+
return isCreationToken(creationToken) && (marker === `${PENDING_CREATION_MARKER_PREFIX}${creationToken}` ||
|
|
1334
|
+
marker === `committed:${creationToken}`);
|
|
1335
|
+
}
|
|
1336
|
+
function isCreationToken(value) {
|
|
1337
|
+
return /^[a-f0-9]{16}$/.test(value);
|
|
473
1338
|
}
|
|
474
1339
|
function normalizeHistoryLimit(value) {
|
|
475
1340
|
if (!Number.isInteger(value) || value <= 0) {
|
|
@@ -477,6 +1342,95 @@ function normalizeHistoryLimit(value) {
|
|
|
477
1342
|
}
|
|
478
1343
|
return Math.min(value, 5000);
|
|
479
1344
|
}
|
|
1345
|
+
function parseFixedTmuxSessionCatalog(raw) {
|
|
1346
|
+
return parseTmuxCatalogLines(raw, (line) => {
|
|
1347
|
+
const fields = line.split('\t');
|
|
1348
|
+
if (fields.length === 6 &&
|
|
1349
|
+
/^[0-9]+$/.test(fields[1] ?? '') &&
|
|
1350
|
+
/^%[0-9]+$/.test(fields[2] ?? '') &&
|
|
1351
|
+
/^[0-9]+$/.test(fields[3] ?? '') &&
|
|
1352
|
+
/^[0-9]+$/.test(fields[4] ?? '')) {
|
|
1353
|
+
const [sessionId = '', panePidRaw = '', paneId = '', serverPidRaw = '', sessionCreatedRaw = '', creationMarker = ''] = fields;
|
|
1354
|
+
return parseListedTmuxSession(sessionId, panePidRaw, creationMarker, {
|
|
1355
|
+
paneId,
|
|
1356
|
+
serverPid: Number(serverPidRaw),
|
|
1357
|
+
sessionCreated: Number(sessionCreatedRaw)
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
// Compatibility for command-runner fixtures written before pane ids were
|
|
1361
|
+
// added. Real tmux output for the fixed format always takes the branch
|
|
1362
|
+
// above and never carries variable title/cwd strings in this catalog.
|
|
1363
|
+
if (fields.length === 4 || fields.length === 5) {
|
|
1364
|
+
const [sessionId = '', title = '', cwd = '', panePidRaw = '', creationMarker = ''] = fields;
|
|
1365
|
+
return parseListedTmuxSession(sessionId, panePidRaw, creationMarker, { title, cwd });
|
|
1366
|
+
}
|
|
1367
|
+
return null;
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
function parseTmuxCatalogLines(raw, parseLine) {
|
|
1371
|
+
const output = removeTmuxOutputTerminator(raw);
|
|
1372
|
+
if (!output) {
|
|
1373
|
+
return [];
|
|
1374
|
+
}
|
|
1375
|
+
const sessions = [];
|
|
1376
|
+
for (const line of output.split('\n')) {
|
|
1377
|
+
const session = parseLine(line);
|
|
1378
|
+
if (!session) {
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
sessions.push(session);
|
|
1382
|
+
}
|
|
1383
|
+
return sessions;
|
|
1384
|
+
}
|
|
1385
|
+
function parseListedTmuxSession(sessionId, panePidRaw, creationMarker, metadata = null) {
|
|
1386
|
+
const panePid = Number(panePidRaw);
|
|
1387
|
+
if (!sessionId || !Number.isInteger(panePid) || panePid <= 0) {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
sessionId,
|
|
1392
|
+
panePid,
|
|
1393
|
+
creationMarker,
|
|
1394
|
+
...(metadata ? metadata : {})
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
function removeTmuxOutputTerminator(value) {
|
|
1398
|
+
return value.endsWith('\n') ? value.slice(0, -1) : value;
|
|
1399
|
+
}
|
|
1400
|
+
function splitFixedPrefix(value, fieldCount) {
|
|
1401
|
+
const fields = [];
|
|
1402
|
+
let offset = 0;
|
|
1403
|
+
for (let index = 0; index < fieldCount; index += 1) {
|
|
1404
|
+
const separator = value.indexOf('\t', offset);
|
|
1405
|
+
if (separator === -1) {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
fields.push(value.slice(offset, separator));
|
|
1409
|
+
offset = separator + 1;
|
|
1410
|
+
}
|
|
1411
|
+
fields.push(value.slice(offset));
|
|
1412
|
+
return fields;
|
|
1413
|
+
}
|
|
1414
|
+
function tmuxMetadataMatchesCatalog(metadata, session) {
|
|
1415
|
+
return (metadata.sessionId === session.sessionId &&
|
|
1416
|
+
metadata.panePid === session.panePid &&
|
|
1417
|
+
metadata.serverPid === session.serverPid &&
|
|
1418
|
+
metadata.sessionCreated === session.sessionCreated &&
|
|
1419
|
+
metadata.creationMarker === session.creationMarker);
|
|
1420
|
+
}
|
|
1421
|
+
function exactPaneIdTarget(paneId) {
|
|
1422
|
+
if (!/^%[0-9]+$/.test(paneId)) {
|
|
1423
|
+
throw new Error('invalid tmux pane id');
|
|
1424
|
+
}
|
|
1425
|
+
return paneId;
|
|
1426
|
+
}
|
|
1427
|
+
function toPersistedSession(sessionId, title, cwd) {
|
|
1428
|
+
return {
|
|
1429
|
+
sessionId,
|
|
1430
|
+
...(title ? { title } : {}),
|
|
1431
|
+
...(cwd ? { cwd } : {})
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
480
1434
|
function normalizeScrollLines(value) {
|
|
481
1435
|
if (!Number.isInteger(value) || value === 0) {
|
|
482
1436
|
return 0;
|
|
@@ -487,11 +1441,11 @@ function normalizeCapturedPane(text) {
|
|
|
487
1441
|
const stripped = text.replace(/\r?\n$/, '');
|
|
488
1442
|
return stripped ? `${stripped.replace(/\r?\n/g, '\r\n')}\r\n` : '';
|
|
489
1443
|
}
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
function wrapTmuxPty(pty,
|
|
1444
|
+
// PtyProcess represents only the local tmux client. Persisted session
|
|
1445
|
+
// deletion is handled separately by destroySession(), so shutdown can detach
|
|
1446
|
+
// clients without destroying live shells and deletion never blocks the event
|
|
1447
|
+
// loop on a synchronous tmux subprocess.
|
|
1448
|
+
function wrapTmuxPty(pty, panePid, onOutput, onPause, onResume) {
|
|
495
1449
|
return {
|
|
496
1450
|
pid: panePid,
|
|
497
1451
|
write(data) {
|
|
@@ -502,18 +1456,13 @@ function wrapTmuxPty(pty, socket, configPath, sessionId, panePid) {
|
|
|
502
1456
|
},
|
|
503
1457
|
pause() {
|
|
504
1458
|
pty.pause();
|
|
1459
|
+
onPause();
|
|
505
1460
|
},
|
|
506
1461
|
resume() {
|
|
507
1462
|
pty.resume();
|
|
1463
|
+
onResume();
|
|
508
1464
|
},
|
|
509
1465
|
kill() {
|
|
510
|
-
// kill-session tears down the tmux session for this PTY. The local
|
|
511
|
-
// tmux client process (this pty) exits as a side effect once the
|
|
512
|
-
// server reports the session is gone. Done synchronously so the
|
|
513
|
-
// caller's "the shell is gone now" assumption holds.
|
|
514
|
-
spawnSync('tmux', ['-L', socket, '-f', configPath, 'kill-session', '-t', sessionId], { stdio: 'ignore' });
|
|
515
|
-
// Belt-and-suspenders: if kill-session failed (e.g., session already
|
|
516
|
-
// exited), make sure the client process is gone too.
|
|
517
1466
|
try {
|
|
518
1467
|
pty.kill();
|
|
519
1468
|
}
|
|
@@ -522,7 +1471,10 @@ function wrapTmuxPty(pty, socket, configPath, sessionId, panePid) {
|
|
|
522
1471
|
}
|
|
523
1472
|
},
|
|
524
1473
|
onData(listener) {
|
|
525
|
-
const disposable = pty.onData(
|
|
1474
|
+
const disposable = pty.onData((data) => {
|
|
1475
|
+
onOutput();
|
|
1476
|
+
listener(data);
|
|
1477
|
+
});
|
|
526
1478
|
return { dispose: () => disposable.dispose() };
|
|
527
1479
|
},
|
|
528
1480
|
onExit(listener) {
|
|
@@ -539,20 +1491,18 @@ function defaultConfigPath() {
|
|
|
539
1491
|
// tree's config. Same layout in dev (running directly under src/).
|
|
540
1492
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../scripts/nterminal-tmux.conf');
|
|
541
1493
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
function readProcessTree() {
|
|
548
|
-
const result = spawnSync('ps', ['-eo', 'pid=,ppid=,state='], {
|
|
549
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
550
|
-
});
|
|
551
|
-
if (result.status !== 0)
|
|
1494
|
+
async function readProcessTreeAsync() {
|
|
1495
|
+
try {
|
|
1496
|
+
return parseProcessTree((await runCommand('ps', ['-eo', 'pid=,ppid=,state='])).stdout);
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
552
1499
|
return null;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
function parseProcessTree(raw) {
|
|
553
1503
|
const childrenByPpid = new Map();
|
|
554
1504
|
const stateByPid = new Map();
|
|
555
|
-
for (const line of
|
|
1505
|
+
for (const line of raw.split('\n')) {
|
|
556
1506
|
const parts = line.trim().split(/\s+/);
|
|
557
1507
|
if (parts.length < 3)
|
|
558
1508
|
continue;
|
|
@@ -629,18 +1579,18 @@ function listProcessPidsWithDepth(tree, root) {
|
|
|
629
1579
|
}
|
|
630
1580
|
return out;
|
|
631
1581
|
}
|
|
632
|
-
function
|
|
633
|
-
if (!
|
|
1582
|
+
async function normalizeTmuxCurrentCommandAsync(currentCommand, tree, root) {
|
|
1583
|
+
if (!isNodeCommand(currentCommand)) {
|
|
634
1584
|
return currentCommand;
|
|
635
1585
|
}
|
|
636
|
-
const
|
|
637
|
-
return normalizeTmuxCurrentCommand(currentCommand,
|
|
1586
|
+
const argsByProcess = await mapWithConcurrency(listProcessPidsWithDepth(tree, root), 8, async ({ pid }) => await readProcessArgsAsync(pid));
|
|
1587
|
+
return normalizeTmuxCurrentCommand(currentCommand, argsByProcess);
|
|
638
1588
|
}
|
|
639
|
-
export function normalizeTmuxCurrentCommand(currentCommand,
|
|
1589
|
+
export function normalizeTmuxCurrentCommand(currentCommand, processArgs) {
|
|
640
1590
|
if (!isNodeCommand(currentCommand)) {
|
|
641
1591
|
return currentCommand;
|
|
642
1592
|
}
|
|
643
|
-
for (const args of
|
|
1593
|
+
for (const args of processArgs) {
|
|
644
1594
|
if (findCommandArg(args, 'codex') !== -1) {
|
|
645
1595
|
return 'codex';
|
|
646
1596
|
}
|
|
@@ -654,9 +1604,9 @@ function isNodeCommand(command) {
|
|
|
654
1604
|
const base = path.basename(command).toLowerCase();
|
|
655
1605
|
return base === 'node' || base === 'nodejs';
|
|
656
1606
|
}
|
|
657
|
-
function
|
|
1607
|
+
async function readProcessArgsAsync(pid) {
|
|
658
1608
|
try {
|
|
659
|
-
const raw =
|
|
1609
|
+
const raw = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
|
660
1610
|
return raw.split('\0').filter(Boolean);
|
|
661
1611
|
}
|
|
662
1612
|
catch {
|
|
@@ -686,6 +1636,7 @@ function extractClaudeArgsCandidate(args) {
|
|
|
686
1636
|
const NON_INTERACTIVE_CODEX_SUBCOMMANDS = new Set([
|
|
687
1637
|
'exec',
|
|
688
1638
|
'e',
|
|
1639
|
+
'review',
|
|
689
1640
|
'mcp',
|
|
690
1641
|
'mcp-server',
|
|
691
1642
|
'app-server',
|
|
@@ -733,6 +1684,18 @@ export function isNonInteractiveCodexInvocation(args) {
|
|
|
733
1684
|
for (let index = commandIndex + 1; index < args.length; index += 1) {
|
|
734
1685
|
const arg = args[index];
|
|
735
1686
|
if (arg.startsWith('-')) {
|
|
1687
|
+
if ((arg === '-i' || arg === '--image') && !arg.includes('=')) {
|
|
1688
|
+
// --image accepts one or more paths. Stop only at a recognized
|
|
1689
|
+
// subcommand; treating a second image as the command would let a
|
|
1690
|
+
// later `exec`/`review` batch masquerade as an interactive pane.
|
|
1691
|
+
while (index + 1 < args.length &&
|
|
1692
|
+
!args[index + 1].startsWith('-') &&
|
|
1693
|
+
!NON_INTERACTIVE_CODEX_SUBCOMMANDS.has(args[index + 1]) &&
|
|
1694
|
+
args[index + 1] !== 'resume') {
|
|
1695
|
+
index += 1;
|
|
1696
|
+
}
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
736
1699
|
if (!arg.includes('=') && CODEX_VALUE_FLAGS.has(arg)) {
|
|
737
1700
|
index += 1;
|
|
738
1701
|
}
|
|
@@ -788,150 +1751,104 @@ export function selectTranscriptCandidate(entries) {
|
|
|
788
1751
|
}
|
|
789
1752
|
return null;
|
|
790
1753
|
}
|
|
791
|
-
function
|
|
792
|
-
|
|
793
|
-
// process's own HOME / CLAUDE_CONFIG_DIR so a pane running claude under an
|
|
794
|
-
// overridden home never gets resolved against the server's ~/.claude.
|
|
795
|
-
const env = readProcessEnvValues(pid, ['CLAUDE_CONFIG_DIR', 'HOME', 'PWD']);
|
|
1754
|
+
async function resolveClaudeCandidateForPidAsync(pid, args) {
|
|
1755
|
+
const env = await readProcessEnvValuesAsync(pid, ['CLAUDE_CONFIG_DIR', 'HOME', 'PWD']);
|
|
796
1756
|
const configRoot = env.get('CLAUDE_CONFIG_DIR') ?? path.join(env.get('HOME') ?? os.homedir(), '.claude');
|
|
797
|
-
const startTicks =
|
|
798
|
-
const registry = resolveClaudeTranscriptFromSessionRegistry({ pid, configRoot, procStartTicks: startTicks });
|
|
799
|
-
if (registry.kind === 'resolved')
|
|
1757
|
+
const startTicks = await readProcessStartTicksAsync(pid);
|
|
1758
|
+
const registry = await resolveClaudeTranscriptFromSessionRegistry({ pid, configRoot, procStartTicks: startTicks });
|
|
1759
|
+
if (registry.kind === 'resolved')
|
|
800
1760
|
return { entry: { candidate: registry.candidate, rank: 'live' }, pending: false };
|
|
801
|
-
|
|
802
|
-
if (registry.kind === 'pending') {
|
|
1761
|
+
if (registry.kind === 'pending')
|
|
803
1762
|
return { entry: null, pending: true };
|
|
804
|
-
}
|
|
805
1763
|
const projectsRoot = path.join(configRoot, 'projects');
|
|
806
1764
|
const stated = extractClaudeArgsCandidate(args);
|
|
807
1765
|
if (stated) {
|
|
808
|
-
const located =
|
|
1766
|
+
const located = await findTranscriptFilesAsync('claude', stated.sessionId, projectsRoot);
|
|
809
1767
|
return {
|
|
810
|
-
entry: located.length === 1
|
|
811
|
-
? { candidate: { ...stated, filePath: located[0] }, rank: 'stated' }
|
|
812
|
-
: null,
|
|
1768
|
+
entry: located.length === 1 ? { candidate: { ...stated, filePath: located[0] }, rank: 'stated' } : null,
|
|
813
1769
|
pending: false
|
|
814
1770
|
};
|
|
815
1771
|
}
|
|
816
|
-
const startSeconds =
|
|
817
|
-
|
|
818
|
-
|
|
1772
|
+
const [startSeconds, processCwd] = await Promise.all([
|
|
1773
|
+
processStartTicksToSecondsAsync(startTicks),
|
|
1774
|
+
readProcessCwdAsync(pid)
|
|
1775
|
+
]);
|
|
1776
|
+
const cwdCandidates = [processCwd, env.get('PWD') ?? null].filter((value) => Boolean(value));
|
|
1777
|
+
if (cwdCandidates.length === 0)
|
|
819
1778
|
return { entry: null, pending: false };
|
|
820
|
-
|
|
821
|
-
const inferred = resolveClaudeTranscriptFromProjectFiles({
|
|
1779
|
+
const inferred = await resolveClaudeTranscriptFromProjectFiles({
|
|
822
1780
|
cwdCandidates,
|
|
823
1781
|
processStartedAtMs: startSeconds === null ? null : startSeconds * 1000,
|
|
824
1782
|
projectsRoot
|
|
825
1783
|
});
|
|
826
1784
|
return { entry: inferred ? { candidate: inferred, rank: 'inferred' } : null, pending: false };
|
|
827
1785
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
// interactive top-level session writes {pid, sessionId, cwd, procStart, ...}
|
|
831
|
-
// at startup, updates sessionId in place on /clear and /resume, and unlinks
|
|
832
|
-
// the file on exit. `procStart` is the process start time in clock ticks
|
|
833
|
-
// exactly as /proc/<pid>/stat reports it — comparing the two rejects stale
|
|
834
|
-
// files left behind by a crashed process whose pid was since reused.
|
|
835
|
-
//
|
|
836
|
-
// This is an undocumented internal mechanism (Claude Code calls it
|
|
837
|
-
// "concurrentSessions"), so parse defensively and treat any mismatch as
|
|
838
|
-
// "registry unavailable" — the cwd heuristic below remains the fallback.
|
|
839
|
-
export function resolveClaudeTranscriptFromSessionRegistry(options) {
|
|
840
|
-
if (!options.procStartTicks) {
|
|
841
|
-
return { kind: 'unavailable' };
|
|
842
|
-
}
|
|
843
|
-
let raw;
|
|
844
|
-
try {
|
|
845
|
-
raw = readFileSync(path.join(options.configRoot, 'sessions', `${options.pid}.json`), 'utf8');
|
|
846
|
-
}
|
|
847
|
-
catch {
|
|
1786
|
+
export async function resolveClaudeTranscriptFromSessionRegistry(options) {
|
|
1787
|
+
if (!options.procStartTicks)
|
|
848
1788
|
return { kind: 'unavailable' };
|
|
849
|
-
}
|
|
850
1789
|
let entry;
|
|
851
1790
|
try {
|
|
852
|
-
entry = JSON.parse(
|
|
1791
|
+
entry = JSON.parse(await readFile(path.join(options.configRoot, 'sessions', `${options.pid}.json`), 'utf8'));
|
|
853
1792
|
}
|
|
854
1793
|
catch {
|
|
855
1794
|
return { kind: 'unavailable' };
|
|
856
1795
|
}
|
|
857
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1796
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
858
1797
|
return { kind: 'unavailable' };
|
|
859
|
-
}
|
|
860
1798
|
const record = entry;
|
|
861
|
-
// pid is a JSON number today and procStart a JSON string, but this is an
|
|
862
|
-
// undocumented format — normalize both sides so a serialization change
|
|
863
|
-
// degrades the comparison gracefully instead of silently disabling the
|
|
864
|
-
// registry forever.
|
|
865
1799
|
if (Number(record.pid) !== options.pid || record.procStart === undefined || record.procStart === null || String(record.procStart) !== options.procStartTicks) {
|
|
866
1800
|
return { kind: 'unavailable' };
|
|
867
1801
|
}
|
|
868
|
-
|
|
869
|
-
// pane's tooling may have spawned, never to the pane's own conversation.
|
|
870
|
-
if (record.kind !== undefined && record.kind !== 'interactive') {
|
|
1802
|
+
if (record.kind !== undefined && record.kind !== 'interactive')
|
|
871
1803
|
return { kind: 'unavailable' };
|
|
872
|
-
}
|
|
873
1804
|
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined;
|
|
874
|
-
if (!isSessionId(sessionId))
|
|
1805
|
+
if (!isSessionId(sessionId))
|
|
875
1806
|
return { kind: 'unavailable' };
|
|
876
|
-
}
|
|
877
1807
|
const projectsRoot = path.join(options.configRoot, 'projects');
|
|
878
|
-
const cwdCandidates = typeof record.cwd === 'string' ?
|
|
1808
|
+
const cwdCandidates = typeof record.cwd === 'string' ? await normalizeClaudeCwdCandidatesAsync([record.cwd]) : [];
|
|
879
1809
|
for (const cwd of cwdCandidates) {
|
|
880
1810
|
const filePath = path.join(projectsRoot, encodeClaudeProjectPath(cwd), `${sessionId}.jsonl`);
|
|
881
|
-
|
|
1811
|
+
try {
|
|
1812
|
+
await access(filePath);
|
|
882
1813
|
return { kind: 'resolved', candidate: { provider: 'claude', sessionId, filePath } };
|
|
883
1814
|
}
|
|
1815
|
+
catch {
|
|
1816
|
+
// Try the unique-id fallback below.
|
|
1817
|
+
}
|
|
884
1818
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
// unique match anywhere under projects/ is unambiguous.
|
|
888
|
-
const matches = findTranscriptFiles('claude', sessionId, projectsRoot);
|
|
889
|
-
if (matches.length === 1) {
|
|
1819
|
+
const matches = await findTranscriptFilesAsync('claude', sessionId, projectsRoot);
|
|
1820
|
+
if (matches.length === 1)
|
|
890
1821
|
return { kind: 'resolved', candidate: { provider: 'claude', sessionId, filePath: matches[0] } };
|
|
891
|
-
}
|
|
892
|
-
if (matches.length === 0) {
|
|
893
|
-
return { kind: 'pending' };
|
|
894
|
-
}
|
|
895
|
-
// Duplicate copies of the same session id (backed-up project dirs) are
|
|
896
|
-
// ambiguity, not "not written yet" — report unavailable so lower tiers
|
|
897
|
-
// get a shot instead of waiting forever.
|
|
898
|
-
return { kind: 'unavailable' };
|
|
1822
|
+
return matches.length === 0 ? { kind: 'pending' } : { kind: 'unavailable' };
|
|
899
1823
|
}
|
|
900
|
-
export function resolveClaudeTranscriptFromProjectFiles(options) {
|
|
901
|
-
const cwdCandidates =
|
|
902
|
-
if (cwdCandidates.length === 0)
|
|
1824
|
+
export async function resolveClaudeTranscriptFromProjectFiles(options) {
|
|
1825
|
+
const cwdCandidates = await normalizeClaudeCwdCandidatesAsync(options.cwdCandidates);
|
|
1826
|
+
if (cwdCandidates.length === 0)
|
|
903
1827
|
return null;
|
|
904
|
-
}
|
|
905
1828
|
const projectRoot = options.projectsRoot ?? path.join(options.homeDir ?? os.homedir(), '.claude', 'projects');
|
|
906
1829
|
const projectDirs = new Set(cwdCandidates.map((cwd) => path.join(projectRoot, encodeClaudeProjectPath(cwd))));
|
|
907
1830
|
const candidates = new Map();
|
|
908
1831
|
for (const projectDir of projectDirs) {
|
|
909
1832
|
let entries;
|
|
910
1833
|
try {
|
|
911
|
-
entries =
|
|
1834
|
+
entries = await readdir(projectDir, { withFileTypes: true });
|
|
912
1835
|
}
|
|
913
1836
|
catch {
|
|
914
1837
|
continue;
|
|
915
1838
|
}
|
|
916
1839
|
for (const entry of entries) {
|
|
917
|
-
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
1840
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
918
1841
|
continue;
|
|
919
|
-
}
|
|
920
1842
|
const sessionId = entry.name.slice(0, -'.jsonl'.length);
|
|
921
|
-
if (!isSessionId(sessionId))
|
|
1843
|
+
if (!isSessionId(sessionId))
|
|
922
1844
|
continue;
|
|
923
|
-
}
|
|
924
1845
|
const filePath = path.join(projectDir, entry.name);
|
|
925
|
-
if (!
|
|
1846
|
+
if (!(await isPlausibleClaudeTranscriptFileAsync(filePath, sessionId, cwdCandidates, options.processStartedAtMs)))
|
|
926
1847
|
continue;
|
|
927
|
-
}
|
|
928
1848
|
candidates.set(sessionId, { provider: 'claude', sessionId, filePath });
|
|
929
1849
|
}
|
|
930
1850
|
}
|
|
931
|
-
|
|
932
|
-
return null;
|
|
933
|
-
}
|
|
934
|
-
return [...candidates.values()][0] ?? null;
|
|
1851
|
+
return candidates.size === 1 ? ([...candidates.values()][0] ?? null) : null;
|
|
935
1852
|
}
|
|
936
1853
|
function findCommandArg(args, command) {
|
|
937
1854
|
if (command === 'codex') {
|
|
@@ -955,17 +1872,16 @@ function findCommandArg(args, command) {
|
|
|
955
1872
|
return false;
|
|
956
1873
|
});
|
|
957
1874
|
}
|
|
958
|
-
function
|
|
1875
|
+
async function normalizeClaudeCwdCandidatesAsync(values) {
|
|
959
1876
|
const out = new Set();
|
|
960
1877
|
for (const value of values) {
|
|
961
1878
|
const trimmed = value.trim();
|
|
962
|
-
if (!trimmed)
|
|
1879
|
+
if (!trimmed)
|
|
963
1880
|
continue;
|
|
964
|
-
}
|
|
965
1881
|
const resolved = path.resolve(trimmed);
|
|
966
1882
|
out.add(resolved);
|
|
967
1883
|
try {
|
|
968
|
-
out.add(
|
|
1884
|
+
out.add(await realpath(resolved));
|
|
969
1885
|
}
|
|
970
1886
|
catch {
|
|
971
1887
|
// The process cwd is enough when the path no longer resolves.
|
|
@@ -976,55 +1892,108 @@ function normalizeClaudeCwdCandidates(values) {
|
|
|
976
1892
|
function encodeClaudeProjectPath(cwd) {
|
|
977
1893
|
return cwd.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
978
1894
|
}
|
|
979
|
-
function
|
|
980
|
-
let stat;
|
|
1895
|
+
async function isPlausibleClaudeTranscriptFileAsync(filePath, sessionId, cwdCandidates, processStartedAtMs) {
|
|
981
1896
|
try {
|
|
982
|
-
|
|
1897
|
+
const fileStat = await stat(filePath);
|
|
1898
|
+
if (!fileStat.isFile())
|
|
1899
|
+
return false;
|
|
1900
|
+
if (processStartedAtMs !== undefined && processStartedAtMs !== null && fileStat.mtimeMs + CLAUDE_TRANSCRIPT_START_SKEW_MS < processStartedAtMs) {
|
|
1901
|
+
return false;
|
|
1902
|
+
}
|
|
1903
|
+
const cached = confirmedClaudeTranscripts.get(sessionId);
|
|
1904
|
+
if (cached) {
|
|
1905
|
+
if (cached.filePath === filePath &&
|
|
1906
|
+
cached.dev === fileStat.dev &&
|
|
1907
|
+
cached.ino === fileStat.ino &&
|
|
1908
|
+
cwdCandidates.some((cwd) => cached.verifiedCwds.includes(cwd))) {
|
|
1909
|
+
if (fileStat.size >= cached.verifiedSize) {
|
|
1910
|
+
touchConfirmedClaudeTranscript(sessionId, cached);
|
|
1911
|
+
return true;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
confirmedClaudeTranscripts.delete(sessionId);
|
|
1915
|
+
}
|
|
1916
|
+
const sample = await readFileHeadTailAsync(filePath, CLAUDE_TRANSCRIPT_SAMPLE_BYTES, fileStat);
|
|
1917
|
+
if (!sample)
|
|
1918
|
+
return false;
|
|
1919
|
+
const sessionNeedle = `"sessionId":${JSON.stringify(sessionId)}`;
|
|
1920
|
+
const verifiedCwds = cwdCandidates.filter((cwd) => sample.includes(`"cwd":${JSON.stringify(cwd)}`));
|
|
1921
|
+
const plausible = sample.includes(sessionNeedle) && verifiedCwds.length > 0;
|
|
1922
|
+
if (plausible) {
|
|
1923
|
+
rememberConfirmedClaudeTranscript(sessionId, {
|
|
1924
|
+
filePath,
|
|
1925
|
+
dev: fileStat.dev,
|
|
1926
|
+
ino: fileStat.ino,
|
|
1927
|
+
verifiedSize: fileStat.size,
|
|
1928
|
+
verifiedCwds
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
return plausible;
|
|
983
1932
|
}
|
|
984
1933
|
catch {
|
|
985
1934
|
return false;
|
|
986
1935
|
}
|
|
987
|
-
if (!stat.isFile()) {
|
|
988
|
-
return false;
|
|
989
|
-
}
|
|
990
|
-
if (processStartedAtMs !== undefined &&
|
|
991
|
-
processStartedAtMs !== null &&
|
|
992
|
-
stat.mtimeMs + CLAUDE_TRANSCRIPT_START_SKEW_MS < processStartedAtMs) {
|
|
993
|
-
return false;
|
|
994
|
-
}
|
|
995
|
-
const sample = readFileHeadTail(filePath, CLAUDE_TRANSCRIPT_SAMPLE_BYTES);
|
|
996
|
-
if (!sample) {
|
|
997
|
-
return false;
|
|
998
|
-
}
|
|
999
|
-
const sessionNeedle = `"sessionId":${JSON.stringify(sessionId)}`;
|
|
1000
|
-
if (!sample.includes(sessionNeedle)) {
|
|
1001
|
-
return false;
|
|
1002
|
-
}
|
|
1003
|
-
return cwdCandidates.some((cwd) => sample.includes(`"cwd":${JSON.stringify(cwd)}`));
|
|
1004
1936
|
}
|
|
1005
|
-
function
|
|
1006
|
-
let
|
|
1937
|
+
async function readFileHeadTailAsync(filePath, bytes, knownStat) {
|
|
1938
|
+
let handle;
|
|
1007
1939
|
try {
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1940
|
+
handle = await openFile(filePath, 'r');
|
|
1941
|
+
const initial = await handle.stat();
|
|
1942
|
+
if (knownStat && (initial.dev !== knownStat.dev || initial.ino !== knownStat.ino)) {
|
|
1943
|
+
return null;
|
|
1944
|
+
}
|
|
1945
|
+
const size = initial.size;
|
|
1946
|
+
const headBytes = Math.min(bytes, size);
|
|
1011
1947
|
const head = Buffer.alloc(headBytes);
|
|
1012
|
-
|
|
1013
|
-
if (
|
|
1014
|
-
return
|
|
1948
|
+
const actualHeadBytes = await readExactly(handle, head, 0);
|
|
1949
|
+
if (actualHeadBytes !== headBytes)
|
|
1950
|
+
return null;
|
|
1951
|
+
let sample = head.subarray(0, actualHeadBytes).toString('utf8');
|
|
1952
|
+
if (size > bytes) {
|
|
1953
|
+
const tailBytes = Math.min(bytes, size - headBytes);
|
|
1954
|
+
const tail = Buffer.alloc(tailBytes);
|
|
1955
|
+
const actualTailBytes = await readExactly(handle, tail, size - tailBytes);
|
|
1956
|
+
if (actualTailBytes !== tailBytes)
|
|
1957
|
+
return null;
|
|
1958
|
+
sample = `${sample}\n${tail.toString('utf8')}`;
|
|
1015
1959
|
}
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1960
|
+
const after = await handle.stat();
|
|
1961
|
+
if (after.dev !== initial.dev ||
|
|
1962
|
+
after.ino !== initial.ino ||
|
|
1963
|
+
after.size < size ||
|
|
1964
|
+
(after.size === size && after.mtimeMs !== initial.mtimeMs)) {
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
return sample;
|
|
1020
1968
|
}
|
|
1021
1969
|
catch {
|
|
1022
1970
|
return null;
|
|
1023
1971
|
}
|
|
1024
1972
|
finally {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1973
|
+
await handle?.close().catch(() => undefined);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
async function readExactly(handle, buffer, position) {
|
|
1977
|
+
let offset = 0;
|
|
1978
|
+
while (offset < buffer.length) {
|
|
1979
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, position + offset);
|
|
1980
|
+
if (bytesRead === 0)
|
|
1981
|
+
break;
|
|
1982
|
+
offset += bytesRead;
|
|
1983
|
+
}
|
|
1984
|
+
return offset;
|
|
1985
|
+
}
|
|
1986
|
+
function touchConfirmedClaudeTranscript(sessionId, entry) {
|
|
1987
|
+
confirmedClaudeTranscripts.delete(sessionId);
|
|
1988
|
+
confirmedClaudeTranscripts.set(sessionId, entry);
|
|
1989
|
+
}
|
|
1990
|
+
function rememberConfirmedClaudeTranscript(sessionId, entry) {
|
|
1991
|
+
touchConfirmedClaudeTranscript(sessionId, entry);
|
|
1992
|
+
while (confirmedClaudeTranscripts.size > CLAUDE_TRANSCRIPT_CACHE_LIMIT) {
|
|
1993
|
+
const oldest = confirmedClaudeTranscripts.keys().next().value;
|
|
1994
|
+
if (typeof oldest !== 'string')
|
|
1995
|
+
break;
|
|
1996
|
+
confirmedClaudeTranscripts.delete(oldest);
|
|
1028
1997
|
}
|
|
1029
1998
|
}
|
|
1030
1999
|
function findCodexSessionId(args, commandIndex) {
|
|
@@ -1052,49 +2021,41 @@ function readLongOptionValue(arg, option) {
|
|
|
1052
2021
|
const prefix = `${option}=`;
|
|
1053
2022
|
return arg.startsWith(prefix) ? arg.slice(prefix.length) : null;
|
|
1054
2023
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
2024
|
+
const transcriptFileInflight = new KeyedSingleFlight();
|
|
2025
|
+
async function findTranscriptFilesAsync(provider, sessionId, root) {
|
|
2026
|
+
const searchRoot = root ?? (provider === 'codex'
|
|
2027
|
+
? path.join(os.homedir(), '.codex', 'sessions')
|
|
2028
|
+
: path.join(os.homedir(), '.claude', 'projects'));
|
|
1060
2029
|
const cacheKey = `${provider}:${searchRoot}:${sessionId}`;
|
|
1061
2030
|
const cached = transcriptFileCache.get(cacheKey);
|
|
1062
|
-
if (cached)
|
|
2031
|
+
if (cached)
|
|
1063
2032
|
return cached.value.slice();
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
2033
|
+
return transcriptFileInflight.run(cacheKey, async () => {
|
|
2034
|
+
const matches = [];
|
|
2035
|
+
await findTranscriptFilesUnderAsync(searchRoot, provider, sessionId, matches);
|
|
2036
|
+
transcriptFileCache.set(cacheKey, matches.slice(), matches.length === 1 ? TRANSCRIPT_FILE_CACHE_TTL_MS : TRANSCRIPT_FILE_NEGATIVE_CACHE_TTL_MS);
|
|
2037
|
+
return matches;
|
|
2038
|
+
});
|
|
1069
2039
|
}
|
|
1070
|
-
function
|
|
1071
|
-
if (matches.length > 1)
|
|
2040
|
+
async function findTranscriptFilesUnderAsync(root, provider, sessionId, matches) {
|
|
2041
|
+
if (matches.length > 1)
|
|
1072
2042
|
return;
|
|
1073
|
-
}
|
|
1074
2043
|
let entries;
|
|
1075
2044
|
try {
|
|
1076
|
-
entries =
|
|
2045
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
1077
2046
|
}
|
|
1078
2047
|
catch {
|
|
1079
2048
|
return;
|
|
1080
2049
|
}
|
|
1081
2050
|
for (const entry of entries) {
|
|
1082
|
-
if (matches.length > 1)
|
|
2051
|
+
if (matches.length > 1)
|
|
1083
2052
|
return;
|
|
1084
|
-
}
|
|
1085
2053
|
const fullPath = path.join(root, entry.name);
|
|
1086
2054
|
if (entry.isDirectory()) {
|
|
1087
|
-
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1090
|
-
if (!entry.isFile()) {
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
if (provider === 'codex' && entry.name.startsWith('rollout-') && entry.name.endsWith(`${sessionId}.jsonl`)) {
|
|
1094
|
-
matches.push(fullPath);
|
|
1095
|
-
continue;
|
|
2055
|
+
await findTranscriptFilesUnderAsync(fullPath, provider, sessionId, matches);
|
|
1096
2056
|
}
|
|
1097
|
-
if (provider === '
|
|
2057
|
+
else if (entry.isFile() && ((provider === 'codex' && entry.name.startsWith('rollout-') && entry.name.endsWith(`${sessionId}.jsonl`)) ||
|
|
2058
|
+
(provider === 'claude' && entry.name === `${sessionId}.jsonl`))) {
|
|
1098
2059
|
matches.push(fullPath);
|
|
1099
2060
|
}
|
|
1100
2061
|
}
|