@love-moon/tui-driver 0.2.11 → 0.2.13

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.
Files changed (73) hide show
  1. package/dist/driver/TuiDriver.d.ts +73 -0
  2. package/dist/driver/TuiDriver.d.ts.map +1 -1
  3. package/dist/driver/TuiDriver.js +1122 -42
  4. package/dist/driver/TuiDriver.js.map +1 -1
  5. package/dist/driver/TuiProfile.d.ts +2 -0
  6. package/dist/driver/TuiProfile.d.ts.map +1 -1
  7. package/dist/driver/TuiProfile.js.map +1 -1
  8. package/dist/driver/behavior/claude.behavior.d.ts +4 -0
  9. package/dist/driver/behavior/claude.behavior.d.ts.map +1 -0
  10. package/dist/driver/behavior/claude.behavior.js +48 -0
  11. package/dist/driver/behavior/claude.behavior.js.map +1 -0
  12. package/dist/driver/behavior/copilot.behavior.d.ts +4 -0
  13. package/dist/driver/behavior/copilot.behavior.d.ts.map +1 -0
  14. package/dist/driver/behavior/copilot.behavior.js +52 -0
  15. package/dist/driver/behavior/copilot.behavior.js.map +1 -0
  16. package/dist/driver/behavior/default.behavior.d.ts +4 -0
  17. package/dist/driver/behavior/default.behavior.d.ts.map +1 -0
  18. package/dist/driver/behavior/default.behavior.js +13 -0
  19. package/dist/driver/behavior/default.behavior.js.map +1 -0
  20. package/dist/driver/behavior/index.d.ts +5 -0
  21. package/dist/driver/behavior/index.d.ts.map +1 -0
  22. package/dist/driver/behavior/index.js +10 -0
  23. package/dist/driver/behavior/index.js.map +1 -0
  24. package/dist/driver/behavior/types.d.ts +57 -0
  25. package/dist/driver/behavior/types.d.ts.map +1 -0
  26. package/dist/driver/behavior/types.js +3 -0
  27. package/dist/driver/behavior/types.js.map +1 -0
  28. package/dist/driver/index.d.ts +4 -1
  29. package/dist/driver/index.d.ts.map +1 -1
  30. package/dist/driver/index.js +5 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -1
  33. package/dist/driver/profiles/claudeCode.profile.js +7 -3
  34. package/dist/driver/profiles/claudeCode.profile.js.map +1 -1
  35. package/dist/driver/profiles/copilot.profile.d.ts.map +1 -1
  36. package/dist/driver/profiles/copilot.profile.js +4 -0
  37. package/dist/driver/profiles/copilot.profile.js.map +1 -1
  38. package/dist/extract/OutputExtractor.d.ts +16 -0
  39. package/dist/extract/OutputExtractor.d.ts.map +1 -1
  40. package/dist/extract/OutputExtractor.js +113 -5
  41. package/dist/extract/OutputExtractor.js.map +1 -1
  42. package/dist/index.d.ts +2 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/pty/PtySession.d.ts +1 -0
  47. package/dist/pty/PtySession.d.ts.map +1 -1
  48. package/dist/pty/PtySession.js +9 -0
  49. package/dist/pty/PtySession.js.map +1 -1
  50. package/docs/how-to-add-a-new-backend.md +212 -0
  51. package/package.json +1 -1
  52. package/src/driver/TuiDriver.ts +1332 -45
  53. package/src/driver/TuiProfile.ts +3 -0
  54. package/src/driver/behavior/claude.behavior.ts +54 -0
  55. package/src/driver/behavior/copilot.behavior.ts +63 -0
  56. package/src/driver/behavior/default.behavior.ts +12 -0
  57. package/src/driver/behavior/index.ts +14 -0
  58. package/src/driver/behavior/types.ts +64 -0
  59. package/src/driver/index.ts +20 -1
  60. package/src/driver/profiles/claudeCode.profile.ts +7 -3
  61. package/src/driver/profiles/copilot.profile.ts +4 -0
  62. package/src/extract/OutputExtractor.ts +145 -5
  63. package/src/index.ts +15 -0
  64. package/src/pty/PtySession.ts +10 -0
  65. package/test/claude-profile.test.ts +41 -0
  66. package/test/claude-signals.test.ts +80 -0
  67. package/test/codex-session-discovery.test.ts +101 -0
  68. package/test/copilot-profile.test.ts +12 -0
  69. package/test/copilot-signals.test.ts +70 -0
  70. package/test/output-extractor.test.ts +79 -0
  71. package/test/session-file-extraction.test.ts +257 -0
  72. package/test/stream-detection.test.ts +28 -0
  73. package/test/timeout-resolution.test.ts +37 -0
@@ -1,4 +1,9 @@
1
1
  import { EventEmitter } from "events";
2
+ import { execFile } from "node:child_process";
3
+ import { promises as fs } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+ import { promisify } from "node:util";
2
7
  import { PtySession } from "../pty/PtySession.js";
3
8
  import { HeadlessScreen } from "../term/HeadlessScreen.js";
4
9
  import { ScreenSnapshot } from "../term/ScreenSnapshot.js";
@@ -8,9 +13,12 @@ import { OutputExtractor } from "../extract/OutputExtractor.js";
8
13
  import { computeLineDiff } from "../extract/Diff.js";
9
14
  import { TuiProfile, TuiSignals } from "./TuiProfile.js";
10
15
  import { StateMachine, TuiState } from "./StateMachine.js";
16
+ import { defaultTuiDriverBehavior } from "./behavior/index.js";
17
+ import type { TuiDriverBehavior } from "./behavior/index.js";
11
18
 
12
19
  export interface TuiDriverOptions {
13
20
  profile: TuiProfile;
21
+ cwd?: string;
14
22
  debug?: boolean;
15
23
  onSnapshot?: (snapshot: ScreenSnapshot, state: TuiState) => void;
16
24
  onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
@@ -30,6 +38,8 @@ export interface AskResult {
30
38
  replyInProgress?: boolean;
31
39
  statusLine?: string;
32
40
  statusDoneLine?: string;
41
+ sessionId?: string;
42
+ sessionFilePath?: string;
33
43
  }
34
44
 
35
45
  export type HealthReason =
@@ -57,32 +67,81 @@ export interface TuiScreenSignals {
57
67
  statusDoneLine?: string;
58
68
  }
59
69
 
70
+ export interface TuiSessionInfo {
71
+ backend: string;
72
+ sessionId: string;
73
+ sessionFilePath: string;
74
+ }
75
+
76
+ export interface TuiSessionUsageSummary {
77
+ backend: string;
78
+ sessionId: string;
79
+ sessionFilePath: string;
80
+ tokenUsagePercent?: number;
81
+ contextUsagePercent?: number;
82
+ }
83
+
84
+ interface SessionFileCheckpoint {
85
+ sessionInfo: TuiSessionInfo;
86
+ size: number;
87
+ mtimeMs: number;
88
+ }
89
+
90
+ interface SessionUsageCacheEntry {
91
+ backend: string;
92
+ sessionId: string;
93
+ sessionFilePath: string;
94
+ size: number;
95
+ mtimeMs: number;
96
+ summary: TuiSessionUsageSummary;
97
+ }
98
+
99
+ const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
100
+ const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
101
+ const MIN_STAGE_TIMEOUT_MS = 100;
102
+ const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
103
+ const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
104
+
105
+ const execFileAsync = promisify(execFile);
106
+
60
107
  export class TuiDriver extends EventEmitter {
61
108
  private pty: PtySession;
62
109
  private screen: HeadlessScreen;
63
110
  private expect: ExpectEngine;
64
111
  private stateMachine: StateMachine;
65
112
  private profile: TuiProfile;
113
+ private behavior: TuiDriverBehavior;
66
114
  private debug: boolean;
67
115
  private onSnapshot?: (snapshot: ScreenSnapshot, state: TuiState) => void;
68
116
  private onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
69
117
  private isBooted = false;
118
+ private isKilled = false;
119
+ private sessionCwd: string;
120
+ private sessionInfo: TuiSessionInfo | null = null;
121
+ private lastSessionInfo: TuiSessionInfo | null = null;
122
+ private sessionUsageCache: SessionUsageCacheEntry | null = null;
123
+ private initialCommand: string;
124
+ private initialArgs: string[];
70
125
 
71
126
  constructor(options: TuiDriverOptions) {
72
127
  super();
73
128
  this.profile = options.profile;
129
+ this.behavior = options.profile.behavior ?? defaultTuiDriverBehavior;
74
130
  this.debug = options.debug ?? false;
75
131
  this.onSnapshot = options.onSnapshot;
76
132
  this.onSignals = options.onSignals;
133
+ this.sessionCwd = options.cwd ?? process.cwd();
134
+ this.initialCommand = this.profile.command;
135
+ this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
77
136
 
78
137
  const cols = this.profile.cols ?? 120;
79
138
  const rows = this.profile.rows ?? 40;
80
139
  const scrollback = this.profile.scrollback ?? 5000;
81
140
 
82
141
  this.pty = new PtySession(
83
- this.profile.command,
84
- this.profile.args,
85
- { cols, rows, env: this.profile.env }
142
+ this.initialCommand,
143
+ this.initialArgs,
144
+ { cols, rows, env: this.profile.env, cwd: this.sessionCwd }
86
145
  );
87
146
 
88
147
  this.screen = new HeadlessScreen({
@@ -108,6 +167,7 @@ export class TuiDriver extends EventEmitter {
108
167
  this.pty.onExit((code, signal) => {
109
168
  this.log(`PTY exited: code=${code}, signal=${signal}`);
110
169
  this.isBooted = false;
170
+ this.sessionInfo = null;
111
171
  this.emit("exit", code, signal);
112
172
  });
113
173
 
@@ -136,12 +196,71 @@ export class TuiDriver extends EventEmitter {
136
196
  return this.pty.isRunning;
137
197
  }
138
198
 
199
+ getSessionInfo(): TuiSessionInfo | null {
200
+ const current = this.sessionInfo ?? this.lastSessionInfo;
201
+ return current ? { ...current } : null;
202
+ }
203
+
204
+ async ensureSessionInfo(timeoutMs = DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS): Promise<TuiSessionInfo | null> {
205
+ if (!this.supportsSessionFileTracking()) {
206
+ return null;
207
+ }
208
+ if (this.sessionInfo) {
209
+ return { ...this.sessionInfo };
210
+ }
211
+
212
+ const boundedTimeoutMs = this.resolveTimeout(timeoutMs, DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS);
213
+ const discovered = await this.discoverSessionInfo(boundedTimeoutMs);
214
+ return discovered ? { ...discovered } : null;
215
+ }
216
+
217
+ async getSessionUsageSummary(): Promise<TuiSessionUsageSummary | null> {
218
+ if (!this.supportsSessionFileTracking()) {
219
+ return null;
220
+ }
221
+
222
+ const sessionInfo = this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
223
+ if (!sessionInfo) {
224
+ return null;
225
+ }
226
+
227
+ const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
228
+ if (
229
+ this.sessionUsageCache &&
230
+ this.sessionUsageCache.backend === sessionInfo.backend &&
231
+ this.sessionUsageCache.sessionId === sessionInfo.sessionId &&
232
+ this.sessionUsageCache.sessionFilePath === sessionInfo.sessionFilePath &&
233
+ this.sessionUsageCache.size === size &&
234
+ this.sessionUsageCache.mtimeMs === mtimeMs
235
+ ) {
236
+ return { ...this.sessionUsageCache.summary };
237
+ }
238
+
239
+ const lines = await this.readSessionFileJsonLines(sessionInfo.sessionFilePath, 0);
240
+ const summary = this.extractSessionUsageSummaryFromJsonLines(lines, sessionInfo);
241
+ this.sessionUsageCache = {
242
+ backend: sessionInfo.backend,
243
+ sessionId: sessionInfo.sessionId,
244
+ sessionFilePath: sessionInfo.sessionFilePath,
245
+ size,
246
+ mtimeMs,
247
+ summary,
248
+ };
249
+
250
+ return { ...summary };
251
+ }
252
+
139
253
  async boot(): Promise<void> {
254
+ if (this.isKilled) {
255
+ throw this.createSessionClosedError();
256
+ }
257
+
140
258
  if (this.isBooted) {
141
259
  return;
142
260
  }
143
261
 
144
262
  this.stateMachine.transition("BOOT");
263
+ this.sessionInfo = null;
145
264
  this.pty.spawn();
146
265
 
147
266
  const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
@@ -234,6 +353,7 @@ export class TuiDriver extends EventEmitter {
234
353
 
235
354
  this.isBooted = true;
236
355
  this.stateMachine.transition("WAIT_READY");
356
+ await this.ensureSessionInfo();
237
357
  this.captureSnapshot("boot_complete");
238
358
  }
239
359
 
@@ -245,10 +365,14 @@ export class TuiDriver extends EventEmitter {
245
365
 
246
366
  const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
247
367
  const readyTimeout = this.resolveTimeout(this.profile.timeouts?.ready, 10000);
368
+ const guardedReadyMatcher = Matchers.custom((snapshot) => {
369
+ this.assertAliveOrThrow();
370
+ return readyMatcher(snapshot);
371
+ });
248
372
 
249
373
  const result = await this.expect.until({
250
374
  name: "ENSURE_READY",
251
- match: readyMatcher,
375
+ match: guardedReadyMatcher,
252
376
  stableMs: 200,
253
377
  timeoutMs: readyTimeout,
254
378
  });
@@ -261,6 +385,7 @@ export class TuiDriver extends EventEmitter {
261
385
 
262
386
  async ask(prompt: string): Promise<AskResult> {
263
387
  const startTime = Date.now();
388
+ let sessionInfo: TuiSessionInfo | null = null;
264
389
 
265
390
  try {
266
391
  await this.ensureReady();
@@ -279,6 +404,16 @@ export class TuiDriver extends EventEmitter {
279
404
  (error as any).matchedPattern = health.matchedPattern;
280
405
  throw error;
281
406
  }
407
+ } else if (health.reason === "process_exited") {
408
+ this.log("Health check detected exited process, attempting forced restart");
409
+ await this.restart();
410
+ const healthAfterRestart = this.healthCheck();
411
+ if (!healthAfterRestart.healthy) {
412
+ const error = new Error(`Cannot proceed: ${healthAfterRestart.message || healthAfterRestart.reason}`);
413
+ (error as any).reason = healthAfterRestart.reason;
414
+ (error as any).matchedPattern = healthAfterRestart.matchedPattern;
415
+ throw error;
416
+ }
282
417
  } else if (health.reason === "login_required") {
283
418
  const error = new Error(`Cannot proceed: ${health.message}`);
284
419
  (error as any).reason = health.reason;
@@ -299,6 +434,8 @@ export class TuiDriver extends EventEmitter {
299
434
  }
300
435
  }
301
436
 
437
+ sessionInfo = await this.ensureSessionInfo();
438
+
302
439
  this.stateMachine.transition("PREPARE_TURN");
303
440
  await this.prepareTurn();
304
441
 
@@ -313,17 +450,28 @@ export class TuiDriver extends EventEmitter {
313
450
 
314
451
  // 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
315
452
  const beforeSnapshot = this.captureSnapshot("after_submit");
453
+ const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
316
454
 
317
455
  this.stateMachine.transition("WAIT_STREAM_START");
318
- await this.waitStreamStart(preSubmitSnapshot);
456
+ if (sessionCheckpoint) {
457
+ await this.waitForSessionFileGrowth(sessionCheckpoint);
458
+ } else {
459
+ await this.waitStreamStart(preSubmitSnapshot);
460
+ }
319
461
 
320
462
  this.stateMachine.transition("WAIT_STREAM_END");
321
- await this.waitStreamEnd();
463
+ if (sessionCheckpoint) {
464
+ await this.waitForSessionFileIdle(sessionCheckpoint);
465
+ } else {
466
+ await this.waitStreamEnd(beforeSnapshot);
467
+ }
322
468
 
323
469
  this.stateMachine.transition("CAPTURE");
324
470
  const afterSnapshot = this.captureSnapshot("after_response");
325
471
 
326
- const answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
472
+ const answer = sessionCheckpoint
473
+ ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
474
+ : this.extractAnswer(beforeSnapshot, afterSnapshot);
327
475
  const signals = this.getSignals(afterSnapshot);
328
476
 
329
477
  this.stateMachine.transition("DONE");
@@ -341,6 +489,8 @@ export class TuiDriver extends EventEmitter {
341
489
  replyInProgress: signals.replyInProgress,
342
490
  statusLine: signals.statusLine,
343
491
  statusDoneLine: signals.statusDoneLine,
492
+ sessionId: sessionInfo?.sessionId,
493
+ sessionFilePath: sessionInfo?.sessionFilePath,
344
494
  };
345
495
  } catch (error) {
346
496
  this.stateMachine.error(error as Error);
@@ -359,6 +509,8 @@ export class TuiDriver extends EventEmitter {
359
509
  replyInProgress: signals.replyInProgress,
360
510
  statusLine: signals.statusLine,
361
511
  statusDoneLine: signals.statusDoneLine,
512
+ sessionId: sessionInfo?.sessionId,
513
+ sessionFilePath: sessionInfo?.sessionFilePath,
362
514
  };
363
515
  }
364
516
  }
@@ -378,24 +530,886 @@ export class TuiDriver extends EventEmitter {
378
530
  await this.pty.sendKeys(this.profile.keys.submit, 50);
379
531
  }
380
532
 
533
+ private supportsSessionFileTracking(): boolean {
534
+ const backend = String(this.profile.name || "").toLowerCase();
535
+ return backend === "codex" || backend === "claude-code" || backend === "copilot";
536
+ }
537
+
538
+ private async discoverSessionInfo(timeoutMs: number): Promise<TuiSessionInfo | null> {
539
+ const startedAt = Date.now();
540
+ const deadline = startedAt + Math.max(MIN_STAGE_TIMEOUT_MS, timeoutMs);
541
+
542
+ while (Date.now() < deadline) {
543
+ this.assertAliveOrThrow();
544
+ const discovered = await this.detectSessionInfoByBackend();
545
+ if (discovered) {
546
+ const changed =
547
+ !this.sessionInfo ||
548
+ this.sessionInfo.sessionId !== discovered.sessionId ||
549
+ this.sessionInfo.sessionFilePath !== discovered.sessionFilePath;
550
+ this.sessionInfo = discovered;
551
+ this.lastSessionInfo = discovered;
552
+ if (changed) {
553
+ this.emit("session", { ...discovered });
554
+ this.log(
555
+ `session discovered: id=${discovered.sessionId} file=${discovered.sessionFilePath}`,
556
+ );
557
+ }
558
+ return discovered;
559
+ }
560
+ await this.sleep(250);
561
+ }
562
+
563
+ return this.sessionInfo ? { ...this.sessionInfo } : null;
564
+ }
565
+
566
+ private async detectSessionInfoByBackend(): Promise<TuiSessionInfo | null> {
567
+ if (!this.supportsSessionFileTracking()) {
568
+ return null;
569
+ }
570
+ try {
571
+ switch (this.profile.name) {
572
+ case "codex":
573
+ return this.detectCodexSessionInfo();
574
+ case "claude-code":
575
+ return this.detectClaudeSessionInfo();
576
+ case "copilot":
577
+ return this.detectCopilotSessionInfo();
578
+ default:
579
+ return null;
580
+ }
581
+ } catch (error) {
582
+ this.log(`session detect failed: ${(error as Error)?.message || error}`);
583
+ return null;
584
+ }
585
+ }
586
+
587
+ private async detectCodexSessionInfo(): Promise<TuiSessionInfo | null> {
588
+ const dbPath = join(homedir(), ".codex", "state_5.sqlite");
589
+ if (!(await this.pathExists(dbPath))) {
590
+ return null;
591
+ }
592
+
593
+ const parseRowAsSessionInfo = async (row: string | null): Promise<TuiSessionInfo | null> => {
594
+ if (!row) {
595
+ return null;
596
+ }
597
+ const [sessionIdRaw, sessionFilePathRaw] = row.split("|");
598
+ const sessionId = String(sessionIdRaw || "").trim();
599
+ const sessionFilePath = String(sessionFilePathRaw || "").trim();
600
+ if (!sessionId || !sessionFilePath || !(await this.pathExists(sessionFilePath))) {
601
+ return null;
602
+ }
603
+ return {
604
+ backend: "codex",
605
+ sessionId,
606
+ sessionFilePath,
607
+ };
608
+ };
609
+
610
+ const pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
611
+ if (pinnedSessionId) {
612
+ const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
613
+ const pinnedRow = await this.querySqliteRow(
614
+ dbPath,
615
+ `select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`,
616
+ );
617
+ const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
618
+ if (pinnedSession) {
619
+ return pinnedSession;
620
+ }
621
+ }
622
+
623
+ const escapedCwd = this.sessionCwd.replace(/'/g, "''");
624
+ const row = await this.querySqliteRow(
625
+ 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;`,
627
+ );
628
+ return parseRowAsSessionInfo(row);
629
+ }
630
+
631
+ private async detectClaudeSessionInfo(): Promise<TuiSessionInfo | null> {
632
+ const projectDir = join(
633
+ homedir(),
634
+ ".claude",
635
+ "projects",
636
+ this.encodeClaudeProjectPath(this.sessionCwd),
637
+ );
638
+ if (!(await this.pathExists(projectDir))) {
639
+ return null;
640
+ }
641
+
642
+ const indexPath = join(projectDir, "sessions-index.json");
643
+ if (await this.pathExists(indexPath)) {
644
+ try {
645
+ const raw = await fs.readFile(indexPath, "utf8");
646
+ const parsed = JSON.parse(raw) as {
647
+ entries?: Array<{
648
+ sessionId?: string;
649
+ fullPath?: string;
650
+ fileMtime?: number;
651
+ modified?: string;
652
+ projectPath?: string;
653
+ }>;
654
+ };
655
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
656
+ const candidates = entries
657
+ .filter((entry) => {
658
+ const entrySessionId = String(entry?.sessionId || "").trim();
659
+ if (!entrySessionId) {
660
+ return false;
661
+ }
662
+ const entryProjectPath = String(entry?.projectPath || "").trim();
663
+ return !entryProjectPath || entryProjectPath === this.sessionCwd;
664
+ })
665
+ .sort((a, b) => {
666
+ const scoreA = Number(a?.fileMtime || Date.parse(String(a?.modified || "")) || 0);
667
+ const scoreB = Number(b?.fileMtime || Date.parse(String(b?.modified || "")) || 0);
668
+ return scoreB - scoreA;
669
+ });
670
+
671
+ for (const entry of candidates) {
672
+ const sessionId = String(entry.sessionId || "").trim();
673
+ 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
+ }
681
+ }
682
+ } catch (error) {
683
+ this.log(`claude session index parse failed: ${(error as Error)?.message || error}`);
684
+ }
685
+ }
686
+
687
+ try {
688
+ const dirents = await fs.readdir(projectDir, { withFileTypes: true });
689
+ const jsonlFiles = dirents
690
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
691
+ .map((entry) => join(projectDir, entry.name));
692
+ const stats = await Promise.all(
693
+ jsonlFiles.map(async (filePath) => ({
694
+ filePath,
695
+ mtimeMs: (await fs.stat(filePath)).mtimeMs,
696
+ })),
697
+ );
698
+ stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
699
+ const latest = stats[0];
700
+ if (!latest) {
701
+ return null;
702
+ }
703
+ const sessionId = basename(latest.filePath, ".jsonl");
704
+ return {
705
+ backend: "claude-code",
706
+ sessionId,
707
+ sessionFilePath: latest.filePath,
708
+ };
709
+ } catch {
710
+ return null;
711
+ }
712
+ }
713
+
714
+ private async detectCopilotSessionInfo(): Promise<TuiSessionInfo | null> {
715
+ const baseDir = join(homedir(), ".copilot", "session-state");
716
+ if (!(await this.pathExists(baseDir))) {
717
+ return null;
718
+ }
719
+
720
+ try {
721
+ const dirents = await fs.readdir(baseDir, { withFileTypes: true });
722
+ const candidates: Array<{ sessionId: string; sessionFilePath: string; mtimeMs: number }> = [];
723
+
724
+ for (const entry of dirents) {
725
+ if (!entry.isDirectory()) {
726
+ continue;
727
+ }
728
+ const sessionDir = join(baseDir, entry.name);
729
+ const workspacePath = join(sessionDir, "workspace.yaml");
730
+ const eventsPath = join(sessionDir, "events.jsonl");
731
+ if (!(await this.pathExists(eventsPath))) {
732
+ continue;
733
+ }
734
+
735
+ let workspaceCwd = "";
736
+ let workspaceId = "";
737
+ if (await this.pathExists(workspacePath)) {
738
+ workspaceCwd = (await this.readWorkspaceYamlValue(workspacePath, "cwd")) || "";
739
+ workspaceId = (await this.readWorkspaceYamlValue(workspacePath, "id")) || "";
740
+ }
741
+
742
+ if (workspaceCwd && workspaceCwd !== this.sessionCwd) {
743
+ continue;
744
+ }
745
+
746
+ const sessionId = workspaceId || entry.name;
747
+ const mtimeMs = (await fs.stat(eventsPath)).mtimeMs;
748
+ candidates.push({
749
+ sessionId,
750
+ sessionFilePath: eventsPath,
751
+ mtimeMs,
752
+ });
753
+ }
754
+
755
+ 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
+ };
765
+ } catch {
766
+ return null;
767
+ }
768
+ }
769
+
770
+ private async querySqliteRow(dbPath: string, query: string): Promise<string | null> {
771
+ try {
772
+ const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
773
+ timeout: 3000,
774
+ maxBuffer: 1024 * 1024,
775
+ });
776
+ const lines = String(stdout || "")
777
+ .split(/\r?\n/)
778
+ .map((line) => line.trim())
779
+ .filter(Boolean);
780
+ return lines[0] ?? null;
781
+ } catch (error) {
782
+ this.log(`sqlite query failed: ${(error as Error)?.message || error}`);
783
+ return null;
784
+ }
785
+ }
786
+
787
+ private encodeClaudeProjectPath(cwd: string): string {
788
+ return String(cwd || "").replace(/\//g, "-");
789
+ }
790
+
791
+ private async readWorkspaceYamlValue(filePath: string, key: string): Promise<string | null> {
792
+ try {
793
+ const raw = await fs.readFile(filePath, "utf8");
794
+ const matcher = new RegExp(`^${key}:\\s*(.+)\\s*$`, "m");
795
+ const match = raw.match(matcher);
796
+ if (!match) {
797
+ return null;
798
+ }
799
+ const value = match[1].trim();
800
+ if (!value) {
801
+ return null;
802
+ }
803
+ return value.replace(/^['"]|['"]$/g, "");
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
809
+ private async pathExists(filePath: string): Promise<boolean> {
810
+ try {
811
+ await fs.access(filePath);
812
+ return true;
813
+ } catch {
814
+ return false;
815
+ }
816
+ }
817
+
818
+ private async captureSessionFileCheckpoint(
819
+ sessionInfo: TuiSessionInfo | null,
820
+ ): Promise<SessionFileCheckpoint | null> {
821
+ if (!sessionInfo) {
822
+ return null;
823
+ }
824
+ const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
825
+ return {
826
+ sessionInfo,
827
+ size,
828
+ mtimeMs,
829
+ };
830
+ }
831
+
832
+ private async readSessionFileStat(
833
+ sessionFilePath: string,
834
+ ): Promise<{ size: number; mtimeMs: number }> {
835
+ try {
836
+ const stats = await fs.stat(sessionFilePath);
837
+ return {
838
+ size: stats.size,
839
+ mtimeMs: stats.mtimeMs,
840
+ };
841
+ } catch {
842
+ return {
843
+ size: 0,
844
+ mtimeMs: 0,
845
+ };
846
+ }
847
+ }
848
+
849
+ private async waitForSessionFileGrowth(checkpoint: SessionFileCheckpoint): Promise<void> {
850
+ const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
851
+ const startedAt = Date.now();
852
+ let lastSize = checkpoint.size;
853
+ let lastMtimeMs = checkpoint.mtimeMs;
854
+
855
+ while (Date.now() - startedAt < timeoutMs) {
856
+ this.assertAliveOrThrow();
857
+ const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
858
+ const changed = current.size !== lastSize || current.mtimeMs !== lastMtimeMs;
859
+ if (changed) {
860
+ this.log(
861
+ `session file growth detected: ${checkpoint.sessionInfo.sessionFilePath} (${lastSize} -> ${current.size})`,
862
+ );
863
+ return;
864
+ }
865
+ await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
866
+ }
867
+
868
+ throw new Error("Stream start timeout: session file did not grow");
869
+ }
870
+
871
+ private async waitForSessionFileIdle(checkpoint: SessionFileCheckpoint): Promise<void> {
872
+ const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
873
+ const startedAt = Date.now();
874
+ let previousSize = checkpoint.size;
875
+ let previousMtimeMs = checkpoint.mtimeMs;
876
+ let observedProgress = false;
877
+ let unchangedChecks = 0;
878
+ const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
879
+ let completionMarkerSeen = false;
880
+
881
+ while (Date.now() - startedAt < timeoutMs) {
882
+ this.assertAliveOrThrow();
883
+ const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
884
+ const changed = current.size !== previousSize || current.mtimeMs !== previousMtimeMs;
885
+ if (changed) {
886
+ this.log(
887
+ `session file changed: backend=${checkpoint.sessionInfo.backend} size=${previousSize}->${current.size} mtime=${previousMtimeMs}->${current.mtimeMs}`,
888
+ );
889
+ observedProgress = true;
890
+ unchangedChecks = 0;
891
+ previousSize = current.size;
892
+ previousMtimeMs = current.mtimeMs;
893
+ if (requireCompletionMarker && !completionMarkerSeen) {
894
+ completionMarkerSeen = await this.hasSessionCompletionMarker(checkpoint, current.size);
895
+ if (completionMarkerSeen) {
896
+ this.log(
897
+ `session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`,
898
+ );
899
+ }
900
+ }
901
+ } else {
902
+ unchangedChecks += 1;
903
+ this.log(
904
+ `session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
905
+ );
906
+ if (observedProgress && unchangedChecks >= 2) {
907
+ if (!requireCompletionMarker || completionMarkerSeen) {
908
+ this.log(
909
+ `session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
910
+ );
911
+ return;
912
+ }
913
+ }
914
+ }
915
+ await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
916
+ }
917
+
918
+ if (!observedProgress) {
919
+ throw new Error("Stream end timeout: session file did not grow");
920
+ }
921
+ if (requireCompletionMarker && !completionMarkerSeen) {
922
+ throw new Error("Stream end timeout: session completion marker not observed");
923
+ }
924
+ throw new Error("Stream end timeout: session file did not become stable");
925
+ }
926
+
927
+ private async extractAnswerFromSessionFile(checkpoint: SessionFileCheckpoint): Promise<string> {
928
+ const lines = await this.readSessionFileJsonLines(
929
+ checkpoint.sessionInfo.sessionFilePath,
930
+ checkpoint.size,
931
+ );
932
+ const codexTaskCompleteMessage = this.extractCodexTaskCompleteMessageFromJsonLines(lines);
933
+ if (codexTaskCompleteMessage) {
934
+ this.log(
935
+ `session answer source=codex.task_complete preview="${this.summarizeForLog(codexTaskCompleteMessage, 160)}"`,
936
+ );
937
+ return codexTaskCompleteMessage;
938
+ }
939
+ const answer = this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend);
940
+ if (answer) {
941
+ this.log(
942
+ `session answer source=${checkpoint.sessionInfo.backend}.assistant preview="${this.summarizeForLog(answer, 160)}"`,
943
+ );
944
+ return answer;
945
+ }
946
+ throw new Error("No assistant reply found in session file");
947
+ }
948
+
949
+ private extractSessionUsageSummaryFromJsonLines(
950
+ lines: string[],
951
+ sessionInfo: TuiSessionInfo,
952
+ ): TuiSessionUsageSummary {
953
+ const backend = sessionInfo.backend;
954
+ const baseSummary: TuiSessionUsageSummary = {
955
+ backend,
956
+ sessionId: sessionInfo.sessionId,
957
+ sessionFilePath: sessionInfo.sessionFilePath,
958
+ };
959
+
960
+ const usage =
961
+ backend === "codex"
962
+ ? this.extractCodexUsageFromJsonLines(lines)
963
+ : backend === "claude-code"
964
+ ? this.extractClaudeUsageFromJsonLines(lines)
965
+ : backend === "copilot"
966
+ ? this.extractCopilotUsageFromJsonLines(lines)
967
+ : {};
968
+
969
+ return {
970
+ ...baseSummary,
971
+ ...usage,
972
+ };
973
+ }
974
+
975
+ private extractCodexUsageFromJsonLines(
976
+ lines: string[],
977
+ ): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
978
+ let tokenUsagePercent: number | undefined;
979
+ let contextUsagePercent: number | undefined;
980
+
981
+ for (const line of lines) {
982
+ let entry: Record<string, unknown> | null = null;
983
+ try {
984
+ entry = JSON.parse(line) as Record<string, unknown>;
985
+ } catch {
986
+ continue;
987
+ }
988
+ if (!entry || entry.type !== "event_msg") {
989
+ continue;
990
+ }
991
+ const payload = entry.payload as Record<string, unknown> | undefined;
992
+ if (!payload || typeof payload !== "object") {
993
+ continue;
994
+ }
995
+
996
+ const secondaryUsedPercent = this.readNumberPath(payload, ["rate_limits", "secondary", "used_percent"]);
997
+ if (secondaryUsedPercent !== undefined) {
998
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
999
+ }
1000
+
1001
+ const inputTokens = this.readNumberPath(payload, ["info", "last_token_usage", "input_tokens"]);
1002
+ const contextWindow = this.readNumberPath(payload, ["info", "model_context_window"]);
1003
+ if (inputTokens !== undefined && contextWindow !== undefined && contextWindow > 0) {
1004
+ contextUsagePercent = this.normalizePercent((inputTokens / contextWindow) * 100);
1005
+ }
1006
+ }
1007
+
1008
+ return {
1009
+ tokenUsagePercent,
1010
+ contextUsagePercent,
1011
+ };
1012
+ }
1013
+
1014
+ private extractClaudeUsageFromJsonLines(
1015
+ lines: string[],
1016
+ ): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
1017
+ let tokenUsagePercent: number | undefined;
1018
+ let contextUsagePercent: number | undefined;
1019
+ let latestInputTokens: number | undefined;
1020
+ let latestContextWindow: number | undefined;
1021
+
1022
+ for (const line of lines) {
1023
+ let entry: Record<string, unknown> | null = null;
1024
+ try {
1025
+ entry = JSON.parse(line) as Record<string, unknown>;
1026
+ } catch {
1027
+ continue;
1028
+ }
1029
+ if (!entry || typeof entry !== "object") {
1030
+ continue;
1031
+ }
1032
+
1033
+ const secondaryUsedPercent =
1034
+ this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
1035
+ this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
1036
+ if (secondaryUsedPercent !== undefined) {
1037
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
1038
+ }
1039
+
1040
+ const inputTokens =
1041
+ this.readNumberPath(entry, ["message", "usage", "input_tokens"]) ??
1042
+ this.readNumberPath(entry, ["message", "usage", "inputTokens"]) ??
1043
+ this.readNumberPath(entry, ["usage", "input_tokens"]) ??
1044
+ this.readNumberPath(entry, ["usage", "inputTokens"]);
1045
+ if (inputTokens !== undefined) {
1046
+ latestInputTokens = inputTokens;
1047
+ }
1048
+
1049
+ const contextWindow =
1050
+ this.readNumberPath(entry, ["message", "model_context_window"]) ??
1051
+ this.readNumberPath(entry, ["message", "modelContextWindow"]) ??
1052
+ this.readNumberPath(entry, ["message", "context_window"]) ??
1053
+ this.readNumberPath(entry, ["message", "contextWindow"]) ??
1054
+ this.readNumberPath(entry, ["model_context_window"]) ??
1055
+ this.readNumberPath(entry, ["modelContextWindow"]) ??
1056
+ this.readNumberPath(entry, ["context_window"]) ??
1057
+ this.readNumberPath(entry, ["contextWindow"]);
1058
+ if (contextWindow !== undefined && contextWindow > 0) {
1059
+ latestContextWindow = contextWindow;
1060
+ }
1061
+ }
1062
+
1063
+ if (
1064
+ latestInputTokens !== undefined &&
1065
+ latestContextWindow !== undefined &&
1066
+ latestContextWindow > 0
1067
+ ) {
1068
+ contextUsagePercent = this.normalizePercent((latestInputTokens / latestContextWindow) * 100);
1069
+ }
1070
+
1071
+ return {
1072
+ tokenUsagePercent,
1073
+ contextUsagePercent,
1074
+ };
1075
+ }
1076
+
1077
+ private extractCopilotUsageFromJsonLines(
1078
+ lines: string[],
1079
+ ): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
1080
+ let tokenUsagePercent: number | undefined;
1081
+ let contextUsagePercent: number | undefined;
1082
+ let latestContextTokens: number | undefined;
1083
+ let latestContextLimit: number | undefined;
1084
+
1085
+ for (const line of lines) {
1086
+ let entry: Record<string, unknown> | null = null;
1087
+ try {
1088
+ entry = JSON.parse(line) as Record<string, unknown>;
1089
+ } catch {
1090
+ continue;
1091
+ }
1092
+ if (!entry || typeof entry !== "object") {
1093
+ continue;
1094
+ }
1095
+
1096
+ const secondaryUsedPercent =
1097
+ this.readNumberPath(entry, ["data", "rate_limits", "secondary", "used_percent"]) ??
1098
+ this.readNumberPath(entry, ["data", "rateLimits", "secondary", "usedPercent"]) ??
1099
+ this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
1100
+ this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
1101
+ if (secondaryUsedPercent !== undefined) {
1102
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
1103
+ }
1104
+
1105
+ const responseTokenLimit =
1106
+ this.readNumberPath(entry, ["data", "toolTelemetry", "metrics", "responseTokenLimit"]) ??
1107
+ this.readNumberPath(entry, ["data", "responseTokenLimit"]) ??
1108
+ this.readNumberPath(entry, ["data", "modelContextWindow"]) ??
1109
+ this.readNumberPath(entry, ["data", "model_context_window"]) ??
1110
+ this.readNumberPath(entry, ["data", "contextWindow"]) ??
1111
+ this.readNumberPath(entry, ["data", "context_window"]) ??
1112
+ this.readNumberPath(entry, ["responseTokenLimit"]);
1113
+ if (responseTokenLimit !== undefined && responseTokenLimit > 0) {
1114
+ latestContextLimit = responseTokenLimit;
1115
+ }
1116
+
1117
+ const contextTokens =
1118
+ this.readNumberPath(entry, ["data", "preCompactionTokens"]) ??
1119
+ this.readNumberPath(entry, ["data", "compactionTokensUsed", "input"]) ??
1120
+ this.readNumberPath(entry, ["data", "postCompactionTokens"]) ??
1121
+ this.readNumberPath(entry, ["data", "tokenUsage", "input_tokens"]) ??
1122
+ this.readNumberPath(entry, ["data", "usage", "input_tokens"]) ??
1123
+ this.readNumberPath(entry, ["data", "inputTokens"]);
1124
+ if (contextTokens !== undefined && contextTokens >= 0) {
1125
+ latestContextTokens = contextTokens;
1126
+ }
1127
+ }
1128
+
1129
+ if (
1130
+ latestContextTokens !== undefined &&
1131
+ latestContextLimit !== undefined &&
1132
+ latestContextLimit > 0
1133
+ ) {
1134
+ contextUsagePercent = this.normalizePercent((latestContextTokens / latestContextLimit) * 100);
1135
+ }
1136
+
1137
+ return {
1138
+ tokenUsagePercent,
1139
+ contextUsagePercent,
1140
+ };
1141
+ }
1142
+
1143
+ private readNumberPath(source: unknown, path: string[]): number | undefined {
1144
+ let cursor: unknown = source;
1145
+ for (const key of path) {
1146
+ if (!cursor || typeof cursor !== "object") {
1147
+ return undefined;
1148
+ }
1149
+ cursor = (cursor as Record<string, unknown>)[key];
1150
+ }
1151
+ if (typeof cursor === "number" && Number.isFinite(cursor)) {
1152
+ return cursor;
1153
+ }
1154
+ if (typeof cursor === "string") {
1155
+ const parsed = Number(cursor);
1156
+ if (Number.isFinite(parsed)) {
1157
+ return parsed;
1158
+ }
1159
+ }
1160
+ return undefined;
1161
+ }
1162
+
1163
+ private normalizePercent(value: number): number {
1164
+ if (!Number.isFinite(value)) {
1165
+ return 0;
1166
+ }
1167
+ if (value < 0) {
1168
+ return 0;
1169
+ }
1170
+ if (value > 100) {
1171
+ return 100;
1172
+ }
1173
+ return value;
1174
+ }
1175
+
1176
+ private requiresSessionCompletionMarker(backend: string): boolean {
1177
+ return backend === "codex" || backend === "copilot";
1178
+ }
1179
+
1180
+ private async hasSessionCompletionMarker(
1181
+ checkpoint: SessionFileCheckpoint,
1182
+ endOffset?: number,
1183
+ ): Promise<boolean> {
1184
+ const lines = await this.readSessionFileJsonLines(
1185
+ checkpoint.sessionInfo.sessionFilePath,
1186
+ checkpoint.size,
1187
+ endOffset,
1188
+ );
1189
+ if (checkpoint.sessionInfo.backend === "codex") {
1190
+ return this.hasCodexTaskCompleteFromJsonLines(lines);
1191
+ }
1192
+ if (checkpoint.sessionInfo.backend === "copilot") {
1193
+ return this.hasCopilotTurnEndFromJsonLines(lines);
1194
+ }
1195
+ return false;
1196
+ }
1197
+
1198
+ private async readSessionFileJsonLines(
1199
+ sessionFilePath: string,
1200
+ startOffset = 0,
1201
+ endOffset?: number,
1202
+ ): Promise<string[]> {
1203
+ let fullBuffer: Buffer;
1204
+ try {
1205
+ fullBuffer = await fs.readFile(sessionFilePath);
1206
+ } catch {
1207
+ return [];
1208
+ }
1209
+ const boundedStartOffset = Math.max(0, Math.min(startOffset, fullBuffer.length));
1210
+ const boundedEndOffset = Number.isFinite(endOffset)
1211
+ ? Math.max(boundedStartOffset, Math.min(Number(endOffset), fullBuffer.length))
1212
+ : fullBuffer.length;
1213
+ return fullBuffer
1214
+ .subarray(boundedStartOffset, boundedEndOffset)
1215
+ .toString("utf8")
1216
+ .split(/\r?\n/)
1217
+ .map((line) => line.trim())
1218
+ .filter(Boolean);
1219
+ }
1220
+
1221
+ private extractAssistantReplyFromJsonLines(lines: string[], backend: string): string {
1222
+ const replies: string[] = [];
1223
+
1224
+ for (const line of lines) {
1225
+ let entry: Record<string, unknown> | null = null;
1226
+ try {
1227
+ entry = JSON.parse(line) as Record<string, unknown>;
1228
+ } catch {
1229
+ continue;
1230
+ }
1231
+ if (!entry || typeof entry !== "object") {
1232
+ continue;
1233
+ }
1234
+
1235
+ const text =
1236
+ backend === "codex"
1237
+ ? this.extractCodexAssistantText(entry)
1238
+ : backend === "claude-code"
1239
+ ? this.extractClaudeAssistantText(entry)
1240
+ : backend === "copilot"
1241
+ ? this.extractCopilotAssistantText(entry)
1242
+ : "";
1243
+ if (text) {
1244
+ replies.push(text);
1245
+ }
1246
+ }
1247
+
1248
+ return replies.length > 0 ? replies[replies.length - 1] : "";
1249
+ }
1250
+
1251
+ private hasCodexTaskCompleteFromJsonLines(lines: string[]): boolean {
1252
+ for (const line of lines) {
1253
+ let entry: Record<string, unknown> | null = null;
1254
+ try {
1255
+ entry = JSON.parse(line) as Record<string, unknown>;
1256
+ } catch {
1257
+ continue;
1258
+ }
1259
+ if (entry && this.isCodexTaskCompleteEntry(entry)) {
1260
+ return true;
1261
+ }
1262
+ }
1263
+ return false;
1264
+ }
1265
+
1266
+ private hasCopilotTurnEndFromJsonLines(lines: string[]): boolean {
1267
+ for (const line of lines) {
1268
+ let entry: Record<string, unknown> | null = null;
1269
+ try {
1270
+ entry = JSON.parse(line) as Record<string, unknown>;
1271
+ } catch {
1272
+ continue;
1273
+ }
1274
+ if (entry && this.isCopilotTurnEndEntry(entry)) {
1275
+ return true;
1276
+ }
1277
+ }
1278
+ return false;
1279
+ }
1280
+
1281
+ private extractCodexTaskCompleteMessageFromJsonLines(lines: string[]): string {
1282
+ let latestMessage = "";
1283
+ for (const line of lines) {
1284
+ let entry: Record<string, unknown> | null = null;
1285
+ try {
1286
+ entry = JSON.parse(line) as Record<string, unknown>;
1287
+ } catch {
1288
+ continue;
1289
+ }
1290
+ if (!entry || !this.isCodexTaskCompleteEntry(entry)) {
1291
+ continue;
1292
+ }
1293
+ const payload = entry.payload as Record<string, unknown> | undefined;
1294
+ const message = typeof payload?.last_agent_message === "string" ? payload.last_agent_message.trim() : "";
1295
+ if (message) {
1296
+ latestMessage = message;
1297
+ }
1298
+ }
1299
+ return latestMessage;
1300
+ }
1301
+
1302
+ private isCodexTaskCompleteEntry(entry: Record<string, unknown>): boolean {
1303
+ if (entry.type !== "event_msg") {
1304
+ return false;
1305
+ }
1306
+ const payload = entry.payload as Record<string, unknown> | undefined;
1307
+ return payload?.type === "task_complete";
1308
+ }
1309
+
1310
+ private isCopilotTurnEndEntry(entry: Record<string, unknown>): boolean {
1311
+ return entry.type === "assistant.turn_end";
1312
+ }
1313
+
1314
+ private summarizeForLog(value: string, maxLen = 160): string {
1315
+ const normalized = String(value || "").replace(/\s+/g, " ").trim();
1316
+ if (!normalized) {
1317
+ return "";
1318
+ }
1319
+ if (normalized.length <= maxLen) {
1320
+ return normalized;
1321
+ }
1322
+ return `${normalized.slice(0, maxLen)}...`;
1323
+ }
1324
+
1325
+ private extractCodexAssistantText(entry: Record<string, unknown>): string {
1326
+ if (entry.type !== "response_item") {
1327
+ return "";
1328
+ }
1329
+ const payload = entry.payload as Record<string, unknown> | undefined;
1330
+ if (!payload || payload.type !== "message" || payload.role !== "assistant") {
1331
+ return "";
1332
+ }
1333
+ const content = payload.content as Array<Record<string, unknown>> | undefined;
1334
+ if (!Array.isArray(content)) {
1335
+ return "";
1336
+ }
1337
+ const text = content
1338
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
1339
+ .filter(Boolean)
1340
+ .join("\n")
1341
+ .trim();
1342
+ return text;
1343
+ }
1344
+
1345
+ private extractClaudeAssistantText(entry: Record<string, unknown>): string {
1346
+ if (entry.type !== "assistant") {
1347
+ return "";
1348
+ }
1349
+ const message = entry.message as Record<string, unknown> | undefined;
1350
+ if (!message || message.role !== "assistant") {
1351
+ return "";
1352
+ }
1353
+ const content = message.content;
1354
+ if (typeof content === "string") {
1355
+ return content.trim();
1356
+ }
1357
+ if (!Array.isArray(content)) {
1358
+ return "";
1359
+ }
1360
+ const text = content
1361
+ .map((block) => {
1362
+ const typed = block as Record<string, unknown>;
1363
+ return typed?.type === "text" && typeof typed?.text === "string" ? typed.text : "";
1364
+ })
1365
+ .filter(Boolean)
1366
+ .join("\n")
1367
+ .trim();
1368
+ return text;
1369
+ }
1370
+
1371
+ private extractCopilotAssistantText(entry: Record<string, unknown>): string {
1372
+ if (entry.type !== "assistant.message") {
1373
+ return "";
1374
+ }
1375
+ const data = entry.data as Record<string, unknown> | undefined;
1376
+ if (!data || typeof data.content !== "string") {
1377
+ return "";
1378
+ }
1379
+ return data.content.trim();
1380
+ }
1381
+
381
1382
  private async waitStreamStart(previousSnapshot: ScreenSnapshot): Promise<void> {
382
1383
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
383
- const replyStartMatcher = this.profile.signals?.replyStart?.length
384
- ? Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
385
- : null;
386
1384
 
387
1385
  if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
388
1386
  const busyPatterns = this.profile.anchors.busy;
389
1387
  const previousScrollback = previousSnapshot.scrollbackText;
390
1388
  const startMatcher = (snapshot: ScreenSnapshot): boolean => {
391
- const { added } = computeLineDiff(previousScrollback, snapshot.scrollbackText);
392
- if (added.some(line => busyPatterns.some(pattern => pattern.test(line)))) {
1389
+ this.assertAliveOrThrow();
1390
+ const added = this.getChangedTailLines(previousScrollback, snapshot.scrollbackText);
1391
+ if (this.anyAddedLineMatches(added, busyPatterns)) {
393
1392
  return true;
394
1393
  }
395
- if (replyStartMatcher && replyStartMatcher(snapshot)) {
1394
+ const hasNewReplyStart = this.hasNewScrollbackPatternSince(
1395
+ previousScrollback,
1396
+ snapshot.scrollbackText,
1397
+ this.profile.signals?.replyStart
1398
+ );
1399
+ if (hasNewReplyStart) {
396
1400
  this.log("waitStreamStart: replyStart detected before busy status");
397
1401
  return true;
398
1402
  }
1403
+ if (this.behavior.matchStreamStartFallback?.({
1404
+ previousSnapshot,
1405
+ snapshot,
1406
+ previousScrollback,
1407
+ addedLines: added,
1408
+ hasNewReplyStart,
1409
+ })) {
1410
+ this.log("waitStreamStart: matched backend fallback condition");
1411
+ return true;
1412
+ }
399
1413
  return false;
400
1414
  };
401
1415
  const result = await this.expect.until({
@@ -415,38 +1429,84 @@ export class TuiDriver extends EventEmitter {
415
1429
  }
416
1430
  }
417
1431
 
418
- private async waitStreamEnd(): Promise<void> {
1432
+ private async waitStreamEnd(turnStartSnapshot?: ScreenSnapshot): Promise<void> {
419
1433
  const idleMs = this.profile.timeouts?.idle ?? 800;
420
1434
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
1435
+ const turnStartScrollback = turnStartSnapshot?.scrollbackText ?? "";
1436
+ const turnStartHash = turnStartSnapshot?.hash ?? "";
1437
+ const replyWaitStartAt = Date.now();
421
1438
 
422
1439
  const readyMatcher = Matchers.anyOf(this.profile.anchors.ready);
423
1440
  const busyMatcher = this.profile.anchors.busy?.length
424
1441
  ? Matchers.anyOf(this.profile.anchors.busy)
425
1442
  : null;
426
- const replyStartMatcher = this.profile.signals?.replyStart?.length
427
- ? Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
428
- : null;
429
- const promptHintMatcher = this.profile.signals?.promptHint?.length
430
- ? Matchers.anyOf(this.profile.signals.promptHint)
1443
+ const promptMatcher = this.profile.signals?.prompt?.length
1444
+ ? Matchers.anyOf(this.profile.signals.prompt)
431
1445
  : null;
1446
+ const hasNewReplyStart = (snapshot: ScreenSnapshot): boolean =>
1447
+ this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.replyStart);
1448
+ const hasNewPromptHint = (snapshot: ScreenSnapshot): boolean =>
1449
+ this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.promptHint);
1450
+ const hasPromptHintSignal = Boolean(this.profile.signals?.promptHint?.length);
432
1451
  const statusMatcher = this.profile.signals?.status?.length
433
1452
  ? Matchers.anyOf(this.profile.signals.status)
434
1453
  : null;
435
1454
  const statusDoneMatcher = this.profile.signals?.statusDone?.length
436
1455
  ? Matchers.anyOf(this.profile.signals.statusDone)
437
1456
  : null;
1457
+ let sawBusyDuringWait = false;
1458
+ const hasAnyNewScrollbackLine = (snapshot: ScreenSnapshot): boolean => {
1459
+ const added = this.getChangedTailLines(turnStartScrollback, snapshot.scrollbackText);
1460
+ return added.some((line) => line.trim().length > 0);
1461
+ };
438
1462
 
439
1463
  // 组合条件:屏幕 idle + ready anchor 出现 + busy anchor 消失
440
- const completeMatcher = busyMatcher
1464
+ const defaultCompleteMatcher = busyMatcher
441
1465
  ? Matchers.and(readyMatcher, Matchers.not(busyMatcher))
442
1466
  : readyMatcher;
443
-
444
- if (this.profile.requireReplyStart && replyStartMatcher) {
1467
+ const completeMatcher = this.behavior.buildStreamEndCompleteMatcher
1468
+ ? this.behavior.buildStreamEndCompleteMatcher({
1469
+ readyMatcher,
1470
+ busyMatcher,
1471
+ defaultMatcher: defaultCompleteMatcher,
1472
+ })
1473
+ : defaultCompleteMatcher;
1474
+
1475
+ if (this.profile.requireReplyStart && this.profile.signals?.replyStart?.length) {
445
1476
  const replyOrHintResult = await this.expect.until({
446
1477
  name: "STREAM_END_REPLY_OR_HINT",
447
- match: promptHintMatcher
448
- ? Matchers.or(replyStartMatcher, promptHintMatcher)
449
- : replyStartMatcher,
1478
+ match: Matchers.custom((snapshot) => {
1479
+ this.assertAliveOrThrow();
1480
+ const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
1481
+ if (busyNow) {
1482
+ sawBusyDuringWait = true;
1483
+ }
1484
+ const fallbackMatched = this.behavior.matchStreamEndReplyFallback
1485
+ ? this.behavior.matchStreamEndReplyFallback({
1486
+ snapshot,
1487
+ turnStartSnapshot,
1488
+ turnStartScrollback,
1489
+ turnStartHash,
1490
+ readyMatcher,
1491
+ busyMatcher,
1492
+ promptMatcher,
1493
+ sawBusyDuringWait,
1494
+ waitStartedAt: replyWaitStartAt,
1495
+ hasAnyNewScrollbackLine,
1496
+ })
1497
+ : false;
1498
+ if (fallbackMatched) {
1499
+ this.log("waitStreamEnd: matched backend reply fallback condition");
1500
+ return true;
1501
+ }
1502
+ if (hasNewReplyStart(snapshot)) {
1503
+ return true;
1504
+ }
1505
+ if (hasPromptHintSignal && hasNewPromptHint(snapshot)) {
1506
+ return true;
1507
+ }
1508
+ return false;
1509
+ }),
450
1510
  stableMs: 200,
451
1511
  timeoutMs: timeout,
452
1512
  });
@@ -456,15 +1516,15 @@ export class TuiDriver extends EventEmitter {
456
1516
 
457
1517
  // Check if we matched promptHint but not replyStart - this indicates potential empty response
458
1518
  const snapshot = this.screen.snapshot();
459
- const hasReplyStart = replyStartMatcher(snapshot);
460
- if (!hasReplyStart && promptHintMatcher) {
461
- const hasPromptHint = promptHintMatcher(snapshot);
1519
+ const hasReplyStart = hasNewReplyStart(snapshot);
1520
+ if (!hasReplyStart && hasPromptHintSignal) {
1521
+ const hasPromptHint = hasNewPromptHint(snapshot);
462
1522
  if (hasPromptHint) {
463
1523
  this.log(`waitStreamEnd: WARNING - promptHint matched but replyStart did not. Waiting for actual reply...`);
464
1524
  // Wait a bit longer for the actual reply to appear
465
1525
  const retryResult = await this.expect.until({
466
1526
  name: "STREAM_END_REPLY_RETRY",
467
- match: replyStartMatcher,
1527
+ match: Matchers.custom((nextSnapshot) => hasNewReplyStart(nextSnapshot)),
468
1528
  stableMs: 200,
469
1529
  timeoutMs: 10000, // Give it 10 more seconds
470
1530
  });
@@ -474,14 +1534,32 @@ export class TuiDriver extends EventEmitter {
474
1534
  }
475
1535
  }
476
1536
 
477
- await this.waitForScrollbackIdle(idleMs, timeout);
1537
+ await this.waitForScrollbackIdle(idleMs, timeout, (snapshot) => {
1538
+ const hasPrompt = promptMatcher ? promptMatcher(snapshot) : readyMatcher(snapshot);
1539
+ const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
1540
+ if (!hasPrompt || busyNow) {
1541
+ return false;
1542
+ }
1543
+ const currentSignals = this.getSignals(snapshot);
1544
+ return !currentSignals.replyInProgress;
1545
+ });
478
1546
 
479
1547
  if (statusMatcher) {
1548
+ const defaultStatusClearMatcher = statusDoneMatcher
1549
+ ? Matchers.or(Matchers.not(statusMatcher), statusDoneMatcher)
1550
+ : Matchers.not(statusMatcher);
1551
+ const statusClearMatcher = this.behavior.buildStreamEndStatusClearMatcher
1552
+ ? this.behavior.buildStreamEndStatusClearMatcher({
1553
+ defaultMatcher: defaultStatusClearMatcher,
1554
+ getSignals: (snapshot) => this.getSignals(snapshot),
1555
+ })
1556
+ : defaultStatusClearMatcher;
480
1557
  const statusClearResult = await this.expect.until({
481
1558
  name: "STREAM_END_STATUS_CLEAR",
482
- match: statusDoneMatcher
483
- ? Matchers.or(Matchers.not(statusMatcher), statusDoneMatcher)
484
- : Matchers.not(statusMatcher),
1559
+ match: Matchers.custom((snapshot) => {
1560
+ this.assertAliveOrThrow();
1561
+ return statusClearMatcher(snapshot);
1562
+ }),
485
1563
  stableMs: 300,
486
1564
  timeoutMs: timeout,
487
1565
  });
@@ -505,7 +1583,10 @@ export class TuiDriver extends EventEmitter {
505
1583
  if (this.profile.requireReplyStart) {
506
1584
  const readyResult = await this.expect.until({
507
1585
  name: "STREAM_END_READY",
508
- match: completeMatcher,
1586
+ match: Matchers.custom((snapshot) => {
1587
+ this.assertAliveOrThrow();
1588
+ return completeMatcher(snapshot);
1589
+ }),
509
1590
  stableMs: 500,
510
1591
  timeoutMs: 3000,
511
1592
  });
@@ -515,7 +1596,10 @@ export class TuiDriver extends EventEmitter {
515
1596
  } else {
516
1597
  const readyResult = await this.expect.until({
517
1598
  name: "STREAM_END_READY",
518
- match: completeMatcher,
1599
+ match: Matchers.custom((snapshot) => {
1600
+ this.assertAliveOrThrow();
1601
+ return completeMatcher(snapshot);
1602
+ }),
519
1603
  stableMs: 500,
520
1604
  timeoutMs: 10000,
521
1605
  });
@@ -593,13 +1677,98 @@ export class TuiDriver extends EventEmitter {
593
1677
 
594
1678
  private async restart(): Promise<void> {
595
1679
  this.log("Restarting PTY...");
1680
+ const restartSession = await this.resolveRestartSessionInfo();
1681
+ const restartArgs = this.resolveRestartArgs(restartSession?.sessionId);
1682
+ if (restartSession?.sessionId) {
1683
+ this.log(
1684
+ `restart resume target: backend=${this.profile.name} session=${restartSession.sessionId} args=${JSON.stringify(restartArgs)}`,
1685
+ );
1686
+ } else {
1687
+ this.log(`restart without resume: backend=${this.profile.name} args=${JSON.stringify(restartArgs)}`);
1688
+ }
596
1689
  this.pty.kill();
1690
+ this.pty.setCommandArgs(this.initialCommand, restartArgs);
597
1691
  this.screen.reset();
598
1692
  this.isBooted = false;
599
1693
  await this.sleep(500);
600
1694
  await this.boot();
601
1695
  }
602
1696
 
1697
+ private async resolveRestartSessionInfo(): Promise<TuiSessionInfo | null> {
1698
+ const cached = this.sessionInfo ?? this.lastSessionInfo;
1699
+ if (cached?.sessionId) {
1700
+ return { ...cached };
1701
+ }
1702
+ if (!this.supportsSessionFileTracking()) {
1703
+ return null;
1704
+ }
1705
+ const detected = await this.detectSessionInfoByBackend();
1706
+ if (!detected?.sessionId) {
1707
+ return null;
1708
+ }
1709
+ this.lastSessionInfo = detected;
1710
+ return { ...detected };
1711
+ }
1712
+
1713
+ private resolveRestartArgs(sessionId?: string): string[] {
1714
+ const normalizedSessionId = String(sessionId || "").trim();
1715
+ if (!normalizedSessionId) {
1716
+ return [...this.initialArgs];
1717
+ }
1718
+ const baseArgs = this.stripResumeArgs(this.initialArgs, this.profile.name);
1719
+ const resumeArgs = this.buildResumeArgsForBackend(this.profile.name, normalizedSessionId);
1720
+ if (resumeArgs.length === 0) {
1721
+ return [...this.initialArgs];
1722
+ }
1723
+ return [...baseArgs, ...resumeArgs];
1724
+ }
1725
+
1726
+ private stripResumeArgs(args: string[], backendName: string): string[] {
1727
+ const result: string[] = [];
1728
+ const backend = String(backendName || "").toLowerCase();
1729
+
1730
+ for (let index = 0; index < args.length; index += 1) {
1731
+ const current = String(args[index] || "");
1732
+ const next = args[index + 1];
1733
+
1734
+ if (current === "--resume") {
1735
+ index += 1;
1736
+ continue;
1737
+ }
1738
+ if (current.startsWith("--resume=")) {
1739
+ continue;
1740
+ }
1741
+ if ((backend === "codex" || backend === "code") && current === "resume") {
1742
+ if (typeof next === "string" && next.length > 0) {
1743
+ index += 1;
1744
+ }
1745
+ continue;
1746
+ }
1747
+
1748
+ result.push(current);
1749
+ }
1750
+
1751
+ return result;
1752
+ }
1753
+
1754
+ private buildResumeArgsForBackend(backendName: string, sessionId: string): string[] {
1755
+ const normalizedBackend = String(backendName || "").toLowerCase();
1756
+ const normalizedSessionId = String(sessionId || "").trim();
1757
+ if (!normalizedSessionId) {
1758
+ return [];
1759
+ }
1760
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
1761
+ return ["resume", normalizedSessionId];
1762
+ }
1763
+ if (normalizedBackend === "claude-code" || normalizedBackend === "claude") {
1764
+ return ["--resume", normalizedSessionId];
1765
+ }
1766
+ if (normalizedBackend === "copilot") {
1767
+ return [`--resume=${normalizedSessionId}`];
1768
+ }
1769
+ return [];
1770
+ }
1771
+
603
1772
  private captureSnapshot(label: string): ScreenSnapshot {
604
1773
  const snapshot = this.screen.snapshot();
605
1774
  if (this.onSnapshot) {
@@ -750,9 +1919,24 @@ export class TuiDriver extends EventEmitter {
750
1919
  this.log(`getSignals: DEBUG scrollback tail (last 30 lines):\n${lastLines.map((l, i) => ` [${lines.length - 30 + i}] "${l}"`).join('\n')}`);
751
1920
  }
752
1921
  const promptLine = this.findLastMatch(lines, signals.prompt);
753
- const statusLine = this.findLastMatch(lines, signals.status);
754
- const statusDoneLine = this.findLastMatch(lines, signals.statusDone);
755
- const reply = this.extractReplyBlocks(lines, signals);
1922
+ const signalScopeLines = this.behavior.getSignalScopeLines
1923
+ ? this.behavior.getSignalScopeLines({
1924
+ lines,
1925
+ signals,
1926
+ helpers: { findLastMatch: this.findLastMatch.bind(this) },
1927
+ })
1928
+ : lines;
1929
+ const defaultStatusLine = this.findLastMatch(signalScopeLines, signals.status);
1930
+ const statusLine = this.behavior.resolveStatusLine
1931
+ ? this.behavior.resolveStatusLine({
1932
+ lines: signalScopeLines,
1933
+ signals,
1934
+ defaultStatusLine,
1935
+ helpers: { findLastMatch: this.findLastMatch.bind(this) },
1936
+ })
1937
+ : defaultStatusLine;
1938
+ const statusDoneLine = this.findLastMatch(signalScopeLines, signals.statusDone);
1939
+ const reply = this.extractReplyBlocks(signalScopeLines, signals);
756
1940
  this.log(`getSignals: promptLine="${promptLine || '(none)'}" statusLine="${statusLine || '(none)'}" reply.text="${reply.text?.slice(0, 100) || '(none)'}" reply.blocks=${reply.blocks.length}`);
757
1941
 
758
1942
  return {
@@ -774,10 +1958,29 @@ export class TuiDriver extends EventEmitter {
774
1958
  this.pty.write(data);
775
1959
  }
776
1960
 
1961
+ async forceRestart(): Promise<void> {
1962
+ await this.restart();
1963
+ }
1964
+
777
1965
  kill(): void {
1966
+ this.isKilled = true;
778
1967
  this.pty.kill();
779
1968
  this.screen.dispose();
780
1969
  this.isBooted = false;
1970
+ this.sessionInfo = null;
1971
+ this.lastSessionInfo = null;
1972
+ }
1973
+
1974
+ private createSessionClosedError(): Error {
1975
+ const error = new Error("TUI session closed");
1976
+ (error as any).reason = "session_closed";
1977
+ return error;
1978
+ }
1979
+
1980
+ private assertAliveOrThrow(): void {
1981
+ if (this.isKilled || !this.running) {
1982
+ throw this.createSessionClosedError();
1983
+ }
781
1984
  }
782
1985
 
783
1986
  private sleep(ms: number): Promise<void> {
@@ -787,28 +1990,70 @@ export class TuiDriver extends EventEmitter {
787
1990
  private terminateSessionForLoginRequired(): void {
788
1991
  this.pty.kill();
789
1992
  this.isBooted = false;
1993
+ this.sessionInfo = null;
1994
+ this.lastSessionInfo = null;
790
1995
  }
791
1996
 
792
1997
  private resolveTimeout(timeoutMs: number | undefined, defaultTimeoutMs: number): number {
793
- const value = timeoutMs ?? defaultTimeoutMs;
794
- if (!Number.isFinite(value) || value <= 0) {
795
- return Number.POSITIVE_INFINITY;
1998
+ const fallback = this.normalizeTimeoutValue(defaultTimeoutMs, Math.max(MIN_STAGE_TIMEOUT_MS, defaultTimeoutMs));
1999
+ return this.normalizeTimeoutValue(timeoutMs, fallback);
2000
+ }
2001
+
2002
+ private normalizeTimeoutValue(timeoutMs: number | undefined, fallback: number): number {
2003
+ const parsed = Number(timeoutMs);
2004
+ if (!Number.isFinite(parsed)) {
2005
+ return fallback;
796
2006
  }
797
- return value;
2007
+ // `0` means "disable hard timeout" for long-running turns.
2008
+ if (parsed === 0) {
2009
+ return this.resolveMaxStageTimeoutMs();
2010
+ }
2011
+ if (parsed < 0) {
2012
+ return fallback;
2013
+ }
2014
+ const bounded = Math.max(MIN_STAGE_TIMEOUT_MS, Math.round(parsed));
2015
+ return Math.min(bounded, this.resolveMaxStageTimeoutMs());
2016
+ }
2017
+
2018
+ private resolveMaxStageTimeoutMs(): number {
2019
+ const raw = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
2020
+ const parsed = Number.parseInt(String(raw || ""), 10);
2021
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2022
+ return DEFAULT_STAGE_TIMEOUT_MAX_MS;
2023
+ }
2024
+ return Math.min(Math.max(parsed, MIN_STAGE_TIMEOUT_MS), ABSOLUTE_STAGE_TIMEOUT_MAX_MS);
798
2025
  }
799
2026
 
800
- private async waitForScrollbackIdle(idleMs: number, timeoutMs: number): Promise<void> {
2027
+ private async waitForScrollbackIdle(
2028
+ idleMs: number,
2029
+ timeoutMs: number,
2030
+ completionHint?: (snapshot: ScreenSnapshot) => boolean
2031
+ ): Promise<void> {
801
2032
  const startTime = Date.now();
802
2033
  let lastHash = ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
803
2034
  let lastChangeTime = Date.now();
2035
+ let hintStableSince: number | null = null;
2036
+ const boundedTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 60000;
804
2037
 
805
2038
  while (true) {
2039
+ this.assertAliveOrThrow();
806
2040
  const elapsed = Date.now() - startTime;
807
- if (elapsed >= timeoutMs) {
2041
+ if (elapsed >= boundedTimeoutMs) {
808
2042
  throw new Error("Stream end timeout: scrollback did not become idle");
809
2043
  }
810
2044
 
811
- const currentHash = ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
2045
+ const snapshot = this.screen.snapshot();
2046
+ if (completionHint && completionHint(snapshot)) {
2047
+ if (hintStableSince === null) {
2048
+ hintStableSince = Date.now();
2049
+ } else if (Date.now() - hintStableSince >= idleMs) {
2050
+ return;
2051
+ }
2052
+ } else {
2053
+ hintStableSince = null;
2054
+ }
2055
+
2056
+ const currentHash = ScreenSnapshot.computeHash(snapshot.scrollbackText);
812
2057
  if (currentHash !== lastHash) {
813
2058
  lastHash = currentHash;
814
2059
  lastChangeTime = Date.now();
@@ -835,6 +2080,48 @@ export class TuiDriver extends EventEmitter {
835
2080
  return null;
836
2081
  }
837
2082
 
2083
+ private anyAddedLineMatches(lines: string[], patterns?: RegExp[]): boolean {
2084
+ if (!patterns || patterns.length === 0 || lines.length === 0) {
2085
+ return false;
2086
+ }
2087
+ return lines.some((line) => this.lineMatchesAny(line, patterns));
2088
+ }
2089
+
2090
+ private lineMatchesAny(line: string, patterns: RegExp[]): boolean {
2091
+ for (const pattern of patterns) {
2092
+ pattern.lastIndex = 0;
2093
+ if (pattern.test(line)) {
2094
+ return true;
2095
+ }
2096
+ }
2097
+ return false;
2098
+ }
2099
+
2100
+ private hasNewScrollbackPatternSince(previousScrollback: string, currentScrollback: string, patterns?: RegExp[]): boolean {
2101
+ if (!patterns || patterns.length === 0) {
2102
+ return false;
2103
+ }
2104
+ const added = this.getChangedTailLines(previousScrollback, currentScrollback);
2105
+ return this.anyAddedLineMatches(added, patterns);
2106
+ }
2107
+
2108
+ private getChangedTailLines(previousScrollback: string, currentScrollback: string): string[] {
2109
+ const previousLines = previousScrollback.split("\n");
2110
+ const currentLines = currentScrollback.split("\n");
2111
+ const minLength = Math.min(previousLines.length, currentLines.length);
2112
+ let prefixLength = 0;
2113
+
2114
+ while (prefixLength < minLength && previousLines[prefixLength] === currentLines[prefixLength]) {
2115
+ prefixLength += 1;
2116
+ }
2117
+
2118
+ if (prefixLength >= currentLines.length) {
2119
+ return [];
2120
+ }
2121
+
2122
+ return currentLines.slice(prefixLength);
2123
+ }
2124
+
838
2125
  private extractReplyBlocks(
839
2126
  lines: string[],
840
2127
  signals: TuiSignals