nterminal 1.2.78 → 1.2.80

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