@love-moon/tui-driver 0.2.13 → 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 +40 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -1
- package/dist/driver/TuiDriver.js +342 -51
- package/dist/driver/TuiDriver.js.map +1 -1
- package/dist/driver/index.d.ts +2 -2
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/driver/TuiDriver.ts +465 -51
- package/src/driver/index.ts +6 -1
- package/src/driver/profiles/copilot.profile.ts +10 -10
- package/src/index.ts +2 -0
- package/test/backend-session-discovery.test.ts +134 -0
- package/test/codex-session-discovery.test.ts +18 -7
- package/test/copilot-profile.test.ts +12 -0
- package/test/copilot-signals.test.ts +14 -0
- package/test/session-file-extraction.test.ts +306 -1
package/dist/driver/TuiDriver.js
CHANGED
|
@@ -20,6 +20,7 @@ const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
|
|
|
20
20
|
const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
|
|
21
21
|
const MIN_STAGE_TIMEOUT_MS = 100;
|
|
22
22
|
const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
23
|
+
const DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS = 30_000;
|
|
23
24
|
const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
24
25
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
25
26
|
class TuiDriver extends events_1.EventEmitter {
|
|
@@ -35,6 +36,10 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
35
36
|
isBooted = false;
|
|
36
37
|
isKilled = false;
|
|
37
38
|
sessionCwd;
|
|
39
|
+
hasExplicitSessionCwd;
|
|
40
|
+
expectedSessionId;
|
|
41
|
+
sessionDetectStartSec;
|
|
42
|
+
sessionDetectBaselineIds;
|
|
38
43
|
sessionInfo = null;
|
|
39
44
|
lastSessionInfo = null;
|
|
40
45
|
sessionUsageCache = null;
|
|
@@ -47,7 +52,11 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
47
52
|
this.debug = options.debug ?? false;
|
|
48
53
|
this.onSnapshot = options.onSnapshot;
|
|
49
54
|
this.onSignals = options.onSignals;
|
|
55
|
+
this.hasExplicitSessionCwd = options.cwd !== undefined;
|
|
50
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 = [];
|
|
51
60
|
this.initialCommand = this.profile.command;
|
|
52
61
|
this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
|
|
53
62
|
const cols = this.profile.cols ?? 120;
|
|
@@ -141,6 +150,30 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
141
150
|
};
|
|
142
151
|
return { ...summary };
|
|
143
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
|
+
}
|
|
144
177
|
async boot() {
|
|
145
178
|
if (this.isKilled) {
|
|
146
179
|
throw this.createSessionClosedError();
|
|
@@ -150,6 +183,8 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
150
183
|
}
|
|
151
184
|
this.stateMachine.transition("BOOT");
|
|
152
185
|
this.sessionInfo = null;
|
|
186
|
+
this.sessionDetectStartSec = Math.floor(Date.now() / 1000);
|
|
187
|
+
this.sessionDetectBaselineIds = await this.captureSessionBaseline();
|
|
153
188
|
this.pty.spawn();
|
|
154
189
|
const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
|
|
155
190
|
const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
|
|
@@ -320,7 +355,16 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
320
355
|
await this.submit();
|
|
321
356
|
// 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
|
|
322
357
|
const beforeSnapshot = this.captureSnapshot("after_submit");
|
|
323
|
-
const sessionCheckpoint = await this.
|
|
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
|
+
}
|
|
324
368
|
this.stateMachine.transition("WAIT_STREAM_START");
|
|
325
369
|
if (sessionCheckpoint) {
|
|
326
370
|
await this.waitForSessionFileGrowth(sessionCheckpoint);
|
|
@@ -337,9 +381,13 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
337
381
|
}
|
|
338
382
|
this.stateMachine.transition("CAPTURE");
|
|
339
383
|
const afterSnapshot = this.captureSnapshot("after_response");
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
384
|
+
let answer = "";
|
|
385
|
+
if (sessionCheckpoint) {
|
|
386
|
+
answer = await this.extractAnswerFromSessionFile(sessionCheckpoint);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
|
|
390
|
+
}
|
|
343
391
|
const signals = this.getSignals(afterSnapshot);
|
|
344
392
|
this.stateMachine.transition("DONE");
|
|
345
393
|
return {
|
|
@@ -440,6 +488,62 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
440
488
|
return null;
|
|
441
489
|
}
|
|
442
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
|
+
}
|
|
443
547
|
async detectCodexSessionInfo() {
|
|
444
548
|
const dbPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "state_5.sqlite");
|
|
445
549
|
if (!(await this.pathExists(dbPath))) {
|
|
@@ -461,24 +565,75 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
461
565
|
sessionFilePath,
|
|
462
566
|
};
|
|
463
567
|
};
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
const escapedSessionId =
|
|
568
|
+
const candidateSessionIds = this.getPinnedSessionIds();
|
|
569
|
+
for (const candidateSessionId of candidateSessionIds) {
|
|
570
|
+
const escapedSessionId = candidateSessionId.replace(/'/g, "''");
|
|
467
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;`);
|
|
468
572
|
const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
|
|
469
573
|
if (pinnedSession) {
|
|
470
574
|
return pinnedSession;
|
|
471
575
|
}
|
|
472
576
|
}
|
|
473
|
-
const
|
|
474
|
-
|
|
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;`);
|
|
475
592
|
return parseRowAsSessionInfo(row);
|
|
476
593
|
}
|
|
477
|
-
async
|
|
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() {
|
|
478
616
|
const projectDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects", this.encodeClaudeProjectPath(this.sessionCwd));
|
|
479
617
|
if (!(await this.pathExists(projectDir))) {
|
|
480
|
-
return
|
|
618
|
+
return [];
|
|
481
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
|
+
};
|
|
482
637
|
const indexPath = (0, node_path_1.join)(projectDir, "sessions-index.json");
|
|
483
638
|
if (await this.pathExists(indexPath)) {
|
|
484
639
|
try {
|
|
@@ -502,13 +657,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
502
657
|
for (const entry of candidates) {
|
|
503
658
|
const sessionId = String(entry.sessionId || "").trim();
|
|
504
659
|
const sessionFilePath = String(entry.fullPath || "").trim() || (0, node_path_1.join)(projectDir, `${sessionId}.jsonl`);
|
|
505
|
-
|
|
506
|
-
return {
|
|
507
|
-
backend: "claude-code",
|
|
508
|
-
sessionId,
|
|
509
|
-
sessionFilePath,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
660
|
+
await pushCandidate(sessionId, sessionFilePath);
|
|
512
661
|
}
|
|
513
662
|
}
|
|
514
663
|
catch (error) {
|
|
@@ -525,25 +674,26 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
525
674
|
mtimeMs: (await node_fs_1.promises.stat(filePath)).mtimeMs,
|
|
526
675
|
})));
|
|
527
676
|
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
return null;
|
|
677
|
+
for (const item of stats) {
|
|
678
|
+
await pushCandidate((0, node_path_1.basename)(item.filePath, ".jsonl"), item.filePath);
|
|
531
679
|
}
|
|
532
|
-
const sessionId = (0, node_path_1.basename)(latest.filePath, ".jsonl");
|
|
533
|
-
return {
|
|
534
|
-
backend: "claude-code",
|
|
535
|
-
sessionId,
|
|
536
|
-
sessionFilePath: latest.filePath,
|
|
537
|
-
};
|
|
538
680
|
}
|
|
539
681
|
catch {
|
|
540
|
-
return
|
|
682
|
+
return candidates;
|
|
541
683
|
}
|
|
684
|
+
return candidates;
|
|
542
685
|
}
|
|
543
|
-
async
|
|
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() {
|
|
544
694
|
const baseDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".copilot", "session-state");
|
|
545
695
|
if (!(await this.pathExists(baseDir))) {
|
|
546
|
-
return
|
|
696
|
+
return [];
|
|
547
697
|
}
|
|
548
698
|
try {
|
|
549
699
|
const dirents = await node_fs_1.promises.readdir(baseDir, { withFileTypes: true });
|
|
@@ -576,37 +726,42 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
576
726
|
});
|
|
577
727
|
}
|
|
578
728
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
return {
|
|
584
|
-
backend: "copilot",
|
|
585
|
-
sessionId: latest.sessionId,
|
|
586
|
-
sessionFilePath: latest.sessionFilePath,
|
|
587
|
-
};
|
|
729
|
+
return candidates.map((candidate) => ({
|
|
730
|
+
sessionId: candidate.sessionId,
|
|
731
|
+
sessionFilePath: candidate.sessionFilePath,
|
|
732
|
+
}));
|
|
588
733
|
}
|
|
589
734
|
catch {
|
|
590
|
-
return
|
|
735
|
+
return [];
|
|
591
736
|
}
|
|
592
737
|
}
|
|
593
|
-
async
|
|
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) {
|
|
594
746
|
try {
|
|
595
747
|
const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
|
|
596
748
|
timeout: 3000,
|
|
597
749
|
maxBuffer: 1024 * 1024,
|
|
598
750
|
});
|
|
599
|
-
|
|
751
|
+
return String(stdout || "")
|
|
600
752
|
.split(/\r?\n/)
|
|
601
753
|
.map((line) => line.trim())
|
|
602
754
|
.filter(Boolean);
|
|
603
|
-
return lines[0] ?? null;
|
|
604
755
|
}
|
|
605
756
|
catch (error) {
|
|
606
757
|
this.log(`sqlite query failed: ${error?.message || error}`);
|
|
607
|
-
return
|
|
758
|
+
return [];
|
|
608
759
|
}
|
|
609
760
|
}
|
|
761
|
+
async querySqliteRow(dbPath, query) {
|
|
762
|
+
const rows = await this.querySqliteRows(dbPath, query);
|
|
763
|
+
return rows[0] ?? null;
|
|
764
|
+
}
|
|
610
765
|
encodeClaudeProjectPath(cwd) {
|
|
611
766
|
return String(cwd || "").replace(/\//g, "-");
|
|
612
767
|
}
|
|
@@ -648,6 +803,29 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
648
803
|
mtimeMs,
|
|
649
804
|
};
|
|
650
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
|
+
}
|
|
651
829
|
async readSessionFileStat(sessionFilePath) {
|
|
652
830
|
try {
|
|
653
831
|
const stats = await node_fs_1.promises.stat(sessionFilePath);
|
|
@@ -688,7 +866,9 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
688
866
|
let observedProgress = false;
|
|
689
867
|
let unchangedChecks = 0;
|
|
690
868
|
const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
|
|
869
|
+
const allowAssistantReplyFallback = this.supportsSessionAssistantReplyIdleFallback(checkpoint.sessionInfo.backend);
|
|
691
870
|
let completionMarkerSeen = false;
|
|
871
|
+
let assistantReplySeen = false;
|
|
692
872
|
while (Date.now() - startedAt < timeoutMs) {
|
|
693
873
|
this.assertAliveOrThrow();
|
|
694
874
|
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
@@ -705,13 +885,19 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
705
885
|
this.log(`session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
|
|
706
886
|
}
|
|
707
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
|
+
}
|
|
708
894
|
}
|
|
709
895
|
else {
|
|
710
896
|
unchangedChecks += 1;
|
|
711
|
-
this.log(`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
|
|
897
|
+
this.log(`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`);
|
|
712
898
|
if (observedProgress && unchangedChecks >= 2) {
|
|
713
|
-
if (!requireCompletionMarker || completionMarkerSeen) {
|
|
714
|
-
this.log(`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
|
|
899
|
+
if (!requireCompletionMarker || completionMarkerSeen || assistantReplySeen) {
|
|
900
|
+
this.log(`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`);
|
|
715
901
|
return;
|
|
716
902
|
}
|
|
717
903
|
}
|
|
@@ -722,6 +908,13 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
722
908
|
throw new Error("Stream end timeout: session file did not grow");
|
|
723
909
|
}
|
|
724
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
|
+
}
|
|
725
918
|
throw new Error("Stream end timeout: session completion marker not observed");
|
|
726
919
|
}
|
|
727
920
|
throw new Error("Stream end timeout: session file did not become stable");
|
|
@@ -929,6 +1122,9 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
929
1122
|
requiresSessionCompletionMarker(backend) {
|
|
930
1123
|
return backend === "codex" || backend === "copilot";
|
|
931
1124
|
}
|
|
1125
|
+
supportsSessionAssistantReplyIdleFallback(backend) {
|
|
1126
|
+
return backend === "codex";
|
|
1127
|
+
}
|
|
932
1128
|
async hasSessionCompletionMarker(checkpoint, endOffset) {
|
|
933
1129
|
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size, endOffset);
|
|
934
1130
|
if (checkpoint.sessionInfo.backend === "codex") {
|
|
@@ -958,6 +1154,49 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
958
1154
|
.map((line) => line.trim())
|
|
959
1155
|
.filter(Boolean);
|
|
960
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
|
+
}
|
|
961
1200
|
extractAssistantReplyFromJsonLines(lines, backend) {
|
|
962
1201
|
const replies = [];
|
|
963
1202
|
for (const line of lines) {
|
|
@@ -984,6 +1223,40 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
984
1223
|
}
|
|
985
1224
|
return replies.length > 0 ? replies[replies.length - 1] : "";
|
|
986
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
|
+
}
|
|
987
1260
|
hasCodexTaskCompleteFromJsonLines(lines) {
|
|
988
1261
|
for (const line of lines) {
|
|
989
1262
|
let entry = null;
|
|
@@ -1000,7 +1273,10 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
1000
1273
|
return false;
|
|
1001
1274
|
}
|
|
1002
1275
|
hasCopilotTurnEndFromJsonLines(lines) {
|
|
1003
|
-
|
|
1276
|
+
let latestAssistantMessageIndex = -1;
|
|
1277
|
+
let latestCompletedTurnEndIndex = -1;
|
|
1278
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1279
|
+
const line = lines[index];
|
|
1004
1280
|
let entry = null;
|
|
1005
1281
|
try {
|
|
1006
1282
|
entry = JSON.parse(line);
|
|
@@ -1008,11 +1284,23 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
1008
1284
|
catch {
|
|
1009
1285
|
continue;
|
|
1010
1286
|
}
|
|
1011
|
-
if (entry
|
|
1012
|
-
|
|
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;
|
|
1013
1301
|
}
|
|
1014
1302
|
}
|
|
1015
|
-
return
|
|
1303
|
+
return latestCompletedTurnEndIndex >= 0;
|
|
1016
1304
|
}
|
|
1017
1305
|
extractCodexTaskCompleteMessageFromJsonLines(lines) {
|
|
1018
1306
|
let latestMessage = "";
|
|
@@ -1042,6 +1330,9 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
1042
1330
|
const payload = entry.payload;
|
|
1043
1331
|
return payload?.type === "task_complete";
|
|
1044
1332
|
}
|
|
1333
|
+
isCopilotTurnStartEntry(entry) {
|
|
1334
|
+
return entry.type === "assistant.turn_start";
|
|
1335
|
+
}
|
|
1045
1336
|
isCopilotTurnEndEntry(entry) {
|
|
1046
1337
|
return entry.type === "assistant.turn_end";
|
|
1047
1338
|
}
|