nterminal 1.2.79 → 1.2.81

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