@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/src/driver/TuiDriver.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { TuiDriverBehavior } from "./behavior/index.js";
|
|
|
19
19
|
export interface TuiDriverOptions {
|
|
20
20
|
profile: TuiProfile;
|
|
21
21
|
cwd?: string;
|
|
22
|
+
expectedSessionId?: string;
|
|
22
23
|
debug?: boolean;
|
|
23
24
|
onSnapshot?: (snapshot: ScreenSnapshot, state: TuiState) => void;
|
|
24
25
|
onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
|
|
@@ -81,12 +82,34 @@ export interface TuiSessionUsageSummary {
|
|
|
81
82
|
contextUsagePercent?: number;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
export interface TuiSessionAssistantMessage {
|
|
86
|
+
backend: string;
|
|
87
|
+
sessionId: string;
|
|
88
|
+
sessionFilePath: string;
|
|
89
|
+
text: string;
|
|
90
|
+
timestamp?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TuiSessionAssistantMessageBatch {
|
|
94
|
+
backend: string;
|
|
95
|
+
sessionId: string;
|
|
96
|
+
sessionFilePath: string;
|
|
97
|
+
nextOffset: number;
|
|
98
|
+
fileSize: number;
|
|
99
|
+
messages: TuiSessionAssistantMessage[];
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
interface SessionFileCheckpoint {
|
|
85
103
|
sessionInfo: TuiSessionInfo;
|
|
86
104
|
size: number;
|
|
87
105
|
mtimeMs: number;
|
|
88
106
|
}
|
|
89
107
|
|
|
108
|
+
interface SessionDiscoveryCandidate {
|
|
109
|
+
sessionId: string;
|
|
110
|
+
sessionFilePath: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
interface SessionUsageCacheEntry {
|
|
91
114
|
backend: string;
|
|
92
115
|
sessionId: string;
|
|
@@ -100,6 +123,7 @@ const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
|
|
|
100
123
|
const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
|
|
101
124
|
const MIN_STAGE_TIMEOUT_MS = 100;
|
|
102
125
|
const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
126
|
+
const DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS = 30_000;
|
|
103
127
|
const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
104
128
|
|
|
105
129
|
const execFileAsync = promisify(execFile);
|
|
@@ -117,6 +141,10 @@ export class TuiDriver extends EventEmitter {
|
|
|
117
141
|
private isBooted = false;
|
|
118
142
|
private isKilled = false;
|
|
119
143
|
private sessionCwd: string;
|
|
144
|
+
private hasExplicitSessionCwd: boolean;
|
|
145
|
+
private expectedSessionId: string;
|
|
146
|
+
private sessionDetectStartSec: number;
|
|
147
|
+
private sessionDetectBaselineIds: string[];
|
|
120
148
|
private sessionInfo: TuiSessionInfo | null = null;
|
|
121
149
|
private lastSessionInfo: TuiSessionInfo | null = null;
|
|
122
150
|
private sessionUsageCache: SessionUsageCacheEntry | null = null;
|
|
@@ -130,7 +158,11 @@ export class TuiDriver extends EventEmitter {
|
|
|
130
158
|
this.debug = options.debug ?? false;
|
|
131
159
|
this.onSnapshot = options.onSnapshot;
|
|
132
160
|
this.onSignals = options.onSignals;
|
|
161
|
+
this.hasExplicitSessionCwd = options.cwd !== undefined;
|
|
133
162
|
this.sessionCwd = options.cwd ?? process.cwd();
|
|
163
|
+
this.expectedSessionId = String(options.expectedSessionId || "").trim();
|
|
164
|
+
this.sessionDetectStartSec = Math.floor(Date.now() / 1000);
|
|
165
|
+
this.sessionDetectBaselineIds = [];
|
|
134
166
|
this.initialCommand = this.profile.command;
|
|
135
167
|
this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
|
|
136
168
|
|
|
@@ -250,6 +282,43 @@ export class TuiDriver extends EventEmitter {
|
|
|
250
282
|
return { ...summary };
|
|
251
283
|
}
|
|
252
284
|
|
|
285
|
+
async getSessionFileSize(sessionInfo?: TuiSessionInfo | null): Promise<number> {
|
|
286
|
+
const resolvedSessionInfo = sessionInfo ?? this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
|
|
287
|
+
if (!resolvedSessionInfo) {
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
const { size } = await this.readSessionFileStat(resolvedSessionInfo.sessionFilePath);
|
|
291
|
+
return size;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async readSessionAssistantMessagesSince(
|
|
295
|
+
sessionInfo: TuiSessionInfo,
|
|
296
|
+
startOffset = 0,
|
|
297
|
+
): Promise<TuiSessionAssistantMessageBatch> {
|
|
298
|
+
const resolvedSessionInfo = {
|
|
299
|
+
backend: String(sessionInfo?.backend || "").trim(),
|
|
300
|
+
sessionId: String(sessionInfo?.sessionId || "").trim(),
|
|
301
|
+
sessionFilePath: String(sessionInfo?.sessionFilePath || "").trim(),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const readResult = await this.readSessionFileLinesSince(
|
|
305
|
+
resolvedSessionInfo.sessionFilePath,
|
|
306
|
+
startOffset,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
backend: resolvedSessionInfo.backend,
|
|
311
|
+
sessionId: resolvedSessionInfo.sessionId,
|
|
312
|
+
sessionFilePath: resolvedSessionInfo.sessionFilePath,
|
|
313
|
+
nextOffset: readResult.nextOffset,
|
|
314
|
+
fileSize: readResult.fileSize,
|
|
315
|
+
messages: this.extractAssistantMessagesFromJsonLines(
|
|
316
|
+
readResult.lines,
|
|
317
|
+
resolvedSessionInfo,
|
|
318
|
+
),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
253
322
|
async boot(): Promise<void> {
|
|
254
323
|
if (this.isKilled) {
|
|
255
324
|
throw this.createSessionClosedError();
|
|
@@ -261,6 +330,8 @@ export class TuiDriver extends EventEmitter {
|
|
|
261
330
|
|
|
262
331
|
this.stateMachine.transition("BOOT");
|
|
263
332
|
this.sessionInfo = null;
|
|
333
|
+
this.sessionDetectStartSec = Math.floor(Date.now() / 1000);
|
|
334
|
+
this.sessionDetectBaselineIds = await this.captureSessionBaseline();
|
|
264
335
|
this.pty.spawn();
|
|
265
336
|
|
|
266
337
|
const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
|
|
@@ -450,7 +521,16 @@ export class TuiDriver extends EventEmitter {
|
|
|
450
521
|
|
|
451
522
|
// 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
|
|
452
523
|
const beforeSnapshot = this.captureSnapshot("after_submit");
|
|
453
|
-
const sessionCheckpoint = await this.
|
|
524
|
+
const sessionCheckpoint = await this.resolveSessionFileCheckpointForTurn(sessionInfo);
|
|
525
|
+
if (sessionCheckpoint) {
|
|
526
|
+
sessionInfo = sessionCheckpoint.sessionInfo;
|
|
527
|
+
}
|
|
528
|
+
if (!sessionCheckpoint && this.requiresSessionFileCheckpointForTurn()) {
|
|
529
|
+
if (this.profile.name === "copilot") {
|
|
530
|
+
throw new Error("Copilot session file checkpoint unavailable");
|
|
531
|
+
}
|
|
532
|
+
throw new Error("Codex session file checkpoint unavailable");
|
|
533
|
+
}
|
|
454
534
|
|
|
455
535
|
this.stateMachine.transition("WAIT_STREAM_START");
|
|
456
536
|
if (sessionCheckpoint) {
|
|
@@ -469,9 +549,12 @@ export class TuiDriver extends EventEmitter {
|
|
|
469
549
|
this.stateMachine.transition("CAPTURE");
|
|
470
550
|
const afterSnapshot = this.captureSnapshot("after_response");
|
|
471
551
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
552
|
+
let answer = "";
|
|
553
|
+
if (sessionCheckpoint) {
|
|
554
|
+
answer = await this.extractAnswerFromSessionFile(sessionCheckpoint);
|
|
555
|
+
} else {
|
|
556
|
+
answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
|
|
557
|
+
}
|
|
475
558
|
const signals = this.getSignals(afterSnapshot);
|
|
476
559
|
|
|
477
560
|
this.stateMachine.transition("DONE");
|
|
@@ -584,6 +667,84 @@ export class TuiDriver extends EventEmitter {
|
|
|
584
667
|
}
|
|
585
668
|
}
|
|
586
669
|
|
|
670
|
+
private getPinnedSessionIds(): string[] {
|
|
671
|
+
return Array.from(
|
|
672
|
+
new Set(
|
|
673
|
+
[
|
|
674
|
+
this.expectedSessionId,
|
|
675
|
+
this.sessionInfo?.sessionId,
|
|
676
|
+
this.lastSessionInfo?.sessionId,
|
|
677
|
+
]
|
|
678
|
+
.map((value) => String(value || "").trim())
|
|
679
|
+
.filter(Boolean),
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private toSessionInfo(
|
|
685
|
+
backend: TuiSessionInfo["backend"],
|
|
686
|
+
candidate: SessionDiscoveryCandidate | null,
|
|
687
|
+
): TuiSessionInfo | null {
|
|
688
|
+
if (!candidate) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
backend,
|
|
693
|
+
sessionId: candidate.sessionId,
|
|
694
|
+
sessionFilePath: candidate.sessionFilePath,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private pickSessionDiscoveryCandidate(
|
|
699
|
+
candidates: SessionDiscoveryCandidate[],
|
|
700
|
+
): SessionDiscoveryCandidate | null {
|
|
701
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const pinnedIds = this.getPinnedSessionIds();
|
|
706
|
+
for (const pinnedId of pinnedIds) {
|
|
707
|
+
const match = candidates.find((candidate) => candidate.sessionId === pinnedId);
|
|
708
|
+
if (match) {
|
|
709
|
+
return match;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (this.sessionDetectBaselineIds.length === 0) {
|
|
714
|
+
return candidates[0] ?? null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const baselineIds = new Set(this.sessionDetectBaselineIds);
|
|
718
|
+
return candidates.find((candidate) => !baselineIds.has(candidate.sessionId)) ?? null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private collectBaselineSessionIds(candidates: SessionDiscoveryCandidate[]): string[] {
|
|
722
|
+
return Array.from(
|
|
723
|
+
new Set(
|
|
724
|
+
candidates
|
|
725
|
+
.map((candidate) => String(candidate.sessionId || "").trim())
|
|
726
|
+
.filter(Boolean),
|
|
727
|
+
),
|
|
728
|
+
).slice(0, 256);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private async captureSessionBaseline(): Promise<string[]> {
|
|
732
|
+
if (this.getPinnedSessionIds().length > 0) {
|
|
733
|
+
return [];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
switch (this.profile.name) {
|
|
737
|
+
case "codex":
|
|
738
|
+
return this.captureCodexSessionBaseline();
|
|
739
|
+
case "claude-code":
|
|
740
|
+
return this.captureClaudeSessionBaseline();
|
|
741
|
+
case "copilot":
|
|
742
|
+
return this.captureCopilotSessionBaseline();
|
|
743
|
+
default:
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
587
748
|
private async detectCodexSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
588
749
|
const dbPath = join(homedir(), ".codex", "state_5.sqlite");
|
|
589
750
|
if (!(await this.pathExists(dbPath))) {
|
|
@@ -607,9 +768,10 @@ export class TuiDriver extends EventEmitter {
|
|
|
607
768
|
};
|
|
608
769
|
};
|
|
609
770
|
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
771
|
+
const candidateSessionIds = this.getPinnedSessionIds();
|
|
772
|
+
|
|
773
|
+
for (const candidateSessionId of candidateSessionIds) {
|
|
774
|
+
const escapedSessionId = candidateSessionId.replace(/'/g, "''");
|
|
613
775
|
const pinnedRow = await this.querySqliteRow(
|
|
614
776
|
dbPath,
|
|
615
777
|
`select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`,
|
|
@@ -620,15 +782,59 @@ export class TuiDriver extends EventEmitter {
|
|
|
620
782
|
}
|
|
621
783
|
}
|
|
622
784
|
|
|
623
|
-
const
|
|
785
|
+
const conditions = [
|
|
786
|
+
"source='cli'",
|
|
787
|
+
"model_provider='openai'",
|
|
788
|
+
`created_at >= ${Math.max(0, this.sessionDetectStartSec)}`,
|
|
789
|
+
];
|
|
790
|
+
if (this.hasExplicitSessionCwd) {
|
|
791
|
+
conditions.push(`cwd='${this.sessionCwd.replace(/'/g, "''")}'`);
|
|
792
|
+
}
|
|
793
|
+
if (this.sessionDetectBaselineIds.length > 0) {
|
|
794
|
+
const escapedBaselineIds = this.sessionDetectBaselineIds
|
|
795
|
+
.map((sessionId) => `'${sessionId.replace(/'/g, "''")}'`)
|
|
796
|
+
.join(", ");
|
|
797
|
+
conditions.push(`id not in (${escapedBaselineIds})`);
|
|
798
|
+
}
|
|
799
|
+
|
|
624
800
|
const row = await this.querySqliteRow(
|
|
625
801
|
dbPath,
|
|
626
|
-
`select id, rollout_path from threads where
|
|
802
|
+
`select id, rollout_path from threads where ${conditions.join(
|
|
803
|
+
" and ",
|
|
804
|
+
)} order by created_at asc, id asc limit 1;`,
|
|
627
805
|
);
|
|
628
806
|
return parseRowAsSessionInfo(row);
|
|
629
807
|
}
|
|
630
808
|
|
|
631
|
-
private async
|
|
809
|
+
private async captureCodexSessionBaseline(): Promise<string[]> {
|
|
810
|
+
if (String(this.profile.name || "").toLowerCase() !== "codex") {
|
|
811
|
+
return [];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const dbPath = join(homedir(), ".codex", "state_5.sqlite");
|
|
815
|
+
if (!(await this.pathExists(dbPath))) {
|
|
816
|
+
return [];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const conditions = [
|
|
820
|
+
"source='cli'",
|
|
821
|
+
"model_provider='openai'",
|
|
822
|
+
`created_at >= ${Math.max(0, this.sessionDetectStartSec)}`,
|
|
823
|
+
];
|
|
824
|
+
if (this.hasExplicitSessionCwd) {
|
|
825
|
+
conditions.push(`cwd='${this.sessionCwd.replace(/'/g, "''")}'`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const rows = await this.querySqliteRows(
|
|
829
|
+
dbPath,
|
|
830
|
+
`select id from threads where ${conditions.join(" and ")} order by created_at asc, id asc limit 256;`,
|
|
831
|
+
);
|
|
832
|
+
return rows
|
|
833
|
+
.map((row) => String(row || "").trim())
|
|
834
|
+
.filter(Boolean);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private async collectClaudeSessionCandidates(): Promise<SessionDiscoveryCandidate[]> {
|
|
632
838
|
const projectDir = join(
|
|
633
839
|
homedir(),
|
|
634
840
|
".claude",
|
|
@@ -636,9 +842,27 @@ export class TuiDriver extends EventEmitter {
|
|
|
636
842
|
this.encodeClaudeProjectPath(this.sessionCwd),
|
|
637
843
|
);
|
|
638
844
|
if (!(await this.pathExists(projectDir))) {
|
|
639
|
-
return
|
|
845
|
+
return [];
|
|
640
846
|
}
|
|
641
847
|
|
|
848
|
+
const candidates: SessionDiscoveryCandidate[] = [];
|
|
849
|
+
const seenSessionIds = new Set<string>();
|
|
850
|
+
const pushCandidate = async (sessionId: string, sessionFilePath: string) => {
|
|
851
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
852
|
+
const normalizedSessionFilePath = String(sessionFilePath || "").trim();
|
|
853
|
+
if (!normalizedSessionId || !normalizedSessionFilePath || seenSessionIds.has(normalizedSessionId)) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!(await this.pathExists(normalizedSessionFilePath))) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
seenSessionIds.add(normalizedSessionId);
|
|
860
|
+
candidates.push({
|
|
861
|
+
sessionId: normalizedSessionId,
|
|
862
|
+
sessionFilePath: normalizedSessionFilePath,
|
|
863
|
+
});
|
|
864
|
+
};
|
|
865
|
+
|
|
642
866
|
const indexPath = join(projectDir, "sessions-index.json");
|
|
643
867
|
if (await this.pathExists(indexPath)) {
|
|
644
868
|
try {
|
|
@@ -671,13 +895,7 @@ export class TuiDriver extends EventEmitter {
|
|
|
671
895
|
for (const entry of candidates) {
|
|
672
896
|
const sessionId = String(entry.sessionId || "").trim();
|
|
673
897
|
const sessionFilePath = String(entry.fullPath || "").trim() || join(projectDir, `${sessionId}.jsonl`);
|
|
674
|
-
|
|
675
|
-
return {
|
|
676
|
-
backend: "claude-code",
|
|
677
|
-
sessionId,
|
|
678
|
-
sessionFilePath,
|
|
679
|
-
};
|
|
680
|
-
}
|
|
898
|
+
await pushCandidate(sessionId, sessionFilePath);
|
|
681
899
|
}
|
|
682
900
|
} catch (error) {
|
|
683
901
|
this.log(`claude session index parse failed: ${(error as Error)?.message || error}`);
|
|
@@ -696,25 +914,29 @@ export class TuiDriver extends EventEmitter {
|
|
|
696
914
|
})),
|
|
697
915
|
);
|
|
698
916
|
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
return null;
|
|
917
|
+
for (const item of stats) {
|
|
918
|
+
await pushCandidate(basename(item.filePath, ".jsonl"), item.filePath);
|
|
702
919
|
}
|
|
703
|
-
const sessionId = basename(latest.filePath, ".jsonl");
|
|
704
|
-
return {
|
|
705
|
-
backend: "claude-code",
|
|
706
|
-
sessionId,
|
|
707
|
-
sessionFilePath: latest.filePath,
|
|
708
|
-
};
|
|
709
920
|
} catch {
|
|
710
|
-
return
|
|
921
|
+
return candidates;
|
|
711
922
|
}
|
|
923
|
+
|
|
924
|
+
return candidates;
|
|
712
925
|
}
|
|
713
926
|
|
|
714
|
-
private async
|
|
927
|
+
private async captureClaudeSessionBaseline(): Promise<string[]> {
|
|
928
|
+
return this.collectBaselineSessionIds(await this.collectClaudeSessionCandidates());
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private async detectClaudeSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
932
|
+
const candidates = await this.collectClaudeSessionCandidates();
|
|
933
|
+
return this.toSessionInfo("claude-code", this.pickSessionDiscoveryCandidate(candidates));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private async collectCopilotSessionCandidates(): Promise<SessionDiscoveryCandidate[]> {
|
|
715
937
|
const baseDir = join(homedir(), ".copilot", "session-state");
|
|
716
938
|
if (!(await this.pathExists(baseDir))) {
|
|
717
|
-
return
|
|
939
|
+
return [];
|
|
718
940
|
}
|
|
719
941
|
|
|
720
942
|
try {
|
|
@@ -753,37 +975,45 @@ export class TuiDriver extends EventEmitter {
|
|
|
753
975
|
}
|
|
754
976
|
|
|
755
977
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
return {
|
|
761
|
-
backend: "copilot",
|
|
762
|
-
sessionId: latest.sessionId,
|
|
763
|
-
sessionFilePath: latest.sessionFilePath,
|
|
764
|
-
};
|
|
978
|
+
return candidates.map((candidate) => ({
|
|
979
|
+
sessionId: candidate.sessionId,
|
|
980
|
+
sessionFilePath: candidate.sessionFilePath,
|
|
981
|
+
}));
|
|
765
982
|
} catch {
|
|
766
|
-
return
|
|
983
|
+
return [];
|
|
767
984
|
}
|
|
768
985
|
}
|
|
769
986
|
|
|
770
|
-
private async
|
|
987
|
+
private async captureCopilotSessionBaseline(): Promise<string[]> {
|
|
988
|
+
return this.collectBaselineSessionIds(await this.collectCopilotSessionCandidates());
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private async detectCopilotSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
992
|
+
const candidates = await this.collectCopilotSessionCandidates();
|
|
993
|
+
return this.toSessionInfo("copilot", this.pickSessionDiscoveryCandidate(candidates));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private async querySqliteRows(dbPath: string, query: string): Promise<string[]> {
|
|
771
997
|
try {
|
|
772
998
|
const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
|
|
773
999
|
timeout: 3000,
|
|
774
1000
|
maxBuffer: 1024 * 1024,
|
|
775
1001
|
});
|
|
776
|
-
|
|
1002
|
+
return String(stdout || "")
|
|
777
1003
|
.split(/\r?\n/)
|
|
778
1004
|
.map((line) => line.trim())
|
|
779
1005
|
.filter(Boolean);
|
|
780
|
-
return lines[0] ?? null;
|
|
781
1006
|
} catch (error) {
|
|
782
1007
|
this.log(`sqlite query failed: ${(error as Error)?.message || error}`);
|
|
783
|
-
return
|
|
1008
|
+
return [];
|
|
784
1009
|
}
|
|
785
1010
|
}
|
|
786
1011
|
|
|
1012
|
+
private async querySqliteRow(dbPath: string, query: string): Promise<string | null> {
|
|
1013
|
+
const rows = await this.querySqliteRows(dbPath, query);
|
|
1014
|
+
return rows[0] ?? null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
787
1017
|
private encodeClaudeProjectPath(cwd: string): string {
|
|
788
1018
|
return String(cwd || "").replace(/\//g, "-");
|
|
789
1019
|
}
|
|
@@ -829,6 +1059,40 @@ export class TuiDriver extends EventEmitter {
|
|
|
829
1059
|
};
|
|
830
1060
|
}
|
|
831
1061
|
|
|
1062
|
+
private requiresSessionFileCheckpointForTurn(): boolean {
|
|
1063
|
+
const backend = String(this.profile.name || "").toLowerCase();
|
|
1064
|
+
return backend === "codex" || backend === "copilot";
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private async resolveSessionFileCheckpointForTurn(
|
|
1068
|
+
initialSessionInfo: TuiSessionInfo | null,
|
|
1069
|
+
): Promise<SessionFileCheckpoint | null> {
|
|
1070
|
+
const immediate = await this.captureSessionFileCheckpoint(initialSessionInfo);
|
|
1071
|
+
if (immediate || !this.requiresSessionFileCheckpointForTurn()) {
|
|
1072
|
+
return immediate;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const startedAt = Date.now();
|
|
1076
|
+
while (Date.now() - startedAt < DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS) {
|
|
1077
|
+
this.assertAliveOrThrow();
|
|
1078
|
+
const elapsed = Date.now() - startedAt;
|
|
1079
|
+
const remainingMs = Math.max(
|
|
1080
|
+
MIN_STAGE_TIMEOUT_MS,
|
|
1081
|
+
DEFAULT_SESSION_CHECKPOINT_TIMEOUT_MS - elapsed,
|
|
1082
|
+
);
|
|
1083
|
+
const discovered = await this.ensureSessionInfo(
|
|
1084
|
+
Math.min(DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS, remainingMs),
|
|
1085
|
+
);
|
|
1086
|
+
const checkpoint = await this.captureSessionFileCheckpoint(discovered);
|
|
1087
|
+
if (checkpoint) {
|
|
1088
|
+
return checkpoint;
|
|
1089
|
+
}
|
|
1090
|
+
await this.sleep(250);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
832
1096
|
private async readSessionFileStat(
|
|
833
1097
|
sessionFilePath: string,
|
|
834
1098
|
): Promise<{ size: number; mtimeMs: number }> {
|
|
@@ -876,7 +1140,11 @@ export class TuiDriver extends EventEmitter {
|
|
|
876
1140
|
let observedProgress = false;
|
|
877
1141
|
let unchangedChecks = 0;
|
|
878
1142
|
const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
|
|
1143
|
+
const allowAssistantReplyFallback = this.supportsSessionAssistantReplyIdleFallback(
|
|
1144
|
+
checkpoint.sessionInfo.backend,
|
|
1145
|
+
);
|
|
879
1146
|
let completionMarkerSeen = false;
|
|
1147
|
+
let assistantReplySeen = false;
|
|
880
1148
|
|
|
881
1149
|
while (Date.now() - startedAt < timeoutMs) {
|
|
882
1150
|
this.assertAliveOrThrow();
|
|
@@ -898,15 +1166,23 @@ export class TuiDriver extends EventEmitter {
|
|
|
898
1166
|
);
|
|
899
1167
|
}
|
|
900
1168
|
}
|
|
1169
|
+
if (allowAssistantReplyFallback && !assistantReplySeen) {
|
|
1170
|
+
assistantReplySeen = await this.hasSessionAssistantReply(checkpoint, current.size);
|
|
1171
|
+
if (assistantReplySeen) {
|
|
1172
|
+
this.log(
|
|
1173
|
+
`session assistant reply observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`,
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
901
1177
|
} else {
|
|
902
1178
|
unchangedChecks += 1;
|
|
903
1179
|
this.log(
|
|
904
|
-
`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
|
|
1180
|
+
`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`,
|
|
905
1181
|
);
|
|
906
1182
|
if (observedProgress && unchangedChecks >= 2) {
|
|
907
|
-
if (!requireCompletionMarker || completionMarkerSeen) {
|
|
1183
|
+
if (!requireCompletionMarker || completionMarkerSeen || assistantReplySeen) {
|
|
908
1184
|
this.log(
|
|
909
|
-
`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
|
|
1185
|
+
`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen} assistantReplySeen=${assistantReplySeen}`,
|
|
910
1186
|
);
|
|
911
1187
|
return;
|
|
912
1188
|
}
|
|
@@ -919,6 +1195,16 @@ export class TuiDriver extends EventEmitter {
|
|
|
919
1195
|
throw new Error("Stream end timeout: session file did not grow");
|
|
920
1196
|
}
|
|
921
1197
|
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
1198
|
+
if (allowAssistantReplyFallback) {
|
|
1199
|
+
const assistantReplyAvailable =
|
|
1200
|
+
assistantReplySeen || (await this.hasSessionAssistantReply(checkpoint));
|
|
1201
|
+
if (assistantReplyAvailable) {
|
|
1202
|
+
this.log(
|
|
1203
|
+
`session completion marker missing; falling back to assistant reply: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`,
|
|
1204
|
+
);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
922
1208
|
throw new Error("Stream end timeout: session completion marker not observed");
|
|
923
1209
|
}
|
|
924
1210
|
throw new Error("Stream end timeout: session file did not become stable");
|
|
@@ -1177,6 +1463,10 @@ export class TuiDriver extends EventEmitter {
|
|
|
1177
1463
|
return backend === "codex" || backend === "copilot";
|
|
1178
1464
|
}
|
|
1179
1465
|
|
|
1466
|
+
private supportsSessionAssistantReplyIdleFallback(backend: string): boolean {
|
|
1467
|
+
return backend === "codex";
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1180
1470
|
private async hasSessionCompletionMarker(
|
|
1181
1471
|
checkpoint: SessionFileCheckpoint,
|
|
1182
1472
|
endOffset?: number,
|
|
@@ -1218,6 +1508,63 @@ export class TuiDriver extends EventEmitter {
|
|
|
1218
1508
|
.filter(Boolean);
|
|
1219
1509
|
}
|
|
1220
1510
|
|
|
1511
|
+
private async readSessionFileLinesSince(
|
|
1512
|
+
sessionFilePath: string,
|
|
1513
|
+
startOffset = 0,
|
|
1514
|
+
): Promise<{ lines: string[]; nextOffset: number; fileSize: number }> {
|
|
1515
|
+
let fullBuffer: Buffer;
|
|
1516
|
+
try {
|
|
1517
|
+
fullBuffer = await fs.readFile(sessionFilePath);
|
|
1518
|
+
} catch {
|
|
1519
|
+
return {
|
|
1520
|
+
lines: [],
|
|
1521
|
+
nextOffset: Math.max(0, startOffset),
|
|
1522
|
+
fileSize: 0,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const fileSize = fullBuffer.length;
|
|
1527
|
+
const boundedStartOffset = Math.max(0, Math.min(startOffset, fileSize));
|
|
1528
|
+
const pendingBuffer = fullBuffer.subarray(boundedStartOffset);
|
|
1529
|
+
const lastNewlineIndex = pendingBuffer.lastIndexOf(0x0a);
|
|
1530
|
+
if (lastNewlineIndex < 0) {
|
|
1531
|
+
return {
|
|
1532
|
+
lines: [],
|
|
1533
|
+
nextOffset: boundedStartOffset,
|
|
1534
|
+
fileSize,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const completedBuffer = pendingBuffer.subarray(0, lastNewlineIndex + 1);
|
|
1539
|
+
const nextOffset = boundedStartOffset + completedBuffer.length;
|
|
1540
|
+
const lines = completedBuffer
|
|
1541
|
+
.toString("utf8")
|
|
1542
|
+
.split(/\r?\n/)
|
|
1543
|
+
.map((line) => line.trim())
|
|
1544
|
+
.filter(Boolean);
|
|
1545
|
+
|
|
1546
|
+
return {
|
|
1547
|
+
lines,
|
|
1548
|
+
nextOffset,
|
|
1549
|
+
fileSize,
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private async hasSessionAssistantReply(
|
|
1554
|
+
checkpoint: SessionFileCheckpoint,
|
|
1555
|
+
endOffset?: number,
|
|
1556
|
+
): Promise<boolean> {
|
|
1557
|
+
if (!this.supportsSessionAssistantReplyIdleFallback(checkpoint.sessionInfo.backend)) {
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
const lines = await this.readSessionFileJsonLines(
|
|
1561
|
+
checkpoint.sessionInfo.sessionFilePath,
|
|
1562
|
+
checkpoint.size,
|
|
1563
|
+
endOffset,
|
|
1564
|
+
);
|
|
1565
|
+
return Boolean(this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend));
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1221
1568
|
private extractAssistantReplyFromJsonLines(lines: string[], backend: string): string {
|
|
1222
1569
|
const replies: string[] = [];
|
|
1223
1570
|
|
|
@@ -1248,6 +1595,48 @@ export class TuiDriver extends EventEmitter {
|
|
|
1248
1595
|
return replies.length > 0 ? replies[replies.length - 1] : "";
|
|
1249
1596
|
}
|
|
1250
1597
|
|
|
1598
|
+
private extractAssistantMessagesFromJsonLines(
|
|
1599
|
+
lines: string[],
|
|
1600
|
+
sessionInfo: TuiSessionInfo,
|
|
1601
|
+
): TuiSessionAssistantMessage[] {
|
|
1602
|
+
const messages: TuiSessionAssistantMessage[] = [];
|
|
1603
|
+
|
|
1604
|
+
for (const line of lines) {
|
|
1605
|
+
let entry: Record<string, unknown> | null = null;
|
|
1606
|
+
try {
|
|
1607
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1608
|
+
} catch {
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
if (!entry || typeof entry !== "object") {
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const text =
|
|
1616
|
+
sessionInfo.backend === "codex"
|
|
1617
|
+
? this.extractCodexAssistantText(entry)
|
|
1618
|
+
: sessionInfo.backend === "claude-code"
|
|
1619
|
+
? this.extractClaudeAssistantText(entry)
|
|
1620
|
+
: sessionInfo.backend === "copilot"
|
|
1621
|
+
? this.extractCopilotAssistantText(entry)
|
|
1622
|
+
: "";
|
|
1623
|
+
if (!text) {
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const timestampRaw = typeof entry.timestamp === "string" ? entry.timestamp.trim() : "";
|
|
1628
|
+
messages.push({
|
|
1629
|
+
backend: sessionInfo.backend,
|
|
1630
|
+
sessionId: sessionInfo.sessionId,
|
|
1631
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
1632
|
+
text,
|
|
1633
|
+
timestamp: timestampRaw || undefined,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
return messages;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1251
1640
|
private hasCodexTaskCompleteFromJsonLines(lines: string[]): boolean {
|
|
1252
1641
|
for (const line of lines) {
|
|
1253
1642
|
let entry: Record<string, unknown> | null = null;
|
|
@@ -1264,18 +1653,39 @@ export class TuiDriver extends EventEmitter {
|
|
|
1264
1653
|
}
|
|
1265
1654
|
|
|
1266
1655
|
private hasCopilotTurnEndFromJsonLines(lines: string[]): boolean {
|
|
1267
|
-
|
|
1656
|
+
let latestAssistantMessageIndex = -1;
|
|
1657
|
+
let latestCompletedTurnEndIndex = -1;
|
|
1658
|
+
|
|
1659
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1660
|
+
const line = lines[index];
|
|
1268
1661
|
let entry: Record<string, unknown> | null = null;
|
|
1269
1662
|
try {
|
|
1270
1663
|
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1271
1664
|
} catch {
|
|
1272
1665
|
continue;
|
|
1273
1666
|
}
|
|
1274
|
-
if (entry
|
|
1275
|
-
|
|
1667
|
+
if (!entry) {
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
if (this.isCopilotTurnStartEntry(entry) && latestCompletedTurnEndIndex >= 0) {
|
|
1672
|
+
latestCompletedTurnEndIndex = -1;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const assistantText = this.extractCopilotAssistantText(entry);
|
|
1676
|
+
if (assistantText) {
|
|
1677
|
+
latestAssistantMessageIndex = index;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (
|
|
1681
|
+
this.isCopilotTurnEndEntry(entry) &&
|
|
1682
|
+
latestAssistantMessageIndex >= 0 &&
|
|
1683
|
+
index > latestAssistantMessageIndex
|
|
1684
|
+
) {
|
|
1685
|
+
latestCompletedTurnEndIndex = index;
|
|
1276
1686
|
}
|
|
1277
1687
|
}
|
|
1278
|
-
return
|
|
1688
|
+
return latestCompletedTurnEndIndex >= 0;
|
|
1279
1689
|
}
|
|
1280
1690
|
|
|
1281
1691
|
private extractCodexTaskCompleteMessageFromJsonLines(lines: string[]): string {
|
|
@@ -1307,6 +1717,10 @@ export class TuiDriver extends EventEmitter {
|
|
|
1307
1717
|
return payload?.type === "task_complete";
|
|
1308
1718
|
}
|
|
1309
1719
|
|
|
1720
|
+
private isCopilotTurnStartEntry(entry: Record<string, unknown>): boolean {
|
|
1721
|
+
return entry.type === "assistant.turn_start";
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1310
1724
|
private isCopilotTurnEndEntry(entry: Record<string, unknown>): boolean {
|
|
1311
1725
|
return entry.type === "assistant.turn_end";
|
|
1312
1726
|
}
|