@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.
- package/bin/pty-remote-cli.js +24 -0
- package/cli.conf +51 -0
- package/codex_template.jsonl +1 -0
- package/package.json +45 -0
- package/scripts/ensure-node-pty-helper.js +24 -0
- package/src/attachments/manager.ts +196 -0
- package/src/cli/cli-config.ts +58 -0
- package/src/cli/client.ts +674 -0
- package/src/cli/jsonl.ts +483 -0
- package/src/cli/pty-manager.ts +1509 -0
- package/src/cli/pty.ts +162 -0
- package/src/cli-main.ts +18 -0
- package/src/project-history.ts +175 -0
- package/src/providers/claude-history.ts +124 -0
- package/src/providers/claude.ts +66 -0
- package/src/providers/codex-history.ts +390 -0
- package/src/providers/codex-jsonl.ts +604 -0
- package/src/providers/codex-manager.ts +1662 -0
- package/src/providers/codex-pty.ts +144 -0
- package/src/providers/codex-resume-session.ts +253 -0
- package/src/providers/codex.ts +67 -0
- package/src/providers/provider-runtime.ts +58 -0
- package/src/providers/slash-commands.ts +115 -0
- package/src/terminal/frame-state.ts +457 -0
- package/src/threads-cli.ts +164 -0
|
@@ -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
|
+
}
|