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