@lzdi/pty-remote-cli 0.1.3

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.
@@ -0,0 +1,1509 @@
1
+ import { promises as fs, watch as watchFs, type FSWatcher } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import type {
6
+ GetOlderMessagesResultPayload,
7
+ ManagedPtyHandleSummary,
8
+ MessagesUpsertPayload,
9
+ SelectConversationResultPayload,
10
+ TerminalFramePatchPayload
11
+ } from '@lzdi/pty-remote-protocol/protocol.ts';
12
+ import type { ChatMessage, ProviderId, RuntimeSnapshot, RuntimeStatus } from '@lzdi/pty-remote-protocol/runtime-types.ts';
13
+ import {
14
+ applyClaudeJsonlLine,
15
+ createClaudeJsonlMessagesState,
16
+ materializeClaudeJsonlMessages,
17
+ resolveClaudeJsonlFilePath,
18
+ type ClaudeJsonlMessagesState,
19
+ type ClaudeJsonlRuntimePhase
20
+ } from './jsonl.ts';
21
+ import {
22
+ appendRecentOutput,
23
+ looksLikeBypassPrompt,
24
+ looksReadyForInput,
25
+ resizeClaudePtySession,
26
+ startClaudePtySession,
27
+ stopClaudePtySession,
28
+ type ClaudePtySession
29
+ } from './pty.ts';
30
+ import { HeadlessTerminalFrameState } from '../terminal/frame-state.ts';
31
+
32
+ export interface PtyManagerOptions {
33
+ claudeBin: string;
34
+ permissionMode: string;
35
+ defaultCwd: string;
36
+ terminalCols: number;
37
+ terminalRows: number;
38
+ terminalFrameScrollback: number;
39
+ recentOutputMaxChars: number;
40
+ claudeReadyTimeoutMs: number;
41
+ promptSubmitDelayMs: number;
42
+ jsonlRefreshDebounceMs: number;
43
+ snapshotEmitDebounceMs: number;
44
+ snapshotMessagesMax: number;
45
+ olderMessagesPageMax: number;
46
+ gcIntervalMs: number;
47
+ detachedDraftTtlMs: number;
48
+ detachedJsonlMissingTtlMs: number;
49
+ detachedPtyTtlMs: number;
50
+ maxDetachedPtys: number;
51
+ }
52
+
53
+ interface PtyManagerCallbacks {
54
+ emitMessagesUpsert(payload: Omit<MessagesUpsertPayload, 'cliId'>): void;
55
+ emitSnapshot(snapshot: RuntimeSnapshot): void;
56
+ emitTerminalFramePatch(payload: Omit<TerminalFramePatchPayload, 'cliId' | 'providerId'>): void;
57
+ emitTerminalSessionEvicted(payload: {
58
+ conversationKey: string | null;
59
+ reason: string;
60
+ sessionId: string;
61
+ }): void;
62
+ }
63
+
64
+ export interface PtyManagerSelection {
65
+ cwd: string;
66
+ label: string;
67
+ sessionId: string | null;
68
+ conversationKey: string;
69
+ }
70
+
71
+ interface RuntimeCleanupTarget {
72
+ cwd: string;
73
+ conversationKey: string;
74
+ sessionId: string | null;
75
+ }
76
+
77
+ interface AgentRuntimeState extends RuntimeSnapshot {
78
+ allMessages: ChatMessage[];
79
+ recentTerminalOutput: string;
80
+ }
81
+
82
+ type HandleLifecycle = 'attached' | 'detached' | 'exited' | 'error';
83
+
84
+ interface PtyHandle {
85
+ threadKey: string;
86
+ cwd: string;
87
+ label: string;
88
+ sessionId: string | null;
89
+ lifecycle: HandleLifecycle;
90
+ pty: ClaudePtySession | null;
91
+ ptyToken: number;
92
+ jsonlWatcher: FSWatcher | null;
93
+ watchedJsonlSessionId: string | null;
94
+ jsonlMessagesState: ClaudeJsonlMessagesState;
95
+ parsedJsonlSessionId: string | null;
96
+ jsonlReadOffset: number;
97
+ jsonlPendingLine: string;
98
+ awaitingJsonlTurn: boolean;
99
+ suppressNextPtyExitError: boolean;
100
+ expectedPtyExitReason: string | null;
101
+ runtime: AgentRuntimeState;
102
+ terminalFrame: HeadlessTerminalFrameState;
103
+ detachedAt: number | null;
104
+ jsonlMissingSince: number | null;
105
+ lastJsonlActivityAt: number | null;
106
+ lastTerminalActivityAt: number | null;
107
+ lastUserInputAt: number | null;
108
+ }
109
+
110
+ function cloneValue<T>(value: T): T {
111
+ return structuredClone(value);
112
+ }
113
+
114
+ function errorMessage(error: unknown, fallback: string): string {
115
+ return error instanceof Error ? error.message : fallback;
116
+ }
117
+
118
+ function tailForLog(text: string | null | undefined, maxChars = 1200): string {
119
+ if (!text) {
120
+ return '';
121
+ }
122
+ return text.slice(-maxChars);
123
+ }
124
+
125
+ function messageEqual(left: ChatMessage | undefined, right: ChatMessage | undefined): boolean {
126
+ if (!left || !right) {
127
+ return false;
128
+ }
129
+
130
+ return (
131
+ left.id === right.id &&
132
+ left.role === right.role &&
133
+ left.status === right.status &&
134
+ left.createdAt === right.createdAt &&
135
+ JSON.stringify(left.blocks) === JSON.stringify(right.blocks)
136
+ );
137
+ }
138
+
139
+ function messagesEqual(left: ChatMessage[], right: ChatMessage[]): boolean {
140
+ if (left.length !== right.length) {
141
+ return false;
142
+ }
143
+
144
+ return left.every((message, index) => messageEqual(message, right[index]));
145
+ }
146
+
147
+ function sleep(ms: number): Promise<void> {
148
+ return new Promise((resolve) => {
149
+ setTimeout(resolve, ms);
150
+ });
151
+ }
152
+
153
+ const AWAITING_JSONL_TURN_STALE_MS = 4000;
154
+
155
+ export class PtyManager {
156
+ private readonly providerId: ProviderId = 'claude';
157
+
158
+ private readonly handles = new Map<string, PtyHandle>();
159
+
160
+ private readonly callbacks: PtyManagerCallbacks;
161
+
162
+ private readonly options: PtyManagerOptions;
163
+
164
+ private activeThreadKey: string | null = null;
165
+
166
+ private currentCwd: string;
167
+
168
+ private jsonlRefreshTimer: NodeJS.Timeout | null = null;
169
+
170
+ private snapshotEmitTimer: NodeJS.Timeout | null = null;
171
+
172
+ private gcTimer: NodeJS.Timeout;
173
+
174
+ private terminalSize: { cols: number; rows: number };
175
+
176
+ constructor(options: PtyManagerOptions, callbacks: PtyManagerCallbacks) {
177
+ this.options = options;
178
+ this.callbacks = callbacks;
179
+ this.currentCwd = options.defaultCwd;
180
+ this.terminalSize = {
181
+ cols: options.terminalCols,
182
+ rows: options.terminalRows
183
+ };
184
+ this.gcTimer = setInterval(() => {
185
+ void this.gcDetachedHandles();
186
+ }, options.gcIntervalMs);
187
+ this.gcTimer.unref();
188
+ }
189
+
190
+ private log(level: 'info' | 'warn' | 'error', message: string, details?: Record<string, unknown>): void {
191
+ const logger = level === 'info' ? console.log : level === 'warn' ? console.warn : console.error;
192
+ if (details) {
193
+ logger(`[pty-remote][claude] ${message}`, details);
194
+ return;
195
+ }
196
+ logger(`[pty-remote][claude] ${message}`);
197
+ }
198
+
199
+ private handleContext(handle: PtyHandle): Record<string, unknown> {
200
+ return {
201
+ conversationKey: handle.threadKey,
202
+ cwd: handle.cwd,
203
+ sessionId: handle.sessionId
204
+ };
205
+ }
206
+
207
+ getRegistrationPayload(): {
208
+ cwd: string;
209
+ sessionId: string | null;
210
+ conversationKey: string | null;
211
+ } {
212
+ const handle = this.getActiveHandle();
213
+ return {
214
+ cwd: handle?.cwd ?? this.currentCwd,
215
+ sessionId: handle?.sessionId ?? null,
216
+ conversationKey: handle?.threadKey ?? null
217
+ };
218
+ }
219
+
220
+ getSnapshot(): RuntimeSnapshot {
221
+ return this.createRuntimeSnapshot(this.getActiveHandle());
222
+ }
223
+
224
+ async primeActiveTerminalFrame(): Promise<void> {
225
+ const handle = this.getActiveHandle();
226
+ if (!handle) {
227
+ throw new Error('No active terminal is selected');
228
+ }
229
+ this.emitActiveTerminalFrame(handle);
230
+ }
231
+
232
+ async refreshActiveState(): Promise<void> {
233
+ await this.refreshActiveMessages();
234
+ this.emitSnapshotNow();
235
+ }
236
+
237
+ updateTerminalSize(cols: number, rows: number): void {
238
+ const nextCols = Number.isFinite(cols) ? Math.max(20, Math.min(Math.floor(cols), 400)) : this.terminalSize.cols;
239
+ const nextRows = Number.isFinite(rows) ? Math.max(8, Math.min(Math.floor(rows), 200)) : this.terminalSize.rows;
240
+ this.terminalSize = {
241
+ cols: nextCols,
242
+ rows: nextRows
243
+ };
244
+
245
+ const handle = this.getActiveHandle();
246
+ resizeClaudePtySession(handle?.pty ?? null, nextCols, nextRows);
247
+ if (!handle) {
248
+ return;
249
+ }
250
+
251
+ const patch = handle.terminalFrame.resize(nextCols, nextRows);
252
+ if (patch && this.isActiveHandle(handle)) {
253
+ this.emitTerminalFramePatch(handle, patch);
254
+ }
255
+ }
256
+
257
+ listManagedPtyHandles(): ManagedPtyHandleSummary[] {
258
+ return [...this.handles.values()]
259
+ .map((handle) => {
260
+ const lastActivityAt = this.getLastActivityAt(handle);
261
+ return {
262
+ conversationKey: handle.threadKey,
263
+ sessionId: handle.sessionId,
264
+ cwd: handle.cwd,
265
+ label: handle.label,
266
+ lifecycle: handle.lifecycle,
267
+ hasPty: handle.pty !== null,
268
+ lastActivityAt: lastActivityAt > 0 ? lastActivityAt : null
269
+ };
270
+ })
271
+ .sort((left, right) => {
272
+ if (left.hasPty !== right.hasPty) {
273
+ return left.hasPty ? -1 : 1;
274
+ }
275
+ const leftLastActivityAt = left.lastActivityAt ?? 0;
276
+ const rightLastActivityAt = right.lastActivityAt ?? 0;
277
+ if (leftLastActivityAt !== rightLastActivityAt) {
278
+ return rightLastActivityAt - leftLastActivityAt;
279
+ }
280
+ return left.conversationKey.localeCompare(right.conversationKey);
281
+ });
282
+ }
283
+
284
+ async activateConversation(selection: PtyManagerSelection): Promise<SelectConversationResultPayload> {
285
+ const normalized = await this.normalizeSelection(selection);
286
+ const current = this.getActiveHandle();
287
+ if (current && current.threadKey !== normalized.conversationKey) {
288
+ this.detachHandle(current);
289
+ }
290
+
291
+ let handle = this.handles.get(normalized.conversationKey);
292
+ if (!handle) {
293
+ handle = this.createHandle(normalized);
294
+ this.handles.set(handle.threadKey, handle);
295
+ } else {
296
+ this.syncHandleSelection(handle, normalized);
297
+ }
298
+
299
+ this.activeThreadKey = handle.threadKey;
300
+ this.currentCwd = handle.cwd;
301
+ handle.lifecycle = 'attached';
302
+ handle.detachedAt = null;
303
+ handle.jsonlMissingSince = null;
304
+
305
+ await this.refreshMessagesFromJsonl(handle);
306
+
307
+ if (!handle.pty) {
308
+ this.startHandleSession(handle, { emitSnapshot: false });
309
+ } else {
310
+ this.ensureJsonlWatcher(handle, handle.sessionId);
311
+ }
312
+
313
+ this.emitSnapshotNow();
314
+
315
+ return {
316
+ providerId: this.providerId,
317
+ cwd: handle.cwd,
318
+ label: handle.label,
319
+ sessionId: handle.sessionId,
320
+ conversationKey: handle.threadKey
321
+ };
322
+ }
323
+
324
+ async dispatchMessage(content: string): Promise<void> {
325
+ const trimmedContent = content.trim();
326
+ if (!trimmedContent) {
327
+ throw new Error('Message cannot be empty');
328
+ }
329
+
330
+ const handle = this.getActiveHandle();
331
+ if (!handle) {
332
+ throw new Error('No active thread selected');
333
+ }
334
+
335
+ this.clearLastError(handle);
336
+ if (handle.runtime.status === 'running' || handle.awaitingJsonlTurn) {
337
+ throw new Error('Claude is still handling the previous message');
338
+ }
339
+
340
+ await this.ensureHandleSession(handle);
341
+
342
+ try {
343
+ await this.waitForHandleReady(handle);
344
+ handle.awaitingJsonlTurn = true;
345
+ handle.lastUserInputAt = Date.now();
346
+ this.setStatus(handle, 'running', true);
347
+ await this.sendPromptToHandle(handle, trimmedContent);
348
+ this.scheduleJsonlRefresh(0);
349
+ } catch (error) {
350
+ this.log('error', 'dispatchMessage failed', {
351
+ ...this.handleContext(handle),
352
+ error: errorMessage(error, 'Claude request failed'),
353
+ promptLength: trimmedContent.length
354
+ });
355
+ this.setStatus(handle, 'idle', true);
356
+ this.setLastError(handle, error instanceof Error ? error.message : 'Claude request failed');
357
+ throw error;
358
+ }
359
+ }
360
+
361
+ async stopActiveRun(): Promise<void> {
362
+ const handle = this.getActiveHandle();
363
+ if (!handle || !this.isBusyStatus(handle.runtime.status)) {
364
+ return;
365
+ }
366
+
367
+ const previousMessages = handle.runtime.messages;
368
+ const previousHasOlderMessages = handle.runtime.hasOlderMessages;
369
+
370
+ this.clearLastError(handle);
371
+ handle.awaitingJsonlTurn = false;
372
+ if (handle.jsonlMessagesState.runtimePhase !== 'idle') {
373
+ handle.jsonlMessagesState.runtimePhase = 'idle';
374
+ handle.jsonlMessagesState.activityRevision += 1;
375
+ }
376
+
377
+ const nextAllMessages = this.applyStreamingStatus(materializeClaudeJsonlMessages(handle.jsonlMessagesState), false);
378
+ const nextMessages = this.selectRecentMessages(nextAllMessages);
379
+ const nextHasOlderMessages = nextAllMessages.length > nextMessages.length;
380
+ const allMessagesChanged = !messagesEqual(handle.runtime.allMessages, nextAllMessages);
381
+ const messagesChanged = !messagesEqual(handle.runtime.messages, nextMessages);
382
+ const hasOlderMessagesChanged = handle.runtime.hasOlderMessages !== nextHasOlderMessages;
383
+
384
+ if (allMessagesChanged) {
385
+ handle.runtime.allMessages = nextAllMessages;
386
+ }
387
+ if (messagesChanged) {
388
+ handle.runtime.messages = nextMessages;
389
+ }
390
+ if (allMessagesChanged || messagesChanged || hasOlderMessagesChanged) {
391
+ handle.runtime.hasOlderMessages = nextHasOlderMessages;
392
+ }
393
+
394
+ this.stopHandlePtyPreservingReplay(handle, 'stop-active-run');
395
+ this.setStatus(handle, 'idle', true);
396
+
397
+ if (this.isActiveHandle(handle) && (allMessagesChanged || messagesChanged || hasOlderMessagesChanged)) {
398
+ const upsertPayload = this.createMessagesUpsertPayload(
399
+ handle,
400
+ previousMessages,
401
+ nextMessages,
402
+ previousHasOlderMessages,
403
+ nextHasOlderMessages
404
+ );
405
+ if (upsertPayload) {
406
+ this.callbacks.emitMessagesUpsert(upsertPayload);
407
+ }
408
+ }
409
+
410
+ this.scheduleJsonlRefresh(0);
411
+ }
412
+
413
+ async resetActiveThread(): Promise<void> {
414
+ const handle = this.getActiveHandle();
415
+ if (!handle) {
416
+ return;
417
+ }
418
+
419
+ this.closeJsonlWatcher(handle);
420
+ this.stopHandlePty(handle, 'reset-session');
421
+ handle.sessionId = null;
422
+ this.resetHandleRuntime(handle, null);
423
+ handle.lifecycle = 'attached';
424
+ handle.detachedAt = null;
425
+ this.emitSnapshotNow();
426
+ }
427
+
428
+ async cleanupProject(cwd: string): Promise<void> {
429
+ const normalizedCwd = await this.normalizeProjectCwd(cwd);
430
+ const targets = [...this.handles.values()].filter((handle) => handle.cwd === normalizedCwd);
431
+ for (const handle of targets) {
432
+ this.destroyHandle(handle, 'cleanup-project');
433
+ }
434
+ }
435
+
436
+ async cleanupConversation(target: RuntimeCleanupTarget): Promise<void> {
437
+ const normalizedCwd = await this.normalizeProjectCwd(target.cwd);
438
+ const matches = [...this.handles.values()].filter((handle) => {
439
+ if (handle.cwd !== normalizedCwd) {
440
+ return false;
441
+ }
442
+ return (
443
+ handle.threadKey === target.conversationKey ||
444
+ (target.sessionId !== null && handle.sessionId === target.sessionId)
445
+ );
446
+ });
447
+ for (const handle of matches) {
448
+ this.destroyHandle(handle, 'cleanup-conversation');
449
+ }
450
+ }
451
+
452
+ async getOlderMessages(beforeMessageId?: string, maxMessages = this.options.olderMessagesPageMax): Promise<GetOlderMessagesResultPayload> {
453
+ await this.refreshActiveMessages();
454
+ const handle = this.getActiveHandle();
455
+ if (!handle) {
456
+ return {
457
+ messages: [],
458
+ providerId: null,
459
+ conversationKey: null,
460
+ sessionId: null,
461
+ hasOlderMessages: false
462
+ };
463
+ }
464
+
465
+ const normalizedMaxMessages = Number.isFinite(maxMessages)
466
+ ? Math.max(1, Math.min(Math.floor(maxMessages), this.options.olderMessagesPageMax))
467
+ : this.options.olderMessagesPageMax;
468
+ const allMessages = handle.runtime.allMessages;
469
+ const boundaryIndex = beforeMessageId ? allMessages.findIndex((message) => message.id === beforeMessageId) : allMessages.length;
470
+ const end = boundaryIndex >= 0 ? boundaryIndex : allMessages.length;
471
+ const start = Math.max(0, end - normalizedMaxMessages);
472
+
473
+ return {
474
+ messages: cloneValue(allMessages.slice(start, end)),
475
+ providerId: this.providerId,
476
+ conversationKey: handle.threadKey,
477
+ sessionId: handle.sessionId,
478
+ hasOlderMessages: start > 0
479
+ };
480
+ }
481
+
482
+ async shutdown(): Promise<void> {
483
+ if (this.jsonlRefreshTimer) {
484
+ clearTimeout(this.jsonlRefreshTimer);
485
+ this.jsonlRefreshTimer = null;
486
+ }
487
+ if (this.snapshotEmitTimer) {
488
+ clearTimeout(this.snapshotEmitTimer);
489
+ this.snapshotEmitTimer = null;
490
+ }
491
+
492
+ clearInterval(this.gcTimer);
493
+ for (const handle of this.handles.values()) {
494
+ this.closeJsonlWatcher(handle);
495
+ this.stopHandlePty(handle, 'shutdown');
496
+ }
497
+ }
498
+
499
+ private createHandle(selection: PtyManagerSelection): PtyHandle {
500
+ return {
501
+ threadKey: selection.conversationKey,
502
+ cwd: selection.cwd,
503
+ label: selection.label,
504
+ sessionId: selection.sessionId,
505
+ lifecycle: 'exited',
506
+ pty: null,
507
+ ptyToken: 0,
508
+ jsonlWatcher: null,
509
+ watchedJsonlSessionId: null,
510
+ jsonlMessagesState: createClaudeJsonlMessagesState(),
511
+ parsedJsonlSessionId: selection.sessionId,
512
+ jsonlReadOffset: 0,
513
+ jsonlPendingLine: '',
514
+ awaitingJsonlTurn: false,
515
+ suppressNextPtyExitError: false,
516
+ expectedPtyExitReason: null,
517
+ runtime: this.createFreshState(selection.conversationKey, selection.sessionId),
518
+ terminalFrame: new HeadlessTerminalFrameState({
519
+ cols: this.terminalSize.cols,
520
+ maxLines: this.options.terminalFrameScrollback,
521
+ rows: this.terminalSize.rows,
522
+ scrollback: this.options.terminalFrameScrollback
523
+ }),
524
+ detachedAt: null,
525
+ jsonlMissingSince: null,
526
+ lastJsonlActivityAt: null,
527
+ lastTerminalActivityAt: null,
528
+ lastUserInputAt: null
529
+ };
530
+ }
531
+
532
+ private createFreshState(conversationKey: string | null, sessionId: string | null): AgentRuntimeState {
533
+ return {
534
+ providerId: this.providerId,
535
+ conversationKey,
536
+ status: 'idle',
537
+ sessionId,
538
+ allMessages: [],
539
+ recentTerminalOutput: '',
540
+ messages: [],
541
+ hasOlderMessages: false,
542
+ lastError: null
543
+ };
544
+ }
545
+
546
+ private emitActiveTerminalFrame(handle: PtyHandle | null): void {
547
+ if (!handle) {
548
+ return;
549
+ }
550
+
551
+ this.emitTerminalFramePatch(handle, handle.terminalFrame.createResetPatch());
552
+ }
553
+
554
+ private createRuntimeSnapshot(handle: PtyHandle | null): RuntimeSnapshot {
555
+ if (!handle) {
556
+ return {
557
+ providerId: null,
558
+ conversationKey: null,
559
+ status: 'idle',
560
+ sessionId: null,
561
+ messages: [],
562
+ hasOlderMessages: false,
563
+ lastError: null
564
+ };
565
+ }
566
+
567
+ return {
568
+ providerId: this.providerId,
569
+ conversationKey: handle.threadKey,
570
+ status: handle.runtime.status,
571
+ sessionId: handle.runtime.sessionId,
572
+ messages: cloneValue(handle.runtime.messages),
573
+ hasOlderMessages: handle.runtime.hasOlderMessages,
574
+ lastError: handle.runtime.lastError
575
+ };
576
+ }
577
+
578
+ private getActiveHandle(): PtyHandle | null {
579
+ if (!this.activeThreadKey) {
580
+ return null;
581
+ }
582
+
583
+ return this.handles.get(this.activeThreadKey) ?? null;
584
+ }
585
+
586
+ private isActiveHandle(handle: PtyHandle): boolean {
587
+ return this.activeThreadKey === handle.threadKey;
588
+ }
589
+
590
+ private isBusyStatus(status: RuntimeStatus): boolean {
591
+ return status === 'starting' || status === 'running';
592
+ }
593
+
594
+ private async normalizeSelection(selection: PtyManagerSelection): Promise<PtyManagerSelection> {
595
+ const resolvedCwd = await this.normalizeProjectCwd(selection.cwd);
596
+ const stat = await fs.stat(resolvedCwd);
597
+ if (!stat.isDirectory()) {
598
+ throw new Error('Selected project is not a directory');
599
+ }
600
+
601
+ return {
602
+ cwd: resolvedCwd,
603
+ label: selection.label.trim() || path.basename(resolvedCwd) || resolvedCwd,
604
+ sessionId: selection.sessionId,
605
+ conversationKey: selection.conversationKey
606
+ };
607
+ }
608
+
609
+ private async normalizeProjectCwd(cwd: string): Promise<string> {
610
+ const resolvedCwd = path.resolve(cwd);
611
+ return fs.realpath(resolvedCwd).catch(() => resolvedCwd);
612
+ }
613
+
614
+ private syncHandleSelection(handle: PtyHandle, selection: PtyManagerSelection): void {
615
+ handle.cwd = selection.cwd;
616
+ handle.label = selection.label;
617
+ handle.runtime.providerId = this.providerId;
618
+ handle.runtime.conversationKey = selection.conversationKey;
619
+
620
+ if (handle.sessionId === null) {
621
+ handle.sessionId = selection.sessionId;
622
+ handle.runtime.sessionId = selection.sessionId;
623
+ return;
624
+ }
625
+
626
+ if (!handle.pty && selection.sessionId && handle.sessionId !== selection.sessionId) {
627
+ handle.sessionId = selection.sessionId;
628
+ handle.runtime.sessionId = selection.sessionId;
629
+ this.resetJsonlParsingState(handle, selection.sessionId);
630
+ }
631
+ }
632
+
633
+ private resetJsonlParsingState(handle: PtyHandle, sessionId: string | null): void {
634
+ handle.jsonlMessagesState = createClaudeJsonlMessagesState();
635
+ handle.parsedJsonlSessionId = sessionId;
636
+ handle.jsonlReadOffset = 0;
637
+ handle.jsonlPendingLine = '';
638
+ }
639
+
640
+ private resetHandleRuntime(handle: PtyHandle, sessionId: string | null): void {
641
+ handle.runtime = this.createFreshState(handle.threadKey, sessionId);
642
+ handle.sessionId = sessionId;
643
+ handle.awaitingJsonlTurn = false;
644
+ handle.jsonlMissingSince = null;
645
+ handle.lastJsonlActivityAt = null;
646
+ handle.lastTerminalActivityAt = null;
647
+ handle.lastUserInputAt = null;
648
+ this.resetJsonlParsingState(handle, sessionId);
649
+ }
650
+
651
+ private closeJsonlWatcher(handle: PtyHandle): void {
652
+ if (!handle.jsonlWatcher) {
653
+ return;
654
+ }
655
+
656
+ handle.jsonlWatcher.close();
657
+ handle.jsonlWatcher = null;
658
+ handle.watchedJsonlSessionId = null;
659
+ }
660
+
661
+ private ensureJsonlWatcher(handle: PtyHandle, sessionId: string | null): void {
662
+ if (!this.isActiveHandle(handle) && !handle.pty) {
663
+ this.closeJsonlWatcher(handle);
664
+ return;
665
+ }
666
+
667
+ if (!sessionId) {
668
+ this.closeJsonlWatcher(handle);
669
+ return;
670
+ }
671
+
672
+ if (handle.jsonlWatcher && handle.watchedJsonlSessionId === sessionId) {
673
+ return;
674
+ }
675
+
676
+ this.closeJsonlWatcher(handle);
677
+
678
+ const filePath = this.resolveSessionJsonlFilePath(handle, sessionId);
679
+ const dirPath = path.dirname(filePath);
680
+ const fileName = path.basename(filePath);
681
+
682
+ try {
683
+ handle.jsonlWatcher = watchFs(dirPath, { persistent: false }, (_eventType, changedFileName) => {
684
+ if ((!this.isActiveHandle(handle) && !handle.pty) || handle.sessionId !== sessionId) {
685
+ return;
686
+ }
687
+ if (typeof changedFileName === 'string' && changedFileName.length > 0 && changedFileName !== fileName) {
688
+ return;
689
+ }
690
+ if (this.isActiveHandle(handle)) {
691
+ this.scheduleJsonlRefresh(0);
692
+ return;
693
+ }
694
+ void this.refreshMessagesFromJsonl(handle);
695
+ });
696
+ handle.watchedJsonlSessionId = sessionId;
697
+ handle.jsonlWatcher.on('error', (error) => {
698
+ if ((!this.isActiveHandle(handle) && !handle.pty) || handle.sessionId !== sessionId) {
699
+ return;
700
+ }
701
+ this.closeJsonlWatcher(handle);
702
+ const code = (error as NodeJS.ErrnoException).code;
703
+ if (code === 'ENOENT') {
704
+ return;
705
+ }
706
+ this.log('error', 'jsonl watcher error', {
707
+ ...this.handleContext(handle),
708
+ code,
709
+ dirPath,
710
+ error: errorMessage(error, 'Failed to watch Claude jsonl'),
711
+ filePath
712
+ });
713
+ this.setLastError(handle, error instanceof Error ? error.message : 'Failed to watch Claude jsonl');
714
+ });
715
+ } catch (error) {
716
+ const code = (error as NodeJS.ErrnoException).code;
717
+ if (code === 'ENOENT') {
718
+ return;
719
+ }
720
+ this.log('error', 'failed to start jsonl watcher', {
721
+ ...this.handleContext(handle),
722
+ code,
723
+ dirPath,
724
+ error: errorMessage(error, 'Failed to watch Claude jsonl'),
725
+ filePath
726
+ });
727
+ this.setLastError(handle, error instanceof Error ? error.message : 'Failed to watch Claude jsonl');
728
+ }
729
+ }
730
+
731
+ private resolveSessionJsonlFilePath(handle: PtyHandle, sessionId: string): string {
732
+ return resolveClaudeJsonlFilePath(handle.cwd, sessionId, os.homedir());
733
+ }
734
+
735
+ private resolveRuntimeStatusFromJsonl(handle: PtyHandle, runtimePhase: ClaudeJsonlRuntimePhase): RuntimeStatus {
736
+ if (handle.runtime.lastError !== null && handle.runtime.status === 'error') {
737
+ return 'error';
738
+ }
739
+
740
+ if (runtimePhase === 'running' || handle.awaitingJsonlTurn) {
741
+ return 'running';
742
+ }
743
+
744
+ return 'idle';
745
+ }
746
+
747
+ private shouldContinueJsonlRefresh(handle: PtyHandle): boolean {
748
+ if (!this.isActiveHandle(handle) || !handle.pty) {
749
+ return false;
750
+ }
751
+
752
+ return handle.awaitingJsonlTurn || handle.runtime.status === 'starting' || handle.runtime.status === 'running';
753
+ }
754
+
755
+ private maybeClearStaleAwaitingTurn(handle: PtyHandle): void {
756
+ if (!handle.awaitingJsonlTurn || !handle.lastUserInputAt) {
757
+ return;
758
+ }
759
+
760
+ const elapsedMs = Date.now() - handle.lastUserInputAt;
761
+ if (elapsedMs < AWAITING_JSONL_TURN_STALE_MS) {
762
+ return;
763
+ }
764
+
765
+ handle.awaitingJsonlTurn = false;
766
+ }
767
+
768
+ private applyStreamingStatus(messages: ChatMessage[], isRunning: boolean): ChatMessage[] {
769
+ if (!isRunning) {
770
+ return messages;
771
+ }
772
+
773
+ const lastAssistantIndex = [...messages].reverse().findIndex((message) => message.role === 'assistant');
774
+ if (lastAssistantIndex < 0) {
775
+ return messages;
776
+ }
777
+
778
+ const targetIndex = messages.length - 1 - lastAssistantIndex;
779
+ const targetMessage = messages[targetIndex];
780
+ if (!targetMessage || targetMessage.status === 'error' || targetMessage.status === 'streaming') {
781
+ return messages;
782
+ }
783
+
784
+ const nextMessages = messages.slice();
785
+ nextMessages[targetIndex] = {
786
+ ...targetMessage,
787
+ status: 'streaming'
788
+ };
789
+ return nextMessages;
790
+ }
791
+
792
+ private selectRecentMessages(messages: ChatMessage[]): ChatMessage[] {
793
+ if (messages.length <= this.options.snapshotMessagesMax) {
794
+ return messages;
795
+ }
796
+ return messages.slice(-this.options.snapshotMessagesMax);
797
+ }
798
+
799
+ private createMessagesUpsertPayload(
800
+ handle: PtyHandle,
801
+ previousMessages: ChatMessage[],
802
+ nextMessages: ChatMessage[],
803
+ previousHasOlderMessages: boolean,
804
+ hasOlderMessages: boolean
805
+ ): Omit<MessagesUpsertPayload, 'cliId'> | null {
806
+ const previousIds = previousMessages.map((message) => message.id);
807
+ const nextIds = nextMessages.map((message) => message.id);
808
+ const idsChanged =
809
+ previousIds.length !== nextIds.length || previousIds.some((messageId, index) => messageId !== nextIds[index]);
810
+ const olderFlagChanged = previousHasOlderMessages !== hasOlderMessages;
811
+
812
+ const previousById = new Map(previousMessages.map((message) => [message.id, message]));
813
+ const upserts = nextMessages.filter((message) => !messageEqual(previousById.get(message.id), message));
814
+
815
+ if (!idsChanged && upserts.length === 0 && !olderFlagChanged) {
816
+ return null;
817
+ }
818
+
819
+ return {
820
+ providerId: this.providerId,
821
+ conversationKey: handle.threadKey,
822
+ sessionId: handle.sessionId,
823
+ upserts: cloneValue(upserts),
824
+ recentMessageIds: nextIds,
825
+ hasOlderMessages
826
+ };
827
+ }
828
+
829
+ private async readJsonlTail(filePath: string, startOffset: number): Promise<{ size: number; text: string }> {
830
+ const stat = await fs.stat(filePath);
831
+ if (startOffset >= stat.size) {
832
+ return {
833
+ text: '',
834
+ size: stat.size
835
+ };
836
+ }
837
+
838
+ const fileHandle = await fs.open(filePath, 'r');
839
+ try {
840
+ const length = stat.size - startOffset;
841
+ const buffer = Buffer.alloc(length);
842
+ await fileHandle.read(buffer, 0, length, startOffset);
843
+ return {
844
+ text: buffer.toString('utf8'),
845
+ size: stat.size
846
+ };
847
+ } finally {
848
+ await fileHandle.close();
849
+ }
850
+ }
851
+
852
+ private async refreshActiveMessages(): Promise<void> {
853
+ const handle = this.getActiveHandle();
854
+ if (!handle) {
855
+ return;
856
+ }
857
+
858
+ await this.refreshMessagesFromJsonl(handle);
859
+ }
860
+
861
+ private async refreshMessagesFromJsonl(handle: PtyHandle): Promise<void> {
862
+ const sessionId = handle.sessionId;
863
+ if (!sessionId) {
864
+ this.closeJsonlWatcher(handle);
865
+ if (this.isActiveHandle(handle) && handle.runtime.status !== 'idle') {
866
+ handle.runtime.status = 'idle';
867
+ this.emitSnapshotNow();
868
+ }
869
+ return;
870
+ }
871
+
872
+ this.ensureJsonlWatcher(handle, sessionId);
873
+
874
+ const filePath = this.resolveSessionJsonlFilePath(handle, sessionId);
875
+ const previousMessages = handle.runtime.messages;
876
+ const previousHasOlderMessages = handle.runtime.hasOlderMessages;
877
+ const previousStatus = handle.runtime.status;
878
+ const previousActivityRevision = handle.jsonlMessagesState.activityRevision;
879
+
880
+ try {
881
+ if (handle.parsedJsonlSessionId !== sessionId) {
882
+ this.resetJsonlParsingState(handle, sessionId);
883
+ handle.runtime.allMessages = [];
884
+ handle.runtime.messages = [];
885
+ handle.runtime.hasOlderMessages = false;
886
+ }
887
+
888
+ const stat = await fs.stat(filePath);
889
+ handle.lastJsonlActivityAt = Math.max(handle.lastJsonlActivityAt ?? 0, Math.floor(stat.mtimeMs));
890
+ handle.jsonlMissingSince = null;
891
+
892
+ if (handle.jsonlReadOffset > stat.size) {
893
+ this.resetJsonlParsingState(handle, sessionId);
894
+ }
895
+
896
+ const { text, size } = await this.readJsonlTail(filePath, handle.jsonlReadOffset);
897
+ if (text) {
898
+ const combined = `${handle.jsonlPendingLine}${text}`;
899
+ const lines = combined.split('\n');
900
+ const trailingLine = lines.pop() ?? '';
901
+
902
+ for (const line of lines) {
903
+ applyClaudeJsonlLine(handle.jsonlMessagesState, line);
904
+ }
905
+
906
+ if (trailingLine.trim() && !applyClaudeJsonlLine(handle.jsonlMessagesState, trailingLine)) {
907
+ handle.jsonlPendingLine = trailingLine;
908
+ } else {
909
+ handle.jsonlPendingLine = '';
910
+ }
911
+ } else if (handle.jsonlPendingLine.trim() && applyClaudeJsonlLine(handle.jsonlMessagesState, handle.jsonlPendingLine)) {
912
+ handle.jsonlPendingLine = '';
913
+ }
914
+
915
+ handle.jsonlReadOffset = size;
916
+ const sawJsonlActivity = handle.jsonlMessagesState.activityRevision !== previousActivityRevision;
917
+ if (sawJsonlActivity) {
918
+ handle.awaitingJsonlTurn = false;
919
+ handle.lastJsonlActivityAt = Date.now();
920
+ }
921
+ this.maybeClearStaleAwaitingTurn(handle);
922
+
923
+ const nextRuntimeStatus = this.resolveRuntimeStatusFromJsonl(handle, handle.jsonlMessagesState.runtimePhase);
924
+ const nextAllMessages = this.applyStreamingStatus(
925
+ materializeClaudeJsonlMessages(handle.jsonlMessagesState),
926
+ handle.jsonlMessagesState.runtimePhase === 'running'
927
+ );
928
+ const nextMessages = this.selectRecentMessages(nextAllMessages);
929
+ const allMessagesChanged = !messagesEqual(handle.runtime.allMessages, nextAllMessages);
930
+ const messagesChanged = !messagesEqual(handle.runtime.messages, nextMessages);
931
+ const hasOlderMessagesChanged = handle.runtime.hasOlderMessages !== (nextAllMessages.length > nextMessages.length);
932
+ const statusChanged = previousStatus !== nextRuntimeStatus;
933
+
934
+ if (allMessagesChanged) {
935
+ handle.runtime.allMessages = nextAllMessages;
936
+ }
937
+ if (messagesChanged) {
938
+ handle.runtime.messages = nextMessages;
939
+ }
940
+ if (allMessagesChanged || messagesChanged || hasOlderMessagesChanged) {
941
+ handle.runtime.hasOlderMessages = nextAllMessages.length > nextMessages.length;
942
+ }
943
+ if (statusChanged) {
944
+ handle.runtime.status = nextRuntimeStatus;
945
+ }
946
+
947
+ if (allMessagesChanged || messagesChanged || hasOlderMessagesChanged || statusChanged) {
948
+ const upsertPayload = this.createMessagesUpsertPayload(
949
+ handle,
950
+ previousMessages,
951
+ nextMessages,
952
+ previousHasOlderMessages,
953
+ nextAllMessages.length > nextMessages.length
954
+ );
955
+ if (upsertPayload) {
956
+ this.callbacks.emitMessagesUpsert(upsertPayload);
957
+ }
958
+ if (this.isActiveHandle(handle)) {
959
+ this.scheduleSnapshotEmit(statusChanged ? 0 : this.options.snapshotEmitDebounceMs);
960
+ }
961
+ }
962
+
963
+ if (this.shouldContinueJsonlRefresh(handle)) {
964
+ this.scheduleJsonlRefresh(Math.max(this.options.jsonlRefreshDebounceMs, 250));
965
+ }
966
+ } catch (error) {
967
+ const code = (error as NodeJS.ErrnoException).code;
968
+ if (code === 'ENOENT') {
969
+ handle.jsonlMissingSince ??= Date.now();
970
+ if (this.shouldContinueJsonlRefresh(handle)) {
971
+ this.scheduleJsonlRefresh(Math.max(this.options.jsonlRefreshDebounceMs, 250));
972
+ }
973
+ return;
974
+ }
975
+ this.log('error', 'failed to refresh messages from claude jsonl', {
976
+ ...this.handleContext(handle),
977
+ code,
978
+ error: errorMessage(error, 'Failed to read Claude jsonl'),
979
+ jsonlReadOffset: handle.jsonlReadOffset
980
+ });
981
+ this.setLastError(handle, error instanceof Error ? error.message : 'Failed to read Claude jsonl');
982
+ }
983
+ }
984
+
985
+ private scheduleJsonlRefresh(delayMs = this.options.jsonlRefreshDebounceMs): void {
986
+ if (this.jsonlRefreshTimer) {
987
+ clearTimeout(this.jsonlRefreshTimer);
988
+ }
989
+
990
+ this.jsonlRefreshTimer = setTimeout(() => {
991
+ this.jsonlRefreshTimer = null;
992
+ const handle = this.getActiveHandle();
993
+ if (!handle) {
994
+ return;
995
+ }
996
+ void this.refreshMessagesFromJsonl(handle);
997
+ }, delayMs);
998
+ }
999
+
1000
+ private scheduleSnapshotEmit(delayMs = this.options.snapshotEmitDebounceMs): void {
1001
+ if (this.snapshotEmitTimer) {
1002
+ clearTimeout(this.snapshotEmitTimer);
1003
+ }
1004
+
1005
+ this.snapshotEmitTimer = setTimeout(() => {
1006
+ this.snapshotEmitTimer = null;
1007
+ this.emitSnapshotNow();
1008
+ }, delayMs);
1009
+ }
1010
+
1011
+ private emitSnapshotNow(): void {
1012
+ this.callbacks.emitSnapshot(this.createRuntimeSnapshot(this.getActiveHandle()));
1013
+ }
1014
+
1015
+ private setStatus(handle: PtyHandle, nextStatus: RuntimeStatus, immediate = false): void {
1016
+ if (handle.runtime.status === nextStatus) {
1017
+ return;
1018
+ }
1019
+
1020
+ handle.runtime.status = nextStatus;
1021
+ if (!this.isActiveHandle(handle)) {
1022
+ return;
1023
+ }
1024
+ if (immediate) {
1025
+ this.emitSnapshotNow();
1026
+ return;
1027
+ }
1028
+ this.scheduleSnapshotEmit();
1029
+ }
1030
+
1031
+ private clearLastError(handle: PtyHandle): void {
1032
+ if (handle.runtime.lastError === null && handle.runtime.status !== 'error') {
1033
+ return;
1034
+ }
1035
+
1036
+ handle.runtime.lastError = null;
1037
+ if (handle.runtime.status === 'error') {
1038
+ handle.runtime.status = this.resolveRuntimeStatusFromJsonl(handle, handle.jsonlMessagesState.runtimePhase);
1039
+ }
1040
+ if (this.isActiveHandle(handle)) {
1041
+ this.emitSnapshotNow();
1042
+ }
1043
+ }
1044
+
1045
+ private setLastError(handle: PtyHandle, nextError: string | null): void {
1046
+ if (handle.runtime.lastError === nextError && (nextError === null || handle.runtime.status === 'error')) {
1047
+ return;
1048
+ }
1049
+
1050
+ handle.runtime.lastError = nextError;
1051
+ if (nextError !== null) {
1052
+ this.log('error', 'runtime entered error state', {
1053
+ ...this.handleContext(handle),
1054
+ lifecycle: handle.lifecycle,
1055
+ previousStatus: handle.runtime.status,
1056
+ runtimeError: nextError
1057
+ });
1058
+ handle.runtime.status = 'error';
1059
+ handle.lifecycle = 'error';
1060
+ }
1061
+ if (this.isActiveHandle(handle)) {
1062
+ this.emitSnapshotNow();
1063
+ }
1064
+ }
1065
+
1066
+ private detachHandle(handle: PtyHandle): void {
1067
+ this.closeJsonlWatcher(handle);
1068
+ handle.lifecycle = handle.pty ? 'detached' : 'exited';
1069
+ handle.detachedAt = Date.now();
1070
+ this.log('info', handle.pty ? 'detached claude handle and kept pty cached' : 'detached claude handle without cached pty', {
1071
+ ...this.handleContext(handle),
1072
+ detachedAt: handle.detachedAt,
1073
+ runtimeStatus: handle.runtime.status
1074
+ });
1075
+ if (this.activeThreadKey === handle.threadKey) {
1076
+ this.activeThreadKey = null;
1077
+ }
1078
+ this.pruneInactiveHandleState(handle);
1079
+ if (handle.pty) {
1080
+ this.ensureJsonlWatcher(handle, handle.sessionId);
1081
+ }
1082
+ if (this.jsonlRefreshTimer) {
1083
+ clearTimeout(this.jsonlRefreshTimer);
1084
+ this.jsonlRefreshTimer = null;
1085
+ }
1086
+ if (!handle.pty) {
1087
+ this.discardInactiveHandle(handle);
1088
+ }
1089
+ }
1090
+
1091
+ private destroyHandle(handle: PtyHandle, reason: string): void {
1092
+ const wasActive = this.isActiveHandle(handle);
1093
+ const hadPty = handle.pty !== null;
1094
+ this.log('info', 'destroying claude handle', {
1095
+ ...this.handleContext(handle),
1096
+ destroyReason: reason,
1097
+ hadPty,
1098
+ wasActive,
1099
+ lifecycle: handle.lifecycle,
1100
+ runtimeStatus: handle.runtime.status
1101
+ });
1102
+ if (wasActive) {
1103
+ this.activeThreadKey = null;
1104
+ this.currentCwd = this.options.defaultCwd;
1105
+ }
1106
+
1107
+ this.closeJsonlWatcher(handle);
1108
+ this.stopHandlePty(handle, reason);
1109
+ if (!hadPty) {
1110
+ this.emitTerminalSessionEvicted(handle, reason);
1111
+ }
1112
+ handle.terminalFrame.dispose();
1113
+ this.handles.delete(handle.threadKey);
1114
+
1115
+ if (wasActive) {
1116
+ if (this.jsonlRefreshTimer) {
1117
+ clearTimeout(this.jsonlRefreshTimer);
1118
+ this.jsonlRefreshTimer = null;
1119
+ }
1120
+ this.emitSnapshotNow();
1121
+ }
1122
+ }
1123
+
1124
+ private resetTerminalReplay(handle: PtyHandle): void {
1125
+ handle.runtime.recentTerminalOutput = '';
1126
+ const patch = handle.terminalFrame.reset(handle.sessionId);
1127
+ if (handle.pty) {
1128
+ handle.pty.recentOutput = '';
1129
+ }
1130
+ if (this.isActiveHandle(handle)) {
1131
+ this.emitTerminalFramePatch(handle, patch);
1132
+ }
1133
+ }
1134
+
1135
+ private stopHandlePty(handle: PtyHandle, reason: string, details?: Record<string, unknown>): void {
1136
+ const currentPty = handle.pty;
1137
+ if (!currentPty) {
1138
+ return;
1139
+ }
1140
+
1141
+ this.log('info', 'stopping claude pty session', {
1142
+ ...this.handleContext(handle),
1143
+ stopReason: reason,
1144
+ lifecycle: handle.lifecycle,
1145
+ runtimeStatus: handle.runtime.status,
1146
+ ...details
1147
+ });
1148
+ handle.suppressNextPtyExitError = true;
1149
+ handle.expectedPtyExitReason = reason;
1150
+ handle.pty = null;
1151
+ handle.awaitingJsonlTurn = false;
1152
+ this.emitTerminalSessionEvicted(handle, reason);
1153
+ this.resetTerminalReplay(handle);
1154
+ stopClaudePtySession(currentPty);
1155
+ this.discardInactiveHandle(handle, { emitTerminalSessionEvicted: false });
1156
+ }
1157
+
1158
+ private stopHandlePtyPreservingReplay(handle: PtyHandle, reason: string, details?: Record<string, unknown>): void {
1159
+ const currentPty = handle.pty;
1160
+ if (!currentPty) {
1161
+ return;
1162
+ }
1163
+
1164
+ this.log('info', 'stopping claude pty session and preserving replay', {
1165
+ ...this.handleContext(handle),
1166
+ stopReason: reason,
1167
+ lifecycle: handle.lifecycle,
1168
+ runtimeStatus: handle.runtime.status,
1169
+ ...details
1170
+ });
1171
+ handle.suppressNextPtyExitError = true;
1172
+ handle.expectedPtyExitReason = reason;
1173
+ handle.pty = null;
1174
+ handle.awaitingJsonlTurn = false;
1175
+ stopClaudePtySession(currentPty);
1176
+ }
1177
+
1178
+ private startHandleSession(handle: PtyHandle, options?: { emitSnapshot?: boolean }): void {
1179
+ this.resetTerminalReplay(handle);
1180
+ handle.suppressNextPtyExitError = false;
1181
+ handle.expectedPtyExitReason = null;
1182
+ const token = ++handle.ptyToken;
1183
+ this.log('info', 'starting claude pty session', {
1184
+ ...this.handleContext(handle),
1185
+ cols: this.terminalSize.cols,
1186
+ rows: this.terminalSize.rows,
1187
+ token
1188
+ });
1189
+ const started = startClaudePtySession({
1190
+ claudeBin: this.options.claudeBin,
1191
+ cols: this.terminalSize.cols,
1192
+ cwd: handle.cwd,
1193
+ env: {
1194
+ ...process.env,
1195
+ TERM: 'xterm-256color'
1196
+ },
1197
+ permissionMode: this.options.permissionMode,
1198
+ resumeSessionId: handle.sessionId,
1199
+ rows: this.terminalSize.rows,
1200
+ onData: (chunk) => {
1201
+ if (token !== handle.ptyToken) {
1202
+ return;
1203
+ }
1204
+ this.handlePtyData(handle, chunk);
1205
+ },
1206
+ onExit: () => {
1207
+ if (token !== handle.ptyToken) {
1208
+ return;
1209
+ }
1210
+ void this.handlePtyExit(handle);
1211
+ }
1212
+ });
1213
+
1214
+ handle.pty = started.session;
1215
+ handle.sessionId = started.sessionId;
1216
+ handle.runtime.sessionId = started.sessionId;
1217
+ const framePatch = handle.terminalFrame.reset(started.sessionId);
1218
+ handle.runtime.status = 'starting';
1219
+ handle.lifecycle = this.isActiveHandle(handle) ? 'attached' : 'detached';
1220
+ if (this.isActiveHandle(handle)) {
1221
+ this.emitTerminalFramePatch(handle, framePatch);
1222
+ this.ensureJsonlWatcher(handle, started.sessionId);
1223
+ if (options?.emitSnapshot !== false) {
1224
+ this.emitSnapshotNow();
1225
+ }
1226
+ this.scheduleJsonlRefresh(0);
1227
+ }
1228
+ }
1229
+
1230
+ private async ensureHandleSession(handle: PtyHandle): Promise<void> {
1231
+ if (handle.pty) {
1232
+ return;
1233
+ }
1234
+
1235
+ this.clearLastError(handle);
1236
+ this.startHandleSession(handle);
1237
+ }
1238
+
1239
+ private handlePtyData(handle: PtyHandle, chunk: string): void {
1240
+ const session = handle.pty;
1241
+ if (!session) {
1242
+ return;
1243
+ }
1244
+
1245
+ handle.runtime.recentTerminalOutput = appendRecentOutput(session, chunk, this.options.recentOutputMaxChars);
1246
+ handle.lastTerminalActivityAt = Date.now();
1247
+ const isActiveHandle = this.isActiveHandle(handle);
1248
+
1249
+ void handle.terminalFrame
1250
+ .enqueueOutput(chunk)
1251
+ .then((patch) => {
1252
+ if (patch) {
1253
+ this.emitTerminalFramePatch(handle, patch);
1254
+ }
1255
+ })
1256
+ .catch((error) => {
1257
+ if (this.isActiveHandle(handle)) {
1258
+ this.setLastError(handle, errorMessage(error, 'Failed to materialize terminal frame'));
1259
+ return;
1260
+ }
1261
+
1262
+ this.log('error', 'failed to materialize detached claude terminal frame', {
1263
+ ...this.handleContext(handle),
1264
+ error: errorMessage(error, 'Failed to materialize terminal frame')
1265
+ });
1266
+ });
1267
+ if (isActiveHandle) {
1268
+ this.scheduleJsonlRefresh();
1269
+ }
1270
+ }
1271
+
1272
+ private emitTerminalFramePatch(handle: PtyHandle, patch: TerminalFramePatchPayload['patch']): void {
1273
+ this.callbacks.emitTerminalFramePatch({
1274
+ conversationKey: handle.threadKey,
1275
+ patch
1276
+ });
1277
+ }
1278
+
1279
+ private async handlePtyExit(handle: PtyHandle): Promise<void> {
1280
+ const expectedExit = handle.suppressNextPtyExitError;
1281
+ const expectedExitReason = handle.expectedPtyExitReason;
1282
+ handle.suppressNextPtyExitError = false;
1283
+ handle.expectedPtyExitReason = null;
1284
+ handle.pty = null;
1285
+ handle.awaitingJsonlTurn = false;
1286
+ handle.lifecycle = 'exited';
1287
+ this.closeJsonlWatcher(handle);
1288
+
1289
+ if (!this.isActiveHandle(handle)) {
1290
+ if (expectedExit) {
1291
+ this.log('info', 'inactive claude pty exited after requested stop', {
1292
+ ...this.handleContext(handle),
1293
+ stopReason: expectedExitReason ?? 'unknown',
1294
+ runtimeStatus: handle.runtime.status
1295
+ });
1296
+ }
1297
+ if (!expectedExit) {
1298
+ this.log('error', 'inactive claude pty exited unexpectedly', {
1299
+ ...this.handleContext(handle),
1300
+ recentOutputTail: tailForLog(handle.runtime.recentTerminalOutput),
1301
+ runtimeStatus: handle.runtime.status
1302
+ });
1303
+ handle.runtime.lastError = 'Claude CLI exited unexpectedly';
1304
+ }
1305
+ try {
1306
+ await this.refreshMessagesFromJsonl(handle);
1307
+ } catch (error) {
1308
+ this.log('error', 'failed to finalize detached claude messages after pty exit', {
1309
+ ...this.handleContext(handle),
1310
+ error: errorMessage(error, 'Failed to finalize detached messages')
1311
+ });
1312
+ }
1313
+ this.discardInactiveHandle(handle);
1314
+ return;
1315
+ }
1316
+
1317
+ this.scheduleJsonlRefresh(0);
1318
+ if (expectedExit) {
1319
+ this.log('info', 'active claude pty exited after requested stop', {
1320
+ ...this.handleContext(handle),
1321
+ stopReason: expectedExitReason ?? 'unknown',
1322
+ runtimeStatus: handle.runtime.status
1323
+ });
1324
+ this.setStatus(handle, 'idle', true);
1325
+ return;
1326
+ }
1327
+ this.log('error', 'active claude pty exited unexpectedly', {
1328
+ ...this.handleContext(handle),
1329
+ recentOutputTail: tailForLog(handle.runtime.recentTerminalOutput),
1330
+ runtimeStatus: handle.runtime.status
1331
+ });
1332
+ this.setLastError(handle, 'Claude CLI exited unexpectedly');
1333
+ }
1334
+
1335
+ private async autoAcceptBypassPrompt(handle: PtyHandle): Promise<boolean> {
1336
+ if (!handle.pty) {
1337
+ return false;
1338
+ }
1339
+
1340
+ if (!looksLikeBypassPrompt(handle.pty.recentOutput)) {
1341
+ return false;
1342
+ }
1343
+
1344
+ handle.pty.pty.write('\x1b[B');
1345
+ await sleep(120);
1346
+ handle.pty.pty.write('\r');
1347
+ await sleep(320);
1348
+ return true;
1349
+ }
1350
+
1351
+ private async waitForHandleReady(handle: PtyHandle): Promise<void> {
1352
+ const deadline = Date.now() + this.options.claudeReadyTimeoutMs;
1353
+
1354
+ while (Date.now() < deadline) {
1355
+ if (!handle.pty) {
1356
+ throw new Error('Claude PTY session is not running');
1357
+ }
1358
+
1359
+ const currentText = handle.pty.recentOutput;
1360
+ if (looksReadyForInput(currentText)) {
1361
+ return;
1362
+ }
1363
+
1364
+ if (await this.autoAcceptBypassPrompt(handle)) {
1365
+ continue;
1366
+ }
1367
+
1368
+ await sleep(250);
1369
+ }
1370
+
1371
+ this.log('error', 'claude startup timed out waiting for ready prompt', {
1372
+ ...this.handleContext(handle),
1373
+ readyTimeoutMs: this.options.claudeReadyTimeoutMs,
1374
+ recentOutputTail: tailForLog(handle.pty?.recentOutput)
1375
+ });
1376
+ throw new Error('Claude CLI startup timed out');
1377
+ }
1378
+
1379
+ private async sendPromptToHandle(handle: PtyHandle, content: string): Promise<void> {
1380
+ if (!handle.pty) {
1381
+ throw new Error('Claude PTY session is not running');
1382
+ }
1383
+
1384
+ const needsPromptRefocus = /⏵⏵\s*bypass permissions on/i.test(handle.pty.recentOutput);
1385
+ if (needsPromptRefocus) {
1386
+ // Claude can leave keyboard focus on the bypass-permissions toggle after a turn.
1387
+ // Shift+Tab cycles focus back to the prompt input before typing.
1388
+ handle.pty.pty.write('\x1b[Z');
1389
+ await sleep(120);
1390
+ }
1391
+
1392
+ const normalizedContent = content.replace(/\r\n/g, '\n');
1393
+ if (normalizedContent.includes('\n')) {
1394
+ handle.pty.pty.write('\x1b[200~');
1395
+ handle.pty.pty.write(normalizedContent);
1396
+ handle.pty.pty.write('\x1b[201~');
1397
+ } else {
1398
+ handle.pty.pty.write(normalizedContent);
1399
+ }
1400
+
1401
+ await sleep(this.options.promptSubmitDelayMs);
1402
+ handle.pty.pty.write('\r');
1403
+ }
1404
+
1405
+ private getLastActivityAt(handle: PtyHandle): number {
1406
+ return Math.max(
1407
+ handle.lastJsonlActivityAt ?? 0,
1408
+ handle.lastTerminalActivityAt ?? 0,
1409
+ handle.lastUserInputAt ?? 0,
1410
+ handle.detachedAt ?? 0
1411
+ );
1412
+ }
1413
+
1414
+ private pruneInactiveHandleState(handle: PtyHandle): void {
1415
+ if (this.isActiveHandle(handle)) {
1416
+ return;
1417
+ }
1418
+ if (handle.pty) {
1419
+ return;
1420
+ }
1421
+
1422
+ handle.runtime.allMessages = [];
1423
+ handle.runtime.messages = [];
1424
+ handle.runtime.hasOlderMessages = false;
1425
+ handle.awaitingJsonlTurn = false;
1426
+ this.resetJsonlParsingState(handle, handle.sessionId);
1427
+ }
1428
+
1429
+ private emitTerminalSessionEvicted(handle: PtyHandle, reason: string): void {
1430
+ const sessionId = handle.sessionId?.trim();
1431
+ if (!sessionId) {
1432
+ return;
1433
+ }
1434
+ this.callbacks.emitTerminalSessionEvicted({
1435
+ conversationKey: handle.threadKey,
1436
+ reason,
1437
+ sessionId
1438
+ });
1439
+ }
1440
+
1441
+ private discardInactiveHandle(handle: PtyHandle, options: { emitTerminalSessionEvicted?: boolean } = {}): void {
1442
+ if (this.isActiveHandle(handle) || handle.pty) {
1443
+ return;
1444
+ }
1445
+
1446
+ this.closeJsonlWatcher(handle);
1447
+ handle.lifecycle = 'exited';
1448
+ if (options.emitTerminalSessionEvicted !== false) {
1449
+ this.emitTerminalSessionEvicted(handle, 'discard-inactive-handle');
1450
+ }
1451
+ handle.terminalFrame.dispose();
1452
+ this.handles.delete(handle.threadKey);
1453
+ }
1454
+
1455
+ private async gcDetachedHandles(): Promise<void> {
1456
+ const now = Date.now();
1457
+ const detachedHandles = [...this.handles.values()].filter((handle) => handle.lifecycle === 'detached' && handle.pty);
1458
+
1459
+ for (const handle of detachedHandles) {
1460
+ if (handle.detachedAt && now - handle.detachedAt >= this.options.detachedPtyTtlMs) {
1461
+ this.stopHandlePty(handle, 'gc-detached-pty-ttl', {
1462
+ detachedForMs: now - handle.detachedAt
1463
+ });
1464
+ continue;
1465
+ }
1466
+
1467
+ if (!handle.sessionId) {
1468
+ if (handle.detachedAt && now - handle.detachedAt >= this.options.detachedDraftTtlMs) {
1469
+ this.stopHandlePty(handle, 'gc-detached-draft-ttl', {
1470
+ detachedForMs: now - handle.detachedAt
1471
+ });
1472
+ }
1473
+ continue;
1474
+ }
1475
+
1476
+ const filePath = this.resolveSessionJsonlFilePath(handle, handle.sessionId);
1477
+ try {
1478
+ const stat = await fs.stat(filePath);
1479
+ handle.lastJsonlActivityAt = Math.max(handle.lastJsonlActivityAt ?? 0, Math.floor(stat.mtimeMs));
1480
+ handle.jsonlMissingSince = null;
1481
+ } catch (error) {
1482
+ const code = (error as NodeJS.ErrnoException).code;
1483
+ if (code !== 'ENOENT') {
1484
+ continue;
1485
+ }
1486
+ handle.jsonlMissingSince ??= now;
1487
+ if (now - handle.jsonlMissingSince >= this.options.detachedJsonlMissingTtlMs) {
1488
+ this.stopHandlePty(handle, 'gc-detached-jsonl-missing-ttl', {
1489
+ jsonlMissingForMs: now - handle.jsonlMissingSince
1490
+ });
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ const survivors = [...this.handles.values()]
1496
+ .filter((handle) => handle.lifecycle === 'detached' && handle.pty)
1497
+ .sort((left, right) => this.getLastActivityAt(left) - this.getLastActivityAt(right));
1498
+
1499
+ while (survivors.length > this.options.maxDetachedPtys) {
1500
+ const victim = survivors.shift();
1501
+ if (!victim) {
1502
+ break;
1503
+ }
1504
+ this.stopHandlePty(victim, 'gc-max-detached-ptys', {
1505
+ maxDetachedPtys: this.options.maxDetachedPtys
1506
+ });
1507
+ }
1508
+ }
1509
+ }