@love-moon/tui-driver 0.2.12 → 0.2.14
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/dist/driver/TuiDriver.d.ts +104 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -1
- package/dist/driver/TuiDriver.js +1215 -8
- package/dist/driver/TuiDriver.js.map +1 -1
- package/dist/driver/index.d.ts +2 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/profiles/copilot.profile.js +10 -10
- package/dist/driver/profiles/copilot.profile.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pty/PtySession.d.ts +1 -0
- package/dist/pty/PtySession.d.ts.map +1 -1
- package/dist/pty/PtySession.js +9 -0
- package/dist/pty/PtySession.js.map +1 -1
- package/docs/how-to-add-a-new-backend.md +212 -0
- package/package.json +1 -1
- package/src/driver/TuiDriver.ts +1640 -124
- package/src/driver/index.ts +14 -1
- package/src/driver/profiles/copilot.profile.ts +10 -10
- package/src/index.ts +4 -0
- package/src/pty/PtySession.ts +10 -0
- package/test/backend-session-discovery.test.ts +134 -0
- package/test/codex-session-discovery.test.ts +112 -0
- package/test/copilot-profile.test.ts +12 -0
- package/test/copilot-signals.test.ts +14 -0
- package/test/session-file-extraction.test.ts +562 -0
- package/test/timeout-resolution.test.ts +37 -0
package/dist/driver/TuiDriver.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TuiDriver = void 0;
|
|
4
4
|
const events_1 = require("events");
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_os_1 = require("node:os");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const node_util_1 = require("node:util");
|
|
5
10
|
const PtySession_js_1 = require("../pty/PtySession.js");
|
|
6
11
|
const HeadlessScreen_js_1 = require("../term/HeadlessScreen.js");
|
|
7
12
|
const ScreenSnapshot_js_1 = require("../term/ScreenSnapshot.js");
|
|
@@ -11,6 +16,13 @@ const OutputExtractor_js_1 = require("../extract/OutputExtractor.js");
|
|
|
11
16
|
const Diff_js_1 = require("../extract/Diff.js");
|
|
12
17
|
const StateMachine_js_1 = require("./StateMachine.js");
|
|
13
18
|
const index_js_1 = require("./behavior/index.js");
|
|
19
|
+
const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
|
|
20
|
+
const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
|
|
21
|
+
const MIN_STAGE_TIMEOUT_MS = 100;
|
|
22
|
+
const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
23
|
+
const DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS = 30_000;
|
|
24
|
+
const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
25
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
14
26
|
class TuiDriver extends events_1.EventEmitter {
|
|
15
27
|
pty;
|
|
16
28
|
screen;
|
|
@@ -23,6 +35,16 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
23
35
|
onSignals;
|
|
24
36
|
isBooted = false;
|
|
25
37
|
isKilled = false;
|
|
38
|
+
sessionCwd;
|
|
39
|
+
hasExplicitSessionCwd;
|
|
40
|
+
expectedSessionId;
|
|
41
|
+
sessionDetectStartSec;
|
|
42
|
+
sessionDetectBaselineIds;
|
|
43
|
+
sessionInfo = null;
|
|
44
|
+
lastSessionInfo = null;
|
|
45
|
+
sessionUsageCache = null;
|
|
46
|
+
initialCommand;
|
|
47
|
+
initialArgs;
|
|
26
48
|
constructor(options) {
|
|
27
49
|
super();
|
|
28
50
|
this.profile = options.profile;
|
|
@@ -30,10 +52,17 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
30
52
|
this.debug = options.debug ?? false;
|
|
31
53
|
this.onSnapshot = options.onSnapshot;
|
|
32
54
|
this.onSignals = options.onSignals;
|
|
55
|
+
this.hasExplicitSessionCwd = options.cwd !== undefined;
|
|
56
|
+
this.sessionCwd = options.cwd ?? process.cwd();
|
|
57
|
+
this.expectedSessionId = String(options.expectedSessionId || "").trim();
|
|
58
|
+
this.sessionDetectStartSec = Math.floor(Date.now() / 1000);
|
|
59
|
+
this.sessionDetectBaselineIds = [];
|
|
60
|
+
this.initialCommand = this.profile.command;
|
|
61
|
+
this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
|
|
33
62
|
const cols = this.profile.cols ?? 120;
|
|
34
63
|
const rows = this.profile.rows ?? 40;
|
|
35
64
|
const scrollback = this.profile.scrollback ?? 5000;
|
|
36
|
-
this.pty = new PtySession_js_1.PtySession(this.
|
|
65
|
+
this.pty = new PtySession_js_1.PtySession(this.initialCommand, this.initialArgs, { cols, rows, env: this.profile.env, cwd: this.sessionCwd });
|
|
37
66
|
this.screen = new HeadlessScreen_js_1.HeadlessScreen({
|
|
38
67
|
cols,
|
|
39
68
|
rows,
|
|
@@ -54,6 +83,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
54
83
|
this.pty.onExit((code, signal) => {
|
|
55
84
|
this.log(`PTY exited: code=${code}, signal=${signal}`);
|
|
56
85
|
this.isBooted = false;
|
|
86
|
+
this.sessionInfo = null;
|
|
57
87
|
this.emit("exit", code, signal);
|
|
58
88
|
});
|
|
59
89
|
this.stateMachine.on("stateChange", (transition) => {
|
|
@@ -76,6 +106,74 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
76
106
|
get running() {
|
|
77
107
|
return this.pty.isRunning;
|
|
78
108
|
}
|
|
109
|
+
getSessionInfo() {
|
|
110
|
+
const current = this.sessionInfo ?? this.lastSessionInfo;
|
|
111
|
+
return current ? { ...current } : null;
|
|
112
|
+
}
|
|
113
|
+
async ensureSessionInfo(timeoutMs = DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS) {
|
|
114
|
+
if (!this.supportsSessionFileTracking()) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (this.sessionInfo) {
|
|
118
|
+
return { ...this.sessionInfo };
|
|
119
|
+
}
|
|
120
|
+
const boundedTimeoutMs = this.resolveTimeout(timeoutMs, DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS);
|
|
121
|
+
const discovered = await this.discoverSessionInfo(boundedTimeoutMs);
|
|
122
|
+
return discovered ? { ...discovered } : null;
|
|
123
|
+
}
|
|
124
|
+
async getSessionUsageSummary() {
|
|
125
|
+
if (!this.supportsSessionFileTracking()) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const sessionInfo = this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
|
|
129
|
+
if (!sessionInfo) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
133
|
+
if (this.sessionUsageCache &&
|
|
134
|
+
this.sessionUsageCache.backend === sessionInfo.backend &&
|
|
135
|
+
this.sessionUsageCache.sessionId === sessionInfo.sessionId &&
|
|
136
|
+
this.sessionUsageCache.sessionFilePath === sessionInfo.sessionFilePath &&
|
|
137
|
+
this.sessionUsageCache.size === size &&
|
|
138
|
+
this.sessionUsageCache.mtimeMs === mtimeMs) {
|
|
139
|
+
return { ...this.sessionUsageCache.summary };
|
|
140
|
+
}
|
|
141
|
+
const lines = await this.readSessionFileJsonLines(sessionInfo.sessionFilePath, 0);
|
|
142
|
+
const summary = this.extractSessionUsageSummaryFromJsonLines(lines, sessionInfo);
|
|
143
|
+
this.sessionUsageCache = {
|
|
144
|
+
backend: sessionInfo.backend,
|
|
145
|
+
sessionId: sessionInfo.sessionId,
|
|
146
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
147
|
+
size,
|
|
148
|
+
mtimeMs,
|
|
149
|
+
summary,
|
|
150
|
+
};
|
|
151
|
+
return { ...summary };
|
|
152
|
+
}
|
|
153
|
+
async getSessionFileSize(sessionInfo) {
|
|
154
|
+
const resolvedSessionInfo = sessionInfo ?? this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
|
|
155
|
+
if (!resolvedSessionInfo) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
const { size } = await this.readSessionFileStat(resolvedSessionInfo.sessionFilePath);
|
|
159
|
+
return size;
|
|
160
|
+
}
|
|
161
|
+
async readSessionAssistantMessagesSince(sessionInfo, startOffset = 0) {
|
|
162
|
+
const resolvedSessionInfo = {
|
|
163
|
+
backend: String(sessionInfo?.backend || "").trim(),
|
|
164
|
+
sessionId: String(sessionInfo?.sessionId || "").trim(),
|
|
165
|
+
sessionFilePath: String(sessionInfo?.sessionFilePath || "").trim(),
|
|
166
|
+
};
|
|
167
|
+
const readResult = await this.readSessionFileLinesSince(resolvedSessionInfo.sessionFilePath, startOffset);
|
|
168
|
+
return {
|
|
169
|
+
backend: resolvedSessionInfo.backend,
|
|
170
|
+
sessionId: resolvedSessionInfo.sessionId,
|
|
171
|
+
sessionFilePath: resolvedSessionInfo.sessionFilePath,
|
|
172
|
+
nextOffset: readResult.nextOffset,
|
|
173
|
+
fileSize: readResult.fileSize,
|
|
174
|
+
messages: this.extractAssistantMessagesFromJsonLines(readResult.lines, resolvedSessionInfo),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
79
177
|
async boot() {
|
|
80
178
|
if (this.isKilled) {
|
|
81
179
|
throw this.createSessionClosedError();
|
|
@@ -84,6 +182,9 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
84
182
|
return;
|
|
85
183
|
}
|
|
86
184
|
this.stateMachine.transition("BOOT");
|
|
185
|
+
this.sessionInfo = null;
|
|
186
|
+
this.sessionDetectStartSec = Math.floor(Date.now() / 1000);
|
|
187
|
+
this.sessionDetectBaselineIds = await this.captureSessionBaseline();
|
|
87
188
|
this.pty.spawn();
|
|
88
189
|
const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
|
|
89
190
|
const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
|
|
@@ -168,6 +269,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
168
269
|
}
|
|
169
270
|
this.isBooted = true;
|
|
170
271
|
this.stateMachine.transition("WAIT_READY");
|
|
272
|
+
await this.ensureSessionInfo();
|
|
171
273
|
this.captureSnapshot("boot_complete");
|
|
172
274
|
}
|
|
173
275
|
async ensureReady() {
|
|
@@ -194,6 +296,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
194
296
|
}
|
|
195
297
|
async ask(prompt) {
|
|
196
298
|
const startTime = Date.now();
|
|
299
|
+
let sessionInfo = null;
|
|
197
300
|
try {
|
|
198
301
|
await this.ensureReady();
|
|
199
302
|
// 健康检查:在执行前检测异常状态
|
|
@@ -210,6 +313,17 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
210
313
|
throw error;
|
|
211
314
|
}
|
|
212
315
|
}
|
|
316
|
+
else if (health.reason === "process_exited") {
|
|
317
|
+
this.log("Health check detected exited process, attempting forced restart");
|
|
318
|
+
await this.restart();
|
|
319
|
+
const healthAfterRestart = this.healthCheck();
|
|
320
|
+
if (!healthAfterRestart.healthy) {
|
|
321
|
+
const error = new Error(`Cannot proceed: ${healthAfterRestart.message || healthAfterRestart.reason}`);
|
|
322
|
+
error.reason = healthAfterRestart.reason;
|
|
323
|
+
error.matchedPattern = healthAfterRestart.matchedPattern;
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
213
327
|
else if (health.reason === "login_required") {
|
|
214
328
|
const error = new Error(`Cannot proceed: ${health.message}`);
|
|
215
329
|
error.reason = health.reason;
|
|
@@ -230,6 +344,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
230
344
|
}
|
|
231
345
|
}
|
|
232
346
|
}
|
|
347
|
+
sessionInfo = await this.ensureSessionInfo();
|
|
233
348
|
this.stateMachine.transition("PREPARE_TURN");
|
|
234
349
|
await this.prepareTurn();
|
|
235
350
|
this.stateMachine.transition("TYPE_PROMPT");
|
|
@@ -240,13 +355,39 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
240
355
|
await this.submit();
|
|
241
356
|
// 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
|
|
242
357
|
const beforeSnapshot = this.captureSnapshot("after_submit");
|
|
358
|
+
const sessionCheckpoint = await this.resolveSessionFileCheckpointForTurn(sessionInfo);
|
|
359
|
+
if (sessionCheckpoint) {
|
|
360
|
+
sessionInfo = sessionCheckpoint.sessionInfo;
|
|
361
|
+
}
|
|
362
|
+
if (!sessionCheckpoint && this.requiresSessionFileCheckpointForTurn()) {
|
|
363
|
+
if (this.profile.name === "copilot") {
|
|
364
|
+
throw new Error("Copilot session file checkpoint unavailable");
|
|
365
|
+
}
|
|
366
|
+
throw new Error("Codex session file checkpoint unavailable");
|
|
367
|
+
}
|
|
243
368
|
this.stateMachine.transition("WAIT_STREAM_START");
|
|
244
|
-
|
|
369
|
+
if (sessionCheckpoint) {
|
|
370
|
+
await this.waitForSessionFileGrowth(sessionCheckpoint);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
await this.waitStreamStart(preSubmitSnapshot);
|
|
374
|
+
}
|
|
245
375
|
this.stateMachine.transition("WAIT_STREAM_END");
|
|
246
|
-
|
|
376
|
+
if (sessionCheckpoint) {
|
|
377
|
+
await this.waitForSessionFileIdle(sessionCheckpoint);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
await this.waitStreamEnd(beforeSnapshot);
|
|
381
|
+
}
|
|
247
382
|
this.stateMachine.transition("CAPTURE");
|
|
248
383
|
const afterSnapshot = this.captureSnapshot("after_response");
|
|
249
|
-
|
|
384
|
+
let answer = "";
|
|
385
|
+
if (sessionCheckpoint) {
|
|
386
|
+
answer = await this.extractAnswerFromSessionFile(sessionCheckpoint);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
|
|
390
|
+
}
|
|
250
391
|
const signals = this.getSignals(afterSnapshot);
|
|
251
392
|
this.stateMachine.transition("DONE");
|
|
252
393
|
return {
|
|
@@ -262,6 +403,8 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
262
403
|
replyInProgress: signals.replyInProgress,
|
|
263
404
|
statusLine: signals.statusLine,
|
|
264
405
|
statusDoneLine: signals.statusDoneLine,
|
|
406
|
+
sessionId: sessionInfo?.sessionId,
|
|
407
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
265
408
|
};
|
|
266
409
|
}
|
|
267
410
|
catch (error) {
|
|
@@ -281,6 +424,8 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
281
424
|
replyInProgress: signals.replyInProgress,
|
|
282
425
|
statusLine: signals.statusLine,
|
|
283
426
|
statusDoneLine: signals.statusDoneLine,
|
|
427
|
+
sessionId: sessionInfo?.sessionId,
|
|
428
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
284
429
|
};
|
|
285
430
|
}
|
|
286
431
|
}
|
|
@@ -296,6 +441,965 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
296
441
|
async submit() {
|
|
297
442
|
await this.pty.sendKeys(this.profile.keys.submit, 50);
|
|
298
443
|
}
|
|
444
|
+
supportsSessionFileTracking() {
|
|
445
|
+
const backend = String(this.profile.name || "").toLowerCase();
|
|
446
|
+
return backend === "codex" || backend === "claude-code" || backend === "copilot";
|
|
447
|
+
}
|
|
448
|
+
async discoverSessionInfo(timeoutMs) {
|
|
449
|
+
const startedAt = Date.now();
|
|
450
|
+
const deadline = startedAt + Math.max(MIN_STAGE_TIMEOUT_MS, timeoutMs);
|
|
451
|
+
while (Date.now() < deadline) {
|
|
452
|
+
this.assertAliveOrThrow();
|
|
453
|
+
const discovered = await this.detectSessionInfoByBackend();
|
|
454
|
+
if (discovered) {
|
|
455
|
+
const changed = !this.sessionInfo ||
|
|
456
|
+
this.sessionInfo.sessionId !== discovered.sessionId ||
|
|
457
|
+
this.sessionInfo.sessionFilePath !== discovered.sessionFilePath;
|
|
458
|
+
this.sessionInfo = discovered;
|
|
459
|
+
this.lastSessionInfo = discovered;
|
|
460
|
+
if (changed) {
|
|
461
|
+
this.emit("session", { ...discovered });
|
|
462
|
+
this.log(`session discovered: id=${discovered.sessionId} file=${discovered.sessionFilePath}`);
|
|
463
|
+
}
|
|
464
|
+
return discovered;
|
|
465
|
+
}
|
|
466
|
+
await this.sleep(250);
|
|
467
|
+
}
|
|
468
|
+
return this.sessionInfo ? { ...this.sessionInfo } : null;
|
|
469
|
+
}
|
|
470
|
+
async detectSessionInfoByBackend() {
|
|
471
|
+
if (!this.supportsSessionFileTracking()) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
switch (this.profile.name) {
|
|
476
|
+
case "codex":
|
|
477
|
+
return this.detectCodexSessionInfo();
|
|
478
|
+
case "claude-code":
|
|
479
|
+
return this.detectClaudeSessionInfo();
|
|
480
|
+
case "copilot":
|
|
481
|
+
return this.detectCopilotSessionInfo();
|
|
482
|
+
default:
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
this.log(`session detect failed: ${error?.message || error}`);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
getPinnedSessionIds() {
|
|
492
|
+
return Array.from(new Set([
|
|
493
|
+
this.expectedSessionId,
|
|
494
|
+
this.sessionInfo?.sessionId,
|
|
495
|
+
this.lastSessionInfo?.sessionId,
|
|
496
|
+
]
|
|
497
|
+
.map((value) => String(value || "").trim())
|
|
498
|
+
.filter(Boolean)));
|
|
499
|
+
}
|
|
500
|
+
toSessionInfo(backend, candidate) {
|
|
501
|
+
if (!candidate) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
backend,
|
|
506
|
+
sessionId: candidate.sessionId,
|
|
507
|
+
sessionFilePath: candidate.sessionFilePath,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
pickSessionDiscoveryCandidate(candidates) {
|
|
511
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const pinnedIds = this.getPinnedSessionIds();
|
|
515
|
+
for (const pinnedId of pinnedIds) {
|
|
516
|
+
const match = candidates.find((candidate) => candidate.sessionId === pinnedId);
|
|
517
|
+
if (match) {
|
|
518
|
+
return match;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (this.sessionDetectBaselineIds.length === 0) {
|
|
522
|
+
return candidates[0] ?? null;
|
|
523
|
+
}
|
|
524
|
+
const baselineIds = new Set(this.sessionDetectBaselineIds);
|
|
525
|
+
return candidates.find((candidate) => !baselineIds.has(candidate.sessionId)) ?? null;
|
|
526
|
+
}
|
|
527
|
+
collectBaselineSessionIds(candidates) {
|
|
528
|
+
return Array.from(new Set(candidates
|
|
529
|
+
.map((candidate) => String(candidate.sessionId || "").trim())
|
|
530
|
+
.filter(Boolean))).slice(0, 256);
|
|
531
|
+
}
|
|
532
|
+
async captureSessionBaseline() {
|
|
533
|
+
if (this.getPinnedSessionIds().length > 0) {
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
switch (this.profile.name) {
|
|
537
|
+
case "codex":
|
|
538
|
+
return this.captureCodexSessionBaseline();
|
|
539
|
+
case "claude-code":
|
|
540
|
+
return this.captureClaudeSessionBaseline();
|
|
541
|
+
case "copilot":
|
|
542
|
+
return this.captureCopilotSessionBaseline();
|
|
543
|
+
default:
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async detectCodexSessionInfo() {
|
|
548
|
+
const dbPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "state_5.sqlite");
|
|
549
|
+
if (!(await this.pathExists(dbPath))) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const parseRowAsSessionInfo = async (row) => {
|
|
553
|
+
if (!row) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const [sessionIdRaw, sessionFilePathRaw] = row.split("|");
|
|
557
|
+
const sessionId = String(sessionIdRaw || "").trim();
|
|
558
|
+
const sessionFilePath = String(sessionFilePathRaw || "").trim();
|
|
559
|
+
if (!sessionId || !sessionFilePath || !(await this.pathExists(sessionFilePath))) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
backend: "codex",
|
|
564
|
+
sessionId,
|
|
565
|
+
sessionFilePath,
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
const candidateSessionIds = this.getPinnedSessionIds();
|
|
569
|
+
for (const candidateSessionId of candidateSessionIds) {
|
|
570
|
+
const escapedSessionId = candidateSessionId.replace(/'/g, "''");
|
|
571
|
+
const pinnedRow = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`);
|
|
572
|
+
const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
|
|
573
|
+
if (pinnedSession) {
|
|
574
|
+
return pinnedSession;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const conditions = [
|
|
578
|
+
"source='cli'",
|
|
579
|
+
"model_provider='openai'",
|
|
580
|
+
`created_at >= ${Math.max(0, this.sessionDetectStartSec)}`,
|
|
581
|
+
];
|
|
582
|
+
if (this.hasExplicitSessionCwd) {
|
|
583
|
+
conditions.push(`cwd='${this.sessionCwd.replace(/'/g, "''")}'`);
|
|
584
|
+
}
|
|
585
|
+
if (this.sessionDetectBaselineIds.length > 0) {
|
|
586
|
+
const escapedBaselineIds = this.sessionDetectBaselineIds
|
|
587
|
+
.map((sessionId) => `'${sessionId.replace(/'/g, "''")}'`)
|
|
588
|
+
.join(", ");
|
|
589
|
+
conditions.push(`id not in (${escapedBaselineIds})`);
|
|
590
|
+
}
|
|
591
|
+
const row = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where ${conditions.join(" and ")} order by created_at asc, id asc limit 1;`);
|
|
592
|
+
return parseRowAsSessionInfo(row);
|
|
593
|
+
}
|
|
594
|
+
async captureCodexSessionBaseline() {
|
|
595
|
+
if (String(this.profile.name || "").toLowerCase() !== "codex") {
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
const dbPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "state_5.sqlite");
|
|
599
|
+
if (!(await this.pathExists(dbPath))) {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
const conditions = [
|
|
603
|
+
"source='cli'",
|
|
604
|
+
"model_provider='openai'",
|
|
605
|
+
`created_at >= ${Math.max(0, this.sessionDetectStartSec)}`,
|
|
606
|
+
];
|
|
607
|
+
if (this.hasExplicitSessionCwd) {
|
|
608
|
+
conditions.push(`cwd='${this.sessionCwd.replace(/'/g, "''")}'`);
|
|
609
|
+
}
|
|
610
|
+
const rows = await this.querySqliteRows(dbPath, `select id from threads where ${conditions.join(" and ")} order by created_at asc, id asc limit 256;`);
|
|
611
|
+
return rows
|
|
612
|
+
.map((row) => String(row || "").trim())
|
|
613
|
+
.filter(Boolean);
|
|
614
|
+
}
|
|
615
|
+
async collectClaudeSessionCandidates() {
|
|
616
|
+
const projectDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects", this.encodeClaudeProjectPath(this.sessionCwd));
|
|
617
|
+
if (!(await this.pathExists(projectDir))) {
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
const candidates = [];
|
|
621
|
+
const seenSessionIds = new Set();
|
|
622
|
+
const pushCandidate = async (sessionId, sessionFilePath) => {
|
|
623
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
624
|
+
const normalizedSessionFilePath = String(sessionFilePath || "").trim();
|
|
625
|
+
if (!normalizedSessionId || !normalizedSessionFilePath || seenSessionIds.has(normalizedSessionId)) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (!(await this.pathExists(normalizedSessionFilePath))) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
seenSessionIds.add(normalizedSessionId);
|
|
632
|
+
candidates.push({
|
|
633
|
+
sessionId: normalizedSessionId,
|
|
634
|
+
sessionFilePath: normalizedSessionFilePath,
|
|
635
|
+
});
|
|
636
|
+
};
|
|
637
|
+
const indexPath = (0, node_path_1.join)(projectDir, "sessions-index.json");
|
|
638
|
+
if (await this.pathExists(indexPath)) {
|
|
639
|
+
try {
|
|
640
|
+
const raw = await node_fs_1.promises.readFile(indexPath, "utf8");
|
|
641
|
+
const parsed = JSON.parse(raw);
|
|
642
|
+
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
643
|
+
const candidates = entries
|
|
644
|
+
.filter((entry) => {
|
|
645
|
+
const entrySessionId = String(entry?.sessionId || "").trim();
|
|
646
|
+
if (!entrySessionId) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
const entryProjectPath = String(entry?.projectPath || "").trim();
|
|
650
|
+
return !entryProjectPath || entryProjectPath === this.sessionCwd;
|
|
651
|
+
})
|
|
652
|
+
.sort((a, b) => {
|
|
653
|
+
const scoreA = Number(a?.fileMtime || Date.parse(String(a?.modified || "")) || 0);
|
|
654
|
+
const scoreB = Number(b?.fileMtime || Date.parse(String(b?.modified || "")) || 0);
|
|
655
|
+
return scoreB - scoreA;
|
|
656
|
+
});
|
|
657
|
+
for (const entry of candidates) {
|
|
658
|
+
const sessionId = String(entry.sessionId || "").trim();
|
|
659
|
+
const sessionFilePath = String(entry.fullPath || "").trim() || (0, node_path_1.join)(projectDir, `${sessionId}.jsonl`);
|
|
660
|
+
await pushCandidate(sessionId, sessionFilePath);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
this.log(`claude session index parse failed: ${error?.message || error}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const dirents = await node_fs_1.promises.readdir(projectDir, { withFileTypes: true });
|
|
669
|
+
const jsonlFiles = dirents
|
|
670
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
671
|
+
.map((entry) => (0, node_path_1.join)(projectDir, entry.name));
|
|
672
|
+
const stats = await Promise.all(jsonlFiles.map(async (filePath) => ({
|
|
673
|
+
filePath,
|
|
674
|
+
mtimeMs: (await node_fs_1.promises.stat(filePath)).mtimeMs,
|
|
675
|
+
})));
|
|
676
|
+
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
677
|
+
for (const item of stats) {
|
|
678
|
+
await pushCandidate((0, node_path_1.basename)(item.filePath, ".jsonl"), item.filePath);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return candidates;
|
|
683
|
+
}
|
|
684
|
+
return candidates;
|
|
685
|
+
}
|
|
686
|
+
async captureClaudeSessionBaseline() {
|
|
687
|
+
return this.collectBaselineSessionIds(await this.collectClaudeSessionCandidates());
|
|
688
|
+
}
|
|
689
|
+
async detectClaudeSessionInfo() {
|
|
690
|
+
const candidates = await this.collectClaudeSessionCandidates();
|
|
691
|
+
return this.toSessionInfo("claude-code", this.pickSessionDiscoveryCandidate(candidates));
|
|
692
|
+
}
|
|
693
|
+
async collectCopilotSessionCandidates() {
|
|
694
|
+
const baseDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".copilot", "session-state");
|
|
695
|
+
if (!(await this.pathExists(baseDir))) {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
try {
|
|
699
|
+
const dirents = await node_fs_1.promises.readdir(baseDir, { withFileTypes: true });
|
|
700
|
+
const candidates = [];
|
|
701
|
+
for (const entry of dirents) {
|
|
702
|
+
if (!entry.isDirectory()) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const sessionDir = (0, node_path_1.join)(baseDir, entry.name);
|
|
706
|
+
const workspacePath = (0, node_path_1.join)(sessionDir, "workspace.yaml");
|
|
707
|
+
const eventsPath = (0, node_path_1.join)(sessionDir, "events.jsonl");
|
|
708
|
+
if (!(await this.pathExists(eventsPath))) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
let workspaceCwd = "";
|
|
712
|
+
let workspaceId = "";
|
|
713
|
+
if (await this.pathExists(workspacePath)) {
|
|
714
|
+
workspaceCwd = (await this.readWorkspaceYamlValue(workspacePath, "cwd")) || "";
|
|
715
|
+
workspaceId = (await this.readWorkspaceYamlValue(workspacePath, "id")) || "";
|
|
716
|
+
}
|
|
717
|
+
if (workspaceCwd && workspaceCwd !== this.sessionCwd) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const sessionId = workspaceId || entry.name;
|
|
721
|
+
const mtimeMs = (await node_fs_1.promises.stat(eventsPath)).mtimeMs;
|
|
722
|
+
candidates.push({
|
|
723
|
+
sessionId,
|
|
724
|
+
sessionFilePath: eventsPath,
|
|
725
|
+
mtimeMs,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
729
|
+
return candidates.map((candidate) => ({
|
|
730
|
+
sessionId: candidate.sessionId,
|
|
731
|
+
sessionFilePath: candidate.sessionFilePath,
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
async captureCopilotSessionBaseline() {
|
|
739
|
+
return this.collectBaselineSessionIds(await this.collectCopilotSessionCandidates());
|
|
740
|
+
}
|
|
741
|
+
async detectCopilotSessionInfo() {
|
|
742
|
+
const candidates = await this.collectCopilotSessionCandidates();
|
|
743
|
+
return this.toSessionInfo("copilot", this.pickSessionDiscoveryCandidate(candidates));
|
|
744
|
+
}
|
|
745
|
+
async querySqliteRows(dbPath, query) {
|
|
746
|
+
try {
|
|
747
|
+
const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
|
|
748
|
+
timeout: 3000,
|
|
749
|
+
maxBuffer: 1024 * 1024,
|
|
750
|
+
});
|
|
751
|
+
return String(stdout || "")
|
|
752
|
+
.split(/\r?\n/)
|
|
753
|
+
.map((line) => line.trim())
|
|
754
|
+
.filter(Boolean);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
this.log(`sqlite query failed: ${error?.message || error}`);
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async querySqliteRow(dbPath, query) {
|
|
762
|
+
const rows = await this.querySqliteRows(dbPath, query);
|
|
763
|
+
return rows[0] ?? null;
|
|
764
|
+
}
|
|
765
|
+
encodeClaudeProjectPath(cwd) {
|
|
766
|
+
return String(cwd || "").replace(/\//g, "-");
|
|
767
|
+
}
|
|
768
|
+
async readWorkspaceYamlValue(filePath, key) {
|
|
769
|
+
try {
|
|
770
|
+
const raw = await node_fs_1.promises.readFile(filePath, "utf8");
|
|
771
|
+
const matcher = new RegExp(`^${key}:\\s*(.+)\\s*$`, "m");
|
|
772
|
+
const match = raw.match(matcher);
|
|
773
|
+
if (!match) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
const value = match[1].trim();
|
|
777
|
+
if (!value) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async pathExists(filePath) {
|
|
787
|
+
try {
|
|
788
|
+
await node_fs_1.promises.access(filePath);
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async captureSessionFileCheckpoint(sessionInfo) {
|
|
796
|
+
if (!sessionInfo) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
800
|
+
return {
|
|
801
|
+
sessionInfo,
|
|
802
|
+
size,
|
|
803
|
+
mtimeMs,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
requiresSessionFileCheckpointForTurn() {
|
|
807
|
+
const backend = String(this.profile.name || "").toLowerCase();
|
|
808
|
+
return backend === "codex" || backend === "copilot";
|
|
809
|
+
}
|
|
810
|
+
async resolveSessionFileCheckpointForTurn(initialSessionInfo) {
|
|
811
|
+
const immediate = await this.captureSessionFileCheckpoint(initialSessionInfo);
|
|
812
|
+
if (immediate || !this.requiresSessionFileCheckpointForTurn()) {
|
|
813
|
+
return immediate;
|
|
814
|
+
}
|
|
815
|
+
const startedAt = Date.now();
|
|
816
|
+
while (Date.now() - startedAt < DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS) {
|
|
817
|
+
this.assertAliveOrThrow();
|
|
818
|
+
const elapsed = Date.now() - startedAt;
|
|
819
|
+
const remainingMs = Math.max(MIN_STAGE_TIMEOUT_MS, DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS - elapsed);
|
|
820
|
+
const discovered = await this.ensureSessionInfo(Math.min(DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS, remainingMs));
|
|
821
|
+
const checkpoint = await this.captureSessionFileCheckpoint(discovered);
|
|
822
|
+
if (checkpoint) {
|
|
823
|
+
return checkpoint;
|
|
824
|
+
}
|
|
825
|
+
await this.sleep(250);
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
async readSessionFileStat(sessionFilePath) {
|
|
830
|
+
try {
|
|
831
|
+
const stats = await node_fs_1.promises.stat(sessionFilePath);
|
|
832
|
+
return {
|
|
833
|
+
size: stats.size,
|
|
834
|
+
mtimeMs: stats.mtimeMs,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return {
|
|
839
|
+
size: 0,
|
|
840
|
+
mtimeMs: 0,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async waitForSessionFileGrowth(checkpoint) {
|
|
845
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
846
|
+
const startedAt = Date.now();
|
|
847
|
+
let lastSize = checkpoint.size;
|
|
848
|
+
let lastMtimeMs = checkpoint.mtimeMs;
|
|
849
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
850
|
+
this.assertAliveOrThrow();
|
|
851
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
852
|
+
const changed = current.size !== lastSize || current.mtimeMs !== lastMtimeMs;
|
|
853
|
+
if (changed) {
|
|
854
|
+
this.log(`session file growth detected: ${checkpoint.sessionInfo.sessionFilePath} (${lastSize} -> ${current.size})`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
858
|
+
}
|
|
859
|
+
throw new Error("Stream start timeout: session file did not grow");
|
|
860
|
+
}
|
|
861
|
+
async waitForSessionFileIdle(checkpoint) {
|
|
862
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
|
|
863
|
+
const startedAt = Date.now();
|
|
864
|
+
let previousSize = checkpoint.size;
|
|
865
|
+
let previousMtimeMs = checkpoint.mtimeMs;
|
|
866
|
+
let observedProgress = false;
|
|
867
|
+
let unchangedChecks = 0;
|
|
868
|
+
const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
|
|
869
|
+
const allowAssistantReplyFallback = this.supportsSessionAssistantReplyIdleFallback(checkpoint.sessionInfo.backend);
|
|
870
|
+
let completionMarkerSeen = false;
|
|
871
|
+
let assistantReplySeen = false;
|
|
872
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
873
|
+
this.assertAliveOrThrow();
|
|
874
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
875
|
+
const changed = current.size !== previousSize || current.mtimeMs !== previousMtimeMs;
|
|
876
|
+
if (changed) {
|
|
877
|
+
this.log(`session file changed: backend=${checkpoint.sessionInfo.backend} size=${previousSize}->${current.size} mtime=${previousMtimeMs}->${current.mtimeMs}`);
|
|
878
|
+
observedProgress = true;
|
|
879
|
+
unchangedChecks = 0;
|
|
880
|
+
previousSize = current.size;
|
|
881
|
+
previousMtimeMs = current.mtimeMs;
|
|
882
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
883
|
+
completionMarkerSeen = await this.hasSessionCompletionMarker(checkpoint, current.size);
|
|
884
|
+
if (completionMarkerSeen) {
|
|
885
|
+
this.log(`session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (allowAssistantReplyFallback && !assistantReplySeen) {
|
|
889
|
+
assistantReplySeen = await this.hasSessionAssistantReply(checkpoint, current.size);
|
|
890
|
+
if (assistantReplySeen) {
|
|
891
|
+
this.log(`session assistant reply observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
unchangedChecks += 1;
|
|
897
|
+
this.log(`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`);
|
|
898
|
+
if (observedProgress && unchangedChecks >= 2) {
|
|
899
|
+
if (!requireCompletionMarker || completionMarkerSeen || assistantReplySeen) {
|
|
900
|
+
this.log(`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
906
|
+
}
|
|
907
|
+
if (!observedProgress) {
|
|
908
|
+
throw new Error("Stream end timeout: session file did not grow");
|
|
909
|
+
}
|
|
910
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
911
|
+
if (allowAssistantReplyFallback) {
|
|
912
|
+
const assistantReplyAvailable = assistantReplySeen || (await this.hasSessionAssistantReply(checkpoint));
|
|
913
|
+
if (assistantReplyAvailable) {
|
|
914
|
+
this.log(`session completion marker missing; falling back to assistant reply: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
throw new Error("Stream end timeout: session completion marker not observed");
|
|
919
|
+
}
|
|
920
|
+
throw new Error("Stream end timeout: session file did not become stable");
|
|
921
|
+
}
|
|
922
|
+
async extractAnswerFromSessionFile(checkpoint) {
|
|
923
|
+
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size);
|
|
924
|
+
const codexTaskCompleteMessage = this.extractCodexTaskCompleteMessageFromJsonLines(lines);
|
|
925
|
+
if (codexTaskCompleteMessage) {
|
|
926
|
+
this.log(`session answer source=codex.task_complete preview="${this.summarizeForLog(codexTaskCompleteMessage, 160)}"`);
|
|
927
|
+
return codexTaskCompleteMessage;
|
|
928
|
+
}
|
|
929
|
+
const answer = this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend);
|
|
930
|
+
if (answer) {
|
|
931
|
+
this.log(`session answer source=${checkpoint.sessionInfo.backend}.assistant preview="${this.summarizeForLog(answer, 160)}"`);
|
|
932
|
+
return answer;
|
|
933
|
+
}
|
|
934
|
+
throw new Error("No assistant reply found in session file");
|
|
935
|
+
}
|
|
936
|
+
extractSessionUsageSummaryFromJsonLines(lines, sessionInfo) {
|
|
937
|
+
const backend = sessionInfo.backend;
|
|
938
|
+
const baseSummary = {
|
|
939
|
+
backend,
|
|
940
|
+
sessionId: sessionInfo.sessionId,
|
|
941
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
942
|
+
};
|
|
943
|
+
const usage = backend === "codex"
|
|
944
|
+
? this.extractCodexUsageFromJsonLines(lines)
|
|
945
|
+
: backend === "claude-code"
|
|
946
|
+
? this.extractClaudeUsageFromJsonLines(lines)
|
|
947
|
+
: backend === "copilot"
|
|
948
|
+
? this.extractCopilotUsageFromJsonLines(lines)
|
|
949
|
+
: {};
|
|
950
|
+
return {
|
|
951
|
+
...baseSummary,
|
|
952
|
+
...usage,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
extractCodexUsageFromJsonLines(lines) {
|
|
956
|
+
let tokenUsagePercent;
|
|
957
|
+
let contextUsagePercent;
|
|
958
|
+
for (const line of lines) {
|
|
959
|
+
let entry = null;
|
|
960
|
+
try {
|
|
961
|
+
entry = JSON.parse(line);
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (!entry || entry.type !== "event_msg") {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const payload = entry.payload;
|
|
970
|
+
if (!payload || typeof payload !== "object") {
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const secondaryUsedPercent = this.readNumberPath(payload, ["rate_limits", "secondary", "used_percent"]);
|
|
974
|
+
if (secondaryUsedPercent !== undefined) {
|
|
975
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
976
|
+
}
|
|
977
|
+
const inputTokens = this.readNumberPath(payload, ["info", "last_token_usage", "input_tokens"]);
|
|
978
|
+
const contextWindow = this.readNumberPath(payload, ["info", "model_context_window"]);
|
|
979
|
+
if (inputTokens !== undefined && contextWindow !== undefined && contextWindow > 0) {
|
|
980
|
+
contextUsagePercent = this.normalizePercent((inputTokens / contextWindow) * 100);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
tokenUsagePercent,
|
|
985
|
+
contextUsagePercent,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
extractClaudeUsageFromJsonLines(lines) {
|
|
989
|
+
let tokenUsagePercent;
|
|
990
|
+
let contextUsagePercent;
|
|
991
|
+
let latestInputTokens;
|
|
992
|
+
let latestContextWindow;
|
|
993
|
+
for (const line of lines) {
|
|
994
|
+
let entry = null;
|
|
995
|
+
try {
|
|
996
|
+
entry = JSON.parse(line);
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (!entry || typeof entry !== "object") {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const secondaryUsedPercent = this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
1005
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
1006
|
+
if (secondaryUsedPercent !== undefined) {
|
|
1007
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
1008
|
+
}
|
|
1009
|
+
const inputTokens = this.readNumberPath(entry, ["message", "usage", "input_tokens"]) ??
|
|
1010
|
+
this.readNumberPath(entry, ["message", "usage", "inputTokens"]) ??
|
|
1011
|
+
this.readNumberPath(entry, ["usage", "input_tokens"]) ??
|
|
1012
|
+
this.readNumberPath(entry, ["usage", "inputTokens"]);
|
|
1013
|
+
if (inputTokens !== undefined) {
|
|
1014
|
+
latestInputTokens = inputTokens;
|
|
1015
|
+
}
|
|
1016
|
+
const contextWindow = this.readNumberPath(entry, ["message", "model_context_window"]) ??
|
|
1017
|
+
this.readNumberPath(entry, ["message", "modelContextWindow"]) ??
|
|
1018
|
+
this.readNumberPath(entry, ["message", "context_window"]) ??
|
|
1019
|
+
this.readNumberPath(entry, ["message", "contextWindow"]) ??
|
|
1020
|
+
this.readNumberPath(entry, ["model_context_window"]) ??
|
|
1021
|
+
this.readNumberPath(entry, ["modelContextWindow"]) ??
|
|
1022
|
+
this.readNumberPath(entry, ["context_window"]) ??
|
|
1023
|
+
this.readNumberPath(entry, ["contextWindow"]);
|
|
1024
|
+
if (contextWindow !== undefined && contextWindow > 0) {
|
|
1025
|
+
latestContextWindow = contextWindow;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (latestInputTokens !== undefined &&
|
|
1029
|
+
latestContextWindow !== undefined &&
|
|
1030
|
+
latestContextWindow > 0) {
|
|
1031
|
+
contextUsagePercent = this.normalizePercent((latestInputTokens / latestContextWindow) * 100);
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
tokenUsagePercent,
|
|
1035
|
+
contextUsagePercent,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
extractCopilotUsageFromJsonLines(lines) {
|
|
1039
|
+
let tokenUsagePercent;
|
|
1040
|
+
let contextUsagePercent;
|
|
1041
|
+
let latestContextTokens;
|
|
1042
|
+
let latestContextLimit;
|
|
1043
|
+
for (const line of lines) {
|
|
1044
|
+
let entry = null;
|
|
1045
|
+
try {
|
|
1046
|
+
entry = JSON.parse(line);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (!entry || typeof entry !== "object") {
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const secondaryUsedPercent = this.readNumberPath(entry, ["data", "rate_limits", "secondary", "used_percent"]) ??
|
|
1055
|
+
this.readNumberPath(entry, ["data", "rateLimits", "secondary", "usedPercent"]) ??
|
|
1056
|
+
this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
1057
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
1058
|
+
if (secondaryUsedPercent !== undefined) {
|
|
1059
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
1060
|
+
}
|
|
1061
|
+
const responseTokenLimit = this.readNumberPath(entry, ["data", "toolTelemetry", "metrics", "responseTokenLimit"]) ??
|
|
1062
|
+
this.readNumberPath(entry, ["data", "responseTokenLimit"]) ??
|
|
1063
|
+
this.readNumberPath(entry, ["data", "modelContextWindow"]) ??
|
|
1064
|
+
this.readNumberPath(entry, ["data", "model_context_window"]) ??
|
|
1065
|
+
this.readNumberPath(entry, ["data", "contextWindow"]) ??
|
|
1066
|
+
this.readNumberPath(entry, ["data", "context_window"]) ??
|
|
1067
|
+
this.readNumberPath(entry, ["responseTokenLimit"]);
|
|
1068
|
+
if (responseTokenLimit !== undefined && responseTokenLimit > 0) {
|
|
1069
|
+
latestContextLimit = responseTokenLimit;
|
|
1070
|
+
}
|
|
1071
|
+
const contextTokens = this.readNumberPath(entry, ["data", "preCompactionTokens"]) ??
|
|
1072
|
+
this.readNumberPath(entry, ["data", "compactionTokensUsed", "input"]) ??
|
|
1073
|
+
this.readNumberPath(entry, ["data", "postCompactionTokens"]) ??
|
|
1074
|
+
this.readNumberPath(entry, ["data", "tokenUsage", "input_tokens"]) ??
|
|
1075
|
+
this.readNumberPath(entry, ["data", "usage", "input_tokens"]) ??
|
|
1076
|
+
this.readNumberPath(entry, ["data", "inputTokens"]);
|
|
1077
|
+
if (contextTokens !== undefined && contextTokens >= 0) {
|
|
1078
|
+
latestContextTokens = contextTokens;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (latestContextTokens !== undefined &&
|
|
1082
|
+
latestContextLimit !== undefined &&
|
|
1083
|
+
latestContextLimit > 0) {
|
|
1084
|
+
contextUsagePercent = this.normalizePercent((latestContextTokens / latestContextLimit) * 100);
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
tokenUsagePercent,
|
|
1088
|
+
contextUsagePercent,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
readNumberPath(source, path) {
|
|
1092
|
+
let cursor = source;
|
|
1093
|
+
for (const key of path) {
|
|
1094
|
+
if (!cursor || typeof cursor !== "object") {
|
|
1095
|
+
return undefined;
|
|
1096
|
+
}
|
|
1097
|
+
cursor = cursor[key];
|
|
1098
|
+
}
|
|
1099
|
+
if (typeof cursor === "number" && Number.isFinite(cursor)) {
|
|
1100
|
+
return cursor;
|
|
1101
|
+
}
|
|
1102
|
+
if (typeof cursor === "string") {
|
|
1103
|
+
const parsed = Number(cursor);
|
|
1104
|
+
if (Number.isFinite(parsed)) {
|
|
1105
|
+
return parsed;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
normalizePercent(value) {
|
|
1111
|
+
if (!Number.isFinite(value)) {
|
|
1112
|
+
return 0;
|
|
1113
|
+
}
|
|
1114
|
+
if (value < 0) {
|
|
1115
|
+
return 0;
|
|
1116
|
+
}
|
|
1117
|
+
if (value > 100) {
|
|
1118
|
+
return 100;
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
1122
|
+
requiresSessionCompletionMarker(backend) {
|
|
1123
|
+
return backend === "codex" || backend === "copilot";
|
|
1124
|
+
}
|
|
1125
|
+
supportsSessionAssistantReplyIdleFallback(backend) {
|
|
1126
|
+
return backend === "codex";
|
|
1127
|
+
}
|
|
1128
|
+
async hasSessionCompletionMarker(checkpoint, endOffset) {
|
|
1129
|
+
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size, endOffset);
|
|
1130
|
+
if (checkpoint.sessionInfo.backend === "codex") {
|
|
1131
|
+
return this.hasCodexTaskCompleteFromJsonLines(lines);
|
|
1132
|
+
}
|
|
1133
|
+
if (checkpoint.sessionInfo.backend === "copilot") {
|
|
1134
|
+
return this.hasCopilotTurnEndFromJsonLines(lines);
|
|
1135
|
+
}
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
async readSessionFileJsonLines(sessionFilePath, startOffset = 0, endOffset) {
|
|
1139
|
+
let fullBuffer;
|
|
1140
|
+
try {
|
|
1141
|
+
fullBuffer = await node_fs_1.promises.readFile(sessionFilePath);
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
return [];
|
|
1145
|
+
}
|
|
1146
|
+
const boundedStartOffset = Math.max(0, Math.min(startOffset, fullBuffer.length));
|
|
1147
|
+
const boundedEndOffset = Number.isFinite(endOffset)
|
|
1148
|
+
? Math.max(boundedStartOffset, Math.min(Number(endOffset), fullBuffer.length))
|
|
1149
|
+
: fullBuffer.length;
|
|
1150
|
+
return fullBuffer
|
|
1151
|
+
.subarray(boundedStartOffset, boundedEndOffset)
|
|
1152
|
+
.toString("utf8")
|
|
1153
|
+
.split(/\r?\n/)
|
|
1154
|
+
.map((line) => line.trim())
|
|
1155
|
+
.filter(Boolean);
|
|
1156
|
+
}
|
|
1157
|
+
async readSessionFileLinesSince(sessionFilePath, startOffset = 0) {
|
|
1158
|
+
let fullBuffer;
|
|
1159
|
+
try {
|
|
1160
|
+
fullBuffer = await node_fs_1.promises.readFile(sessionFilePath);
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
return {
|
|
1164
|
+
lines: [],
|
|
1165
|
+
nextOffset: Math.max(0, startOffset),
|
|
1166
|
+
fileSize: 0,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
const fileSize = fullBuffer.length;
|
|
1170
|
+
const boundedStartOffset = Math.max(0, Math.min(startOffset, fileSize));
|
|
1171
|
+
const pendingBuffer = fullBuffer.subarray(boundedStartOffset);
|
|
1172
|
+
const lastNewlineIndex = pendingBuffer.lastIndexOf(0x0a);
|
|
1173
|
+
if (lastNewlineIndex < 0) {
|
|
1174
|
+
return {
|
|
1175
|
+
lines: [],
|
|
1176
|
+
nextOffset: boundedStartOffset,
|
|
1177
|
+
fileSize,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const completedBuffer = pendingBuffer.subarray(0, lastNewlineIndex + 1);
|
|
1181
|
+
const nextOffset = boundedStartOffset + completedBuffer.length;
|
|
1182
|
+
const lines = completedBuffer
|
|
1183
|
+
.toString("utf8")
|
|
1184
|
+
.split(/\r?\n/)
|
|
1185
|
+
.map((line) => line.trim())
|
|
1186
|
+
.filter(Boolean);
|
|
1187
|
+
return {
|
|
1188
|
+
lines,
|
|
1189
|
+
nextOffset,
|
|
1190
|
+
fileSize,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
async hasSessionAssistantReply(checkpoint, endOffset) {
|
|
1194
|
+
if (!this.supportsSessionAssistantReplyIdleFallback(checkpoint.sessionInfo.backend)) {
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size, endOffset);
|
|
1198
|
+
return Boolean(this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend));
|
|
1199
|
+
}
|
|
1200
|
+
extractAssistantReplyFromJsonLines(lines, backend) {
|
|
1201
|
+
const replies = [];
|
|
1202
|
+
for (const line of lines) {
|
|
1203
|
+
let entry = null;
|
|
1204
|
+
try {
|
|
1205
|
+
entry = JSON.parse(line);
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (!entry || typeof entry !== "object") {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const text = backend === "codex"
|
|
1214
|
+
? this.extractCodexAssistantText(entry)
|
|
1215
|
+
: backend === "claude-code"
|
|
1216
|
+
? this.extractClaudeAssistantText(entry)
|
|
1217
|
+
: backend === "copilot"
|
|
1218
|
+
? this.extractCopilotAssistantText(entry)
|
|
1219
|
+
: "";
|
|
1220
|
+
if (text) {
|
|
1221
|
+
replies.push(text);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return replies.length > 0 ? replies[replies.length - 1] : "";
|
|
1225
|
+
}
|
|
1226
|
+
extractAssistantMessagesFromJsonLines(lines, sessionInfo) {
|
|
1227
|
+
const messages = [];
|
|
1228
|
+
for (const line of lines) {
|
|
1229
|
+
let entry = null;
|
|
1230
|
+
try {
|
|
1231
|
+
entry = JSON.parse(line);
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (!entry || typeof entry !== "object") {
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
const text = sessionInfo.backend === "codex"
|
|
1240
|
+
? this.extractCodexAssistantText(entry)
|
|
1241
|
+
: sessionInfo.backend === "claude-code"
|
|
1242
|
+
? this.extractClaudeAssistantText(entry)
|
|
1243
|
+
: sessionInfo.backend === "copilot"
|
|
1244
|
+
? this.extractCopilotAssistantText(entry)
|
|
1245
|
+
: "";
|
|
1246
|
+
if (!text) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const timestampRaw = typeof entry.timestamp === "string" ? entry.timestamp.trim() : "";
|
|
1250
|
+
messages.push({
|
|
1251
|
+
backend: sessionInfo.backend,
|
|
1252
|
+
sessionId: sessionInfo.sessionId,
|
|
1253
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
1254
|
+
text,
|
|
1255
|
+
timestamp: timestampRaw || undefined,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
return messages;
|
|
1259
|
+
}
|
|
1260
|
+
hasCodexTaskCompleteFromJsonLines(lines) {
|
|
1261
|
+
for (const line of lines) {
|
|
1262
|
+
let entry = null;
|
|
1263
|
+
try {
|
|
1264
|
+
entry = JSON.parse(line);
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (entry && this.isCodexTaskCompleteEntry(entry)) {
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1275
|
+
hasCopilotTurnEndFromJsonLines(lines) {
|
|
1276
|
+
let latestAssistantMessageIndex = -1;
|
|
1277
|
+
let latestCompletedTurnEndIndex = -1;
|
|
1278
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1279
|
+
const line = lines[index];
|
|
1280
|
+
let entry = null;
|
|
1281
|
+
try {
|
|
1282
|
+
entry = JSON.parse(line);
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (!entry) {
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (this.isCopilotTurnStartEntry(entry) && latestCompletedTurnEndIndex >= 0) {
|
|
1291
|
+
latestCompletedTurnEndIndex = -1;
|
|
1292
|
+
}
|
|
1293
|
+
const assistantText = this.extractCopilotAssistantText(entry);
|
|
1294
|
+
if (assistantText) {
|
|
1295
|
+
latestAssistantMessageIndex = index;
|
|
1296
|
+
}
|
|
1297
|
+
if (this.isCopilotTurnEndEntry(entry) &&
|
|
1298
|
+
latestAssistantMessageIndex >= 0 &&
|
|
1299
|
+
index > latestAssistantMessageIndex) {
|
|
1300
|
+
latestCompletedTurnEndIndex = index;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return latestCompletedTurnEndIndex >= 0;
|
|
1304
|
+
}
|
|
1305
|
+
extractCodexTaskCompleteMessageFromJsonLines(lines) {
|
|
1306
|
+
let latestMessage = "";
|
|
1307
|
+
for (const line of lines) {
|
|
1308
|
+
let entry = null;
|
|
1309
|
+
try {
|
|
1310
|
+
entry = JSON.parse(line);
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
if (!entry || !this.isCodexTaskCompleteEntry(entry)) {
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const payload = entry.payload;
|
|
1319
|
+
const message = typeof payload?.last_agent_message === "string" ? payload.last_agent_message.trim() : "";
|
|
1320
|
+
if (message) {
|
|
1321
|
+
latestMessage = message;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return latestMessage;
|
|
1325
|
+
}
|
|
1326
|
+
isCodexTaskCompleteEntry(entry) {
|
|
1327
|
+
if (entry.type !== "event_msg") {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
const payload = entry.payload;
|
|
1331
|
+
return payload?.type === "task_complete";
|
|
1332
|
+
}
|
|
1333
|
+
isCopilotTurnStartEntry(entry) {
|
|
1334
|
+
return entry.type === "assistant.turn_start";
|
|
1335
|
+
}
|
|
1336
|
+
isCopilotTurnEndEntry(entry) {
|
|
1337
|
+
return entry.type === "assistant.turn_end";
|
|
1338
|
+
}
|
|
1339
|
+
summarizeForLog(value, maxLen = 160) {
|
|
1340
|
+
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
1341
|
+
if (!normalized) {
|
|
1342
|
+
return "";
|
|
1343
|
+
}
|
|
1344
|
+
if (normalized.length <= maxLen) {
|
|
1345
|
+
return normalized;
|
|
1346
|
+
}
|
|
1347
|
+
return `${normalized.slice(0, maxLen)}...`;
|
|
1348
|
+
}
|
|
1349
|
+
extractCodexAssistantText(entry) {
|
|
1350
|
+
if (entry.type !== "response_item") {
|
|
1351
|
+
return "";
|
|
1352
|
+
}
|
|
1353
|
+
const payload = entry.payload;
|
|
1354
|
+
if (!payload || payload.type !== "message" || payload.role !== "assistant") {
|
|
1355
|
+
return "";
|
|
1356
|
+
}
|
|
1357
|
+
const content = payload.content;
|
|
1358
|
+
if (!Array.isArray(content)) {
|
|
1359
|
+
return "";
|
|
1360
|
+
}
|
|
1361
|
+
const text = content
|
|
1362
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
1363
|
+
.filter(Boolean)
|
|
1364
|
+
.join("\n")
|
|
1365
|
+
.trim();
|
|
1366
|
+
return text;
|
|
1367
|
+
}
|
|
1368
|
+
extractClaudeAssistantText(entry) {
|
|
1369
|
+
if (entry.type !== "assistant") {
|
|
1370
|
+
return "";
|
|
1371
|
+
}
|
|
1372
|
+
const message = entry.message;
|
|
1373
|
+
if (!message || message.role !== "assistant") {
|
|
1374
|
+
return "";
|
|
1375
|
+
}
|
|
1376
|
+
const content = message.content;
|
|
1377
|
+
if (typeof content === "string") {
|
|
1378
|
+
return content.trim();
|
|
1379
|
+
}
|
|
1380
|
+
if (!Array.isArray(content)) {
|
|
1381
|
+
return "";
|
|
1382
|
+
}
|
|
1383
|
+
const text = content
|
|
1384
|
+
.map((block) => {
|
|
1385
|
+
const typed = block;
|
|
1386
|
+
return typed?.type === "text" && typeof typed?.text === "string" ? typed.text : "";
|
|
1387
|
+
})
|
|
1388
|
+
.filter(Boolean)
|
|
1389
|
+
.join("\n")
|
|
1390
|
+
.trim();
|
|
1391
|
+
return text;
|
|
1392
|
+
}
|
|
1393
|
+
extractCopilotAssistantText(entry) {
|
|
1394
|
+
if (entry.type !== "assistant.message") {
|
|
1395
|
+
return "";
|
|
1396
|
+
}
|
|
1397
|
+
const data = entry.data;
|
|
1398
|
+
if (!data || typeof data.content !== "string") {
|
|
1399
|
+
return "";
|
|
1400
|
+
}
|
|
1401
|
+
return data.content.trim();
|
|
1402
|
+
}
|
|
299
1403
|
async waitStreamStart(previousSnapshot) {
|
|
300
1404
|
const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
301
1405
|
if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
|
|
@@ -571,12 +1675,88 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
571
1675
|
}
|
|
572
1676
|
async restart() {
|
|
573
1677
|
this.log("Restarting PTY...");
|
|
1678
|
+
const restartSession = await this.resolveRestartSessionInfo();
|
|
1679
|
+
const restartArgs = this.resolveRestartArgs(restartSession?.sessionId);
|
|
1680
|
+
if (restartSession?.sessionId) {
|
|
1681
|
+
this.log(`restart resume target: backend=${this.profile.name} session=${restartSession.sessionId} args=${JSON.stringify(restartArgs)}`);
|
|
1682
|
+
}
|
|
1683
|
+
else {
|
|
1684
|
+
this.log(`restart without resume: backend=${this.profile.name} args=${JSON.stringify(restartArgs)}`);
|
|
1685
|
+
}
|
|
574
1686
|
this.pty.kill();
|
|
1687
|
+
this.pty.setCommandArgs(this.initialCommand, restartArgs);
|
|
575
1688
|
this.screen.reset();
|
|
576
1689
|
this.isBooted = false;
|
|
577
1690
|
await this.sleep(500);
|
|
578
1691
|
await this.boot();
|
|
579
1692
|
}
|
|
1693
|
+
async resolveRestartSessionInfo() {
|
|
1694
|
+
const cached = this.sessionInfo ?? this.lastSessionInfo;
|
|
1695
|
+
if (cached?.sessionId) {
|
|
1696
|
+
return { ...cached };
|
|
1697
|
+
}
|
|
1698
|
+
if (!this.supportsSessionFileTracking()) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
const detected = await this.detectSessionInfoByBackend();
|
|
1702
|
+
if (!detected?.sessionId) {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
this.lastSessionInfo = detected;
|
|
1706
|
+
return { ...detected };
|
|
1707
|
+
}
|
|
1708
|
+
resolveRestartArgs(sessionId) {
|
|
1709
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1710
|
+
if (!normalizedSessionId) {
|
|
1711
|
+
return [...this.initialArgs];
|
|
1712
|
+
}
|
|
1713
|
+
const baseArgs = this.stripResumeArgs(this.initialArgs, this.profile.name);
|
|
1714
|
+
const resumeArgs = this.buildResumeArgsForBackend(this.profile.name, normalizedSessionId);
|
|
1715
|
+
if (resumeArgs.length === 0) {
|
|
1716
|
+
return [...this.initialArgs];
|
|
1717
|
+
}
|
|
1718
|
+
return [...baseArgs, ...resumeArgs];
|
|
1719
|
+
}
|
|
1720
|
+
stripResumeArgs(args, backendName) {
|
|
1721
|
+
const result = [];
|
|
1722
|
+
const backend = String(backendName || "").toLowerCase();
|
|
1723
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1724
|
+
const current = String(args[index] || "");
|
|
1725
|
+
const next = args[index + 1];
|
|
1726
|
+
if (current === "--resume") {
|
|
1727
|
+
index += 1;
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (current.startsWith("--resume=")) {
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
if ((backend === "codex" || backend === "code") && current === "resume") {
|
|
1734
|
+
if (typeof next === "string" && next.length > 0) {
|
|
1735
|
+
index += 1;
|
|
1736
|
+
}
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
result.push(current);
|
|
1740
|
+
}
|
|
1741
|
+
return result;
|
|
1742
|
+
}
|
|
1743
|
+
buildResumeArgsForBackend(backendName, sessionId) {
|
|
1744
|
+
const normalizedBackend = String(backendName || "").toLowerCase();
|
|
1745
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1746
|
+
if (!normalizedSessionId) {
|
|
1747
|
+
return [];
|
|
1748
|
+
}
|
|
1749
|
+
if (normalizedBackend === "codex" || normalizedBackend === "code") {
|
|
1750
|
+
return ["resume", normalizedSessionId];
|
|
1751
|
+
}
|
|
1752
|
+
if (normalizedBackend === "claude-code" || normalizedBackend === "claude") {
|
|
1753
|
+
return ["--resume", normalizedSessionId];
|
|
1754
|
+
}
|
|
1755
|
+
if (normalizedBackend === "copilot") {
|
|
1756
|
+
return [`--resume=${normalizedSessionId}`];
|
|
1757
|
+
}
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
580
1760
|
captureSnapshot(label) {
|
|
581
1761
|
const snapshot = this.screen.snapshot();
|
|
582
1762
|
if (this.onSnapshot) {
|
|
@@ -743,11 +1923,16 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
743
1923
|
async write(data) {
|
|
744
1924
|
this.pty.write(data);
|
|
745
1925
|
}
|
|
1926
|
+
async forceRestart() {
|
|
1927
|
+
await this.restart();
|
|
1928
|
+
}
|
|
746
1929
|
kill() {
|
|
747
1930
|
this.isKilled = true;
|
|
748
1931
|
this.pty.kill();
|
|
749
1932
|
this.screen.dispose();
|
|
750
1933
|
this.isBooted = false;
|
|
1934
|
+
this.sessionInfo = null;
|
|
1935
|
+
this.lastSessionInfo = null;
|
|
751
1936
|
}
|
|
752
1937
|
createSessionClosedError() {
|
|
753
1938
|
const error = new Error("TUI session closed");
|
|
@@ -765,13 +1950,35 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
765
1950
|
terminateSessionForLoginRequired() {
|
|
766
1951
|
this.pty.kill();
|
|
767
1952
|
this.isBooted = false;
|
|
1953
|
+
this.sessionInfo = null;
|
|
1954
|
+
this.lastSessionInfo = null;
|
|
768
1955
|
}
|
|
769
1956
|
resolveTimeout(timeoutMs, defaultTimeoutMs) {
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
1957
|
+
const fallback = this.normalizeTimeoutValue(defaultTimeoutMs, Math.max(MIN_STAGE_TIMEOUT_MS, defaultTimeoutMs));
|
|
1958
|
+
return this.normalizeTimeoutValue(timeoutMs, fallback);
|
|
1959
|
+
}
|
|
1960
|
+
normalizeTimeoutValue(timeoutMs, fallback) {
|
|
1961
|
+
const parsed = Number(timeoutMs);
|
|
1962
|
+
if (!Number.isFinite(parsed)) {
|
|
1963
|
+
return fallback;
|
|
773
1964
|
}
|
|
774
|
-
|
|
1965
|
+
// `0` means "disable hard timeout" for long-running turns.
|
|
1966
|
+
if (parsed === 0) {
|
|
1967
|
+
return this.resolveMaxStageTimeoutMs();
|
|
1968
|
+
}
|
|
1969
|
+
if (parsed < 0) {
|
|
1970
|
+
return fallback;
|
|
1971
|
+
}
|
|
1972
|
+
const bounded = Math.max(MIN_STAGE_TIMEOUT_MS, Math.round(parsed));
|
|
1973
|
+
return Math.min(bounded, this.resolveMaxStageTimeoutMs());
|
|
1974
|
+
}
|
|
1975
|
+
resolveMaxStageTimeoutMs() {
|
|
1976
|
+
const raw = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
|
|
1977
|
+
const parsed = Number.parseInt(String(raw || ""), 10);
|
|
1978
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1979
|
+
return DEFAULT_STAGE_TIMEOUT_MAX_MS;
|
|
1980
|
+
}
|
|
1981
|
+
return Math.min(Math.max(parsed, MIN_STAGE_TIMEOUT_MS), ABSOLUTE_STAGE_TIMEOUT_MAX_MS);
|
|
775
1982
|
}
|
|
776
1983
|
async waitForScrollbackIdle(idleMs, timeoutMs, completionHint) {
|
|
777
1984
|
const startTime = Date.now();
|