@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.
@@ -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.captureSessionFileCheckpoint(sessionInfo);
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
- const answer = sessionCheckpoint
341
- ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
342
- : this.extractAnswer(beforeSnapshot, afterSnapshot);
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 pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
465
- if (pinnedSessionId) {
466
- const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
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 escapedCwd = this.sessionCwd.replace(/'/g, "''");
474
- const row = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and cwd='${escapedCwd}' order by updated_at desc limit 1;`);
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 detectClaudeSessionInfo() {
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 null;
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
- if (sessionId && sessionFilePath && (await this.pathExists(sessionFilePath))) {
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 latest = stats[0];
529
- if (!latest) {
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 null;
682
+ return candidates;
541
683
  }
684
+ return candidates;
542
685
  }
543
- async detectCopilotSessionInfo() {
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 null;
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
- const latest = candidates[0];
580
- if (!latest) {
581
- return null;
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 null;
735
+ return [];
591
736
  }
592
737
  }
593
- async querySqliteRow(dbPath, query) {
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
- const lines = String(stdout || "")
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 null;
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
- for (const line of lines) {
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 && this.isCopilotTurnEndEntry(entry)) {
1012
- return true;
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 false;
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
  }