@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.
@@ -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.captureSessionFileCheckpoint(sessionInfo);
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
- const answer = sessionCheckpoint
473
- ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
474
- : this.extractAnswer(beforeSnapshot, afterSnapshot);
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 pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
611
- if (pinnedSessionId) {
612
- const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
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 escapedCwd = this.sessionCwd.replace(/'/g, "''");
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 source='cli' and model_provider='openai' and cwd='${escapedCwd}' order by updated_at desc limit 1;`,
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 detectClaudeSessionInfo(): Promise<TuiSessionInfo | null> {
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 null;
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
- if (sessionId && sessionFilePath && (await this.pathExists(sessionFilePath))) {
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 latest = stats[0];
700
- if (!latest) {
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 null;
921
+ return candidates;
711
922
  }
923
+
924
+ return candidates;
712
925
  }
713
926
 
714
- private async detectCopilotSessionInfo(): Promise<TuiSessionInfo | null> {
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 null;
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
- const latest = candidates[0];
757
- if (!latest) {
758
- return null;
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 null;
983
+ return [];
767
984
  }
768
985
  }
769
986
 
770
- private async querySqliteRow(dbPath: string, query: string): Promise<string | null> {
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
- const lines = String(stdout || "")
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 null;
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
- for (const line of lines) {
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 && this.isCopilotTurnEndEntry(entry)) {
1275
- return true;
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 false;
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
  }