@love-moon/tui-driver 0.2.12 → 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.
@@ -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";
@@ -33,6 +38,8 @@ export interface AskResult {
33
38
  replyInProgress?: boolean;
34
39
  statusLine?: string;
35
40
  statusDoneLine?: string;
41
+ sessionId?: string;
42
+ sessionFilePath?: string;
36
43
  }
37
44
 
38
45
  export type HealthReason =
@@ -60,6 +67,43 @@ export interface TuiScreenSignals {
60
67
  statusDoneLine?: string;
61
68
  }
62
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
+
63
107
  export class TuiDriver extends EventEmitter {
64
108
  private pty: PtySession;
65
109
  private screen: HeadlessScreen;
@@ -72,6 +116,12 @@ export class TuiDriver extends EventEmitter {
72
116
  private onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
73
117
  private isBooted = false;
74
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[];
75
125
 
76
126
  constructor(options: TuiDriverOptions) {
77
127
  super();
@@ -80,15 +130,18 @@ export class TuiDriver extends EventEmitter {
80
130
  this.debug = options.debug ?? false;
81
131
  this.onSnapshot = options.onSnapshot;
82
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] : [];
83
136
 
84
137
  const cols = this.profile.cols ?? 120;
85
138
  const rows = this.profile.rows ?? 40;
86
139
  const scrollback = this.profile.scrollback ?? 5000;
87
140
 
88
141
  this.pty = new PtySession(
89
- this.profile.command,
90
- this.profile.args,
91
- { cols, rows, env: this.profile.env, cwd: options.cwd }
142
+ this.initialCommand,
143
+ this.initialArgs,
144
+ { cols, rows, env: this.profile.env, cwd: this.sessionCwd }
92
145
  );
93
146
 
94
147
  this.screen = new HeadlessScreen({
@@ -114,6 +167,7 @@ export class TuiDriver extends EventEmitter {
114
167
  this.pty.onExit((code, signal) => {
115
168
  this.log(`PTY exited: code=${code}, signal=${signal}`);
116
169
  this.isBooted = false;
170
+ this.sessionInfo = null;
117
171
  this.emit("exit", code, signal);
118
172
  });
119
173
 
@@ -142,6 +196,60 @@ export class TuiDriver extends EventEmitter {
142
196
  return this.pty.isRunning;
143
197
  }
144
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
+
145
253
  async boot(): Promise<void> {
146
254
  if (this.isKilled) {
147
255
  throw this.createSessionClosedError();
@@ -152,6 +260,7 @@ export class TuiDriver extends EventEmitter {
152
260
  }
153
261
 
154
262
  this.stateMachine.transition("BOOT");
263
+ this.sessionInfo = null;
155
264
  this.pty.spawn();
156
265
 
157
266
  const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
@@ -244,6 +353,7 @@ export class TuiDriver extends EventEmitter {
244
353
 
245
354
  this.isBooted = true;
246
355
  this.stateMachine.transition("WAIT_READY");
356
+ await this.ensureSessionInfo();
247
357
  this.captureSnapshot("boot_complete");
248
358
  }
249
359
 
@@ -275,6 +385,7 @@ export class TuiDriver extends EventEmitter {
275
385
 
276
386
  async ask(prompt: string): Promise<AskResult> {
277
387
  const startTime = Date.now();
388
+ let sessionInfo: TuiSessionInfo | null = null;
278
389
 
279
390
  try {
280
391
  await this.ensureReady();
@@ -293,6 +404,16 @@ export class TuiDriver extends EventEmitter {
293
404
  (error as any).matchedPattern = health.matchedPattern;
294
405
  throw error;
295
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
+ }
296
417
  } else if (health.reason === "login_required") {
297
418
  const error = new Error(`Cannot proceed: ${health.message}`);
298
419
  (error as any).reason = health.reason;
@@ -313,6 +434,8 @@ export class TuiDriver extends EventEmitter {
313
434
  }
314
435
  }
315
436
 
437
+ sessionInfo = await this.ensureSessionInfo();
438
+
316
439
  this.stateMachine.transition("PREPARE_TURN");
317
440
  await this.prepareTurn();
318
441
 
@@ -327,17 +450,28 @@ export class TuiDriver extends EventEmitter {
327
450
 
328
451
  // 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
329
452
  const beforeSnapshot = this.captureSnapshot("after_submit");
453
+ const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
330
454
 
331
455
  this.stateMachine.transition("WAIT_STREAM_START");
332
- await this.waitStreamStart(preSubmitSnapshot);
456
+ if (sessionCheckpoint) {
457
+ await this.waitForSessionFileGrowth(sessionCheckpoint);
458
+ } else {
459
+ await this.waitStreamStart(preSubmitSnapshot);
460
+ }
333
461
 
334
462
  this.stateMachine.transition("WAIT_STREAM_END");
335
- await this.waitStreamEnd(beforeSnapshot);
463
+ if (sessionCheckpoint) {
464
+ await this.waitForSessionFileIdle(sessionCheckpoint);
465
+ } else {
466
+ await this.waitStreamEnd(beforeSnapshot);
467
+ }
336
468
 
337
469
  this.stateMachine.transition("CAPTURE");
338
470
  const afterSnapshot = this.captureSnapshot("after_response");
339
471
 
340
- const answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
472
+ const answer = sessionCheckpoint
473
+ ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
474
+ : this.extractAnswer(beforeSnapshot, afterSnapshot);
341
475
  const signals = this.getSignals(afterSnapshot);
342
476
 
343
477
  this.stateMachine.transition("DONE");
@@ -355,6 +489,8 @@ export class TuiDriver extends EventEmitter {
355
489
  replyInProgress: signals.replyInProgress,
356
490
  statusLine: signals.statusLine,
357
491
  statusDoneLine: signals.statusDoneLine,
492
+ sessionId: sessionInfo?.sessionId,
493
+ sessionFilePath: sessionInfo?.sessionFilePath,
358
494
  };
359
495
  } catch (error) {
360
496
  this.stateMachine.error(error as Error);
@@ -373,6 +509,8 @@ export class TuiDriver extends EventEmitter {
373
509
  replyInProgress: signals.replyInProgress,
374
510
  statusLine: signals.statusLine,
375
511
  statusDoneLine: signals.statusDoneLine,
512
+ sessionId: sessionInfo?.sessionId,
513
+ sessionFilePath: sessionInfo?.sessionFilePath,
376
514
  };
377
515
  }
378
516
  }
@@ -392,6 +530,855 @@ export class TuiDriver extends EventEmitter {
392
530
  await this.pty.sendKeys(this.profile.keys.submit, 50);
393
531
  }
394
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
+
395
1382
  private async waitStreamStart(previousSnapshot: ScreenSnapshot): Promise<void> {
396
1383
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
397
1384
 
@@ -690,13 +1677,98 @@ export class TuiDriver extends EventEmitter {
690
1677
 
691
1678
  private async restart(): Promise<void> {
692
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
+ }
693
1689
  this.pty.kill();
1690
+ this.pty.setCommandArgs(this.initialCommand, restartArgs);
694
1691
  this.screen.reset();
695
1692
  this.isBooted = false;
696
1693
  await this.sleep(500);
697
1694
  await this.boot();
698
1695
  }
699
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
+
700
1772
  private captureSnapshot(label: string): ScreenSnapshot {
701
1773
  const snapshot = this.screen.snapshot();
702
1774
  if (this.onSnapshot) {
@@ -886,11 +1958,17 @@ export class TuiDriver extends EventEmitter {
886
1958
  this.pty.write(data);
887
1959
  }
888
1960
 
1961
+ async forceRestart(): Promise<void> {
1962
+ await this.restart();
1963
+ }
1964
+
889
1965
  kill(): void {
890
1966
  this.isKilled = true;
891
1967
  this.pty.kill();
892
1968
  this.screen.dispose();
893
1969
  this.isBooted = false;
1970
+ this.sessionInfo = null;
1971
+ this.lastSessionInfo = null;
894
1972
  }
895
1973
 
896
1974
  private createSessionClosedError(): Error {
@@ -912,14 +1990,38 @@ export class TuiDriver extends EventEmitter {
912
1990
  private terminateSessionForLoginRequired(): void {
913
1991
  this.pty.kill();
914
1992
  this.isBooted = false;
1993
+ this.sessionInfo = null;
1994
+ this.lastSessionInfo = null;
915
1995
  }
916
1996
 
917
1997
  private resolveTimeout(timeoutMs: number | undefined, defaultTimeoutMs: number): number {
918
- const value = timeoutMs ?? defaultTimeoutMs;
919
- if (!Number.isFinite(value) || value <= 0) {
920
- 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;
921
2006
  }
922
- 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);
923
2025
  }
924
2026
 
925
2027
  private async waitForScrollbackIdle(