@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.
@@ -2,6 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TuiDriver = void 0;
4
4
  const events_1 = require("events");
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_fs_1 = require("node:fs");
7
+ const node_os_1 = require("node:os");
8
+ const node_path_1 = require("node:path");
9
+ const node_util_1 = require("node:util");
5
10
  const PtySession_js_1 = require("../pty/PtySession.js");
6
11
  const HeadlessScreen_js_1 = require("../term/HeadlessScreen.js");
7
12
  const ScreenSnapshot_js_1 = require("../term/ScreenSnapshot.js");
@@ -11,6 +16,12 @@ const OutputExtractor_js_1 = require("../extract/OutputExtractor.js");
11
16
  const Diff_js_1 = require("../extract/Diff.js");
12
17
  const StateMachine_js_1 = require("./StateMachine.js");
13
18
  const index_js_1 = require("./behavior/index.js");
19
+ const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
20
+ const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
21
+ const MIN_STAGE_TIMEOUT_MS = 100;
22
+ const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
23
+ const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
24
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
14
25
  class TuiDriver extends events_1.EventEmitter {
15
26
  pty;
16
27
  screen;
@@ -23,6 +34,12 @@ class TuiDriver extends events_1.EventEmitter {
23
34
  onSignals;
24
35
  isBooted = false;
25
36
  isKilled = false;
37
+ sessionCwd;
38
+ sessionInfo = null;
39
+ lastSessionInfo = null;
40
+ sessionUsageCache = null;
41
+ initialCommand;
42
+ initialArgs;
26
43
  constructor(options) {
27
44
  super();
28
45
  this.profile = options.profile;
@@ -30,10 +47,13 @@ class TuiDriver extends events_1.EventEmitter {
30
47
  this.debug = options.debug ?? false;
31
48
  this.onSnapshot = options.onSnapshot;
32
49
  this.onSignals = options.onSignals;
50
+ this.sessionCwd = options.cwd ?? process.cwd();
51
+ this.initialCommand = this.profile.command;
52
+ this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
33
53
  const cols = this.profile.cols ?? 120;
34
54
  const rows = this.profile.rows ?? 40;
35
55
  const scrollback = this.profile.scrollback ?? 5000;
36
- this.pty = new PtySession_js_1.PtySession(this.profile.command, this.profile.args, { cols, rows, env: this.profile.env, cwd: options.cwd });
56
+ this.pty = new PtySession_js_1.PtySession(this.initialCommand, this.initialArgs, { cols, rows, env: this.profile.env, cwd: this.sessionCwd });
37
57
  this.screen = new HeadlessScreen_js_1.HeadlessScreen({
38
58
  cols,
39
59
  rows,
@@ -54,6 +74,7 @@ class TuiDriver extends events_1.EventEmitter {
54
74
  this.pty.onExit((code, signal) => {
55
75
  this.log(`PTY exited: code=${code}, signal=${signal}`);
56
76
  this.isBooted = false;
77
+ this.sessionInfo = null;
57
78
  this.emit("exit", code, signal);
58
79
  });
59
80
  this.stateMachine.on("stateChange", (transition) => {
@@ -76,6 +97,50 @@ class TuiDriver extends events_1.EventEmitter {
76
97
  get running() {
77
98
  return this.pty.isRunning;
78
99
  }
100
+ getSessionInfo() {
101
+ const current = this.sessionInfo ?? this.lastSessionInfo;
102
+ return current ? { ...current } : null;
103
+ }
104
+ async ensureSessionInfo(timeoutMs = DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS) {
105
+ if (!this.supportsSessionFileTracking()) {
106
+ return null;
107
+ }
108
+ if (this.sessionInfo) {
109
+ return { ...this.sessionInfo };
110
+ }
111
+ const boundedTimeoutMs = this.resolveTimeout(timeoutMs, DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS);
112
+ const discovered = await this.discoverSessionInfo(boundedTimeoutMs);
113
+ return discovered ? { ...discovered } : null;
114
+ }
115
+ async getSessionUsageSummary() {
116
+ if (!this.supportsSessionFileTracking()) {
117
+ return null;
118
+ }
119
+ const sessionInfo = this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
120
+ if (!sessionInfo) {
121
+ return null;
122
+ }
123
+ const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
124
+ if (this.sessionUsageCache &&
125
+ this.sessionUsageCache.backend === sessionInfo.backend &&
126
+ this.sessionUsageCache.sessionId === sessionInfo.sessionId &&
127
+ this.sessionUsageCache.sessionFilePath === sessionInfo.sessionFilePath &&
128
+ this.sessionUsageCache.size === size &&
129
+ this.sessionUsageCache.mtimeMs === mtimeMs) {
130
+ return { ...this.sessionUsageCache.summary };
131
+ }
132
+ const lines = await this.readSessionFileJsonLines(sessionInfo.sessionFilePath, 0);
133
+ const summary = this.extractSessionUsageSummaryFromJsonLines(lines, sessionInfo);
134
+ this.sessionUsageCache = {
135
+ backend: sessionInfo.backend,
136
+ sessionId: sessionInfo.sessionId,
137
+ sessionFilePath: sessionInfo.sessionFilePath,
138
+ size,
139
+ mtimeMs,
140
+ summary,
141
+ };
142
+ return { ...summary };
143
+ }
79
144
  async boot() {
80
145
  if (this.isKilled) {
81
146
  throw this.createSessionClosedError();
@@ -84,6 +149,7 @@ class TuiDriver extends events_1.EventEmitter {
84
149
  return;
85
150
  }
86
151
  this.stateMachine.transition("BOOT");
152
+ this.sessionInfo = null;
87
153
  this.pty.spawn();
88
154
  const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
89
155
  const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
@@ -168,6 +234,7 @@ class TuiDriver extends events_1.EventEmitter {
168
234
  }
169
235
  this.isBooted = true;
170
236
  this.stateMachine.transition("WAIT_READY");
237
+ await this.ensureSessionInfo();
171
238
  this.captureSnapshot("boot_complete");
172
239
  }
173
240
  async ensureReady() {
@@ -194,6 +261,7 @@ class TuiDriver extends events_1.EventEmitter {
194
261
  }
195
262
  async ask(prompt) {
196
263
  const startTime = Date.now();
264
+ let sessionInfo = null;
197
265
  try {
198
266
  await this.ensureReady();
199
267
  // 健康检查:在执行前检测异常状态
@@ -210,6 +278,17 @@ class TuiDriver extends events_1.EventEmitter {
210
278
  throw error;
211
279
  }
212
280
  }
281
+ else if (health.reason === "process_exited") {
282
+ this.log("Health check detected exited process, attempting forced restart");
283
+ await this.restart();
284
+ const healthAfterRestart = this.healthCheck();
285
+ if (!healthAfterRestart.healthy) {
286
+ const error = new Error(`Cannot proceed: ${healthAfterRestart.message || healthAfterRestart.reason}`);
287
+ error.reason = healthAfterRestart.reason;
288
+ error.matchedPattern = healthAfterRestart.matchedPattern;
289
+ throw error;
290
+ }
291
+ }
213
292
  else if (health.reason === "login_required") {
214
293
  const error = new Error(`Cannot proceed: ${health.message}`);
215
294
  error.reason = health.reason;
@@ -230,6 +309,7 @@ class TuiDriver extends events_1.EventEmitter {
230
309
  }
231
310
  }
232
311
  }
312
+ sessionInfo = await this.ensureSessionInfo();
233
313
  this.stateMachine.transition("PREPARE_TURN");
234
314
  await this.prepareTurn();
235
315
  this.stateMachine.transition("TYPE_PROMPT");
@@ -240,13 +320,26 @@ class TuiDriver extends events_1.EventEmitter {
240
320
  await this.submit();
241
321
  // 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
242
322
  const beforeSnapshot = this.captureSnapshot("after_submit");
323
+ const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
243
324
  this.stateMachine.transition("WAIT_STREAM_START");
244
- await this.waitStreamStart(preSubmitSnapshot);
325
+ if (sessionCheckpoint) {
326
+ await this.waitForSessionFileGrowth(sessionCheckpoint);
327
+ }
328
+ else {
329
+ await this.waitStreamStart(preSubmitSnapshot);
330
+ }
245
331
  this.stateMachine.transition("WAIT_STREAM_END");
246
- await this.waitStreamEnd(beforeSnapshot);
332
+ if (sessionCheckpoint) {
333
+ await this.waitForSessionFileIdle(sessionCheckpoint);
334
+ }
335
+ else {
336
+ await this.waitStreamEnd(beforeSnapshot);
337
+ }
247
338
  this.stateMachine.transition("CAPTURE");
248
339
  const afterSnapshot = this.captureSnapshot("after_response");
249
- const answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
340
+ const answer = sessionCheckpoint
341
+ ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
342
+ : this.extractAnswer(beforeSnapshot, afterSnapshot);
250
343
  const signals = this.getSignals(afterSnapshot);
251
344
  this.stateMachine.transition("DONE");
252
345
  return {
@@ -262,6 +355,8 @@ class TuiDriver extends events_1.EventEmitter {
262
355
  replyInProgress: signals.replyInProgress,
263
356
  statusLine: signals.statusLine,
264
357
  statusDoneLine: signals.statusDoneLine,
358
+ sessionId: sessionInfo?.sessionId,
359
+ sessionFilePath: sessionInfo?.sessionFilePath,
265
360
  };
266
361
  }
267
362
  catch (error) {
@@ -281,6 +376,8 @@ class TuiDriver extends events_1.EventEmitter {
281
376
  replyInProgress: signals.replyInProgress,
282
377
  statusLine: signals.statusLine,
283
378
  statusDoneLine: signals.statusDoneLine,
379
+ sessionId: sessionInfo?.sessionId,
380
+ sessionFilePath: sessionInfo?.sessionFilePath,
284
381
  };
285
382
  }
286
383
  }
@@ -296,6 +393,722 @@ class TuiDriver extends events_1.EventEmitter {
296
393
  async submit() {
297
394
  await this.pty.sendKeys(this.profile.keys.submit, 50);
298
395
  }
396
+ supportsSessionFileTracking() {
397
+ const backend = String(this.profile.name || "").toLowerCase();
398
+ return backend === "codex" || backend === "claude-code" || backend === "copilot";
399
+ }
400
+ async discoverSessionInfo(timeoutMs) {
401
+ const startedAt = Date.now();
402
+ const deadline = startedAt + Math.max(MIN_STAGE_TIMEOUT_MS, timeoutMs);
403
+ while (Date.now() < deadline) {
404
+ this.assertAliveOrThrow();
405
+ const discovered = await this.detectSessionInfoByBackend();
406
+ if (discovered) {
407
+ const changed = !this.sessionInfo ||
408
+ this.sessionInfo.sessionId !== discovered.sessionId ||
409
+ this.sessionInfo.sessionFilePath !== discovered.sessionFilePath;
410
+ this.sessionInfo = discovered;
411
+ this.lastSessionInfo = discovered;
412
+ if (changed) {
413
+ this.emit("session", { ...discovered });
414
+ this.log(`session discovered: id=${discovered.sessionId} file=${discovered.sessionFilePath}`);
415
+ }
416
+ return discovered;
417
+ }
418
+ await this.sleep(250);
419
+ }
420
+ return this.sessionInfo ? { ...this.sessionInfo } : null;
421
+ }
422
+ async detectSessionInfoByBackend() {
423
+ if (!this.supportsSessionFileTracking()) {
424
+ return null;
425
+ }
426
+ try {
427
+ switch (this.profile.name) {
428
+ case "codex":
429
+ return this.detectCodexSessionInfo();
430
+ case "claude-code":
431
+ return this.detectClaudeSessionInfo();
432
+ case "copilot":
433
+ return this.detectCopilotSessionInfo();
434
+ default:
435
+ return null;
436
+ }
437
+ }
438
+ catch (error) {
439
+ this.log(`session detect failed: ${error?.message || error}`);
440
+ return null;
441
+ }
442
+ }
443
+ async detectCodexSessionInfo() {
444
+ const dbPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "state_5.sqlite");
445
+ if (!(await this.pathExists(dbPath))) {
446
+ return null;
447
+ }
448
+ const parseRowAsSessionInfo = async (row) => {
449
+ if (!row) {
450
+ return null;
451
+ }
452
+ const [sessionIdRaw, sessionFilePathRaw] = row.split("|");
453
+ const sessionId = String(sessionIdRaw || "").trim();
454
+ const sessionFilePath = String(sessionFilePathRaw || "").trim();
455
+ if (!sessionId || !sessionFilePath || !(await this.pathExists(sessionFilePath))) {
456
+ return null;
457
+ }
458
+ return {
459
+ backend: "codex",
460
+ sessionId,
461
+ sessionFilePath,
462
+ };
463
+ };
464
+ const pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
465
+ if (pinnedSessionId) {
466
+ const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
467
+ const pinnedRow = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`);
468
+ const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
469
+ if (pinnedSession) {
470
+ return pinnedSession;
471
+ }
472
+ }
473
+ const escapedCwd = this.sessionCwd.replace(/'/g, "''");
474
+ const row = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and cwd='${escapedCwd}' order by updated_at desc limit 1;`);
475
+ return parseRowAsSessionInfo(row);
476
+ }
477
+ async detectClaudeSessionInfo() {
478
+ const projectDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects", this.encodeClaudeProjectPath(this.sessionCwd));
479
+ if (!(await this.pathExists(projectDir))) {
480
+ return null;
481
+ }
482
+ const indexPath = (0, node_path_1.join)(projectDir, "sessions-index.json");
483
+ if (await this.pathExists(indexPath)) {
484
+ try {
485
+ const raw = await node_fs_1.promises.readFile(indexPath, "utf8");
486
+ const parsed = JSON.parse(raw);
487
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
488
+ const candidates = entries
489
+ .filter((entry) => {
490
+ const entrySessionId = String(entry?.sessionId || "").trim();
491
+ if (!entrySessionId) {
492
+ return false;
493
+ }
494
+ const entryProjectPath = String(entry?.projectPath || "").trim();
495
+ return !entryProjectPath || entryProjectPath === this.sessionCwd;
496
+ })
497
+ .sort((a, b) => {
498
+ const scoreA = Number(a?.fileMtime || Date.parse(String(a?.modified || "")) || 0);
499
+ const scoreB = Number(b?.fileMtime || Date.parse(String(b?.modified || "")) || 0);
500
+ return scoreB - scoreA;
501
+ });
502
+ for (const entry of candidates) {
503
+ const sessionId = String(entry.sessionId || "").trim();
504
+ const sessionFilePath = String(entry.fullPath || "").trim() || (0, node_path_1.join)(projectDir, `${sessionId}.jsonl`);
505
+ if (sessionId && sessionFilePath && (await this.pathExists(sessionFilePath))) {
506
+ return {
507
+ backend: "claude-code",
508
+ sessionId,
509
+ sessionFilePath,
510
+ };
511
+ }
512
+ }
513
+ }
514
+ catch (error) {
515
+ this.log(`claude session index parse failed: ${error?.message || error}`);
516
+ }
517
+ }
518
+ try {
519
+ const dirents = await node_fs_1.promises.readdir(projectDir, { withFileTypes: true });
520
+ const jsonlFiles = dirents
521
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
522
+ .map((entry) => (0, node_path_1.join)(projectDir, entry.name));
523
+ const stats = await Promise.all(jsonlFiles.map(async (filePath) => ({
524
+ filePath,
525
+ mtimeMs: (await node_fs_1.promises.stat(filePath)).mtimeMs,
526
+ })));
527
+ stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
528
+ const latest = stats[0];
529
+ if (!latest) {
530
+ return null;
531
+ }
532
+ const sessionId = (0, node_path_1.basename)(latest.filePath, ".jsonl");
533
+ return {
534
+ backend: "claude-code",
535
+ sessionId,
536
+ sessionFilePath: latest.filePath,
537
+ };
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ }
543
+ async detectCopilotSessionInfo() {
544
+ const baseDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".copilot", "session-state");
545
+ if (!(await this.pathExists(baseDir))) {
546
+ return null;
547
+ }
548
+ try {
549
+ const dirents = await node_fs_1.promises.readdir(baseDir, { withFileTypes: true });
550
+ const candidates = [];
551
+ for (const entry of dirents) {
552
+ if (!entry.isDirectory()) {
553
+ continue;
554
+ }
555
+ const sessionDir = (0, node_path_1.join)(baseDir, entry.name);
556
+ const workspacePath = (0, node_path_1.join)(sessionDir, "workspace.yaml");
557
+ const eventsPath = (0, node_path_1.join)(sessionDir, "events.jsonl");
558
+ if (!(await this.pathExists(eventsPath))) {
559
+ continue;
560
+ }
561
+ let workspaceCwd = "";
562
+ let workspaceId = "";
563
+ if (await this.pathExists(workspacePath)) {
564
+ workspaceCwd = (await this.readWorkspaceYamlValue(workspacePath, "cwd")) || "";
565
+ workspaceId = (await this.readWorkspaceYamlValue(workspacePath, "id")) || "";
566
+ }
567
+ if (workspaceCwd && workspaceCwd !== this.sessionCwd) {
568
+ continue;
569
+ }
570
+ const sessionId = workspaceId || entry.name;
571
+ const mtimeMs = (await node_fs_1.promises.stat(eventsPath)).mtimeMs;
572
+ candidates.push({
573
+ sessionId,
574
+ sessionFilePath: eventsPath,
575
+ mtimeMs,
576
+ });
577
+ }
578
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
579
+ const latest = candidates[0];
580
+ if (!latest) {
581
+ return null;
582
+ }
583
+ return {
584
+ backend: "copilot",
585
+ sessionId: latest.sessionId,
586
+ sessionFilePath: latest.sessionFilePath,
587
+ };
588
+ }
589
+ catch {
590
+ return null;
591
+ }
592
+ }
593
+ async querySqliteRow(dbPath, query) {
594
+ try {
595
+ const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
596
+ timeout: 3000,
597
+ maxBuffer: 1024 * 1024,
598
+ });
599
+ const lines = String(stdout || "")
600
+ .split(/\r?\n/)
601
+ .map((line) => line.trim())
602
+ .filter(Boolean);
603
+ return lines[0] ?? null;
604
+ }
605
+ catch (error) {
606
+ this.log(`sqlite query failed: ${error?.message || error}`);
607
+ return null;
608
+ }
609
+ }
610
+ encodeClaudeProjectPath(cwd) {
611
+ return String(cwd || "").replace(/\//g, "-");
612
+ }
613
+ async readWorkspaceYamlValue(filePath, key) {
614
+ try {
615
+ const raw = await node_fs_1.promises.readFile(filePath, "utf8");
616
+ const matcher = new RegExp(`^${key}:\\s*(.+)\\s*$`, "m");
617
+ const match = raw.match(matcher);
618
+ if (!match) {
619
+ return null;
620
+ }
621
+ const value = match[1].trim();
622
+ if (!value) {
623
+ return null;
624
+ }
625
+ return value.replace(/^['"]|['"]$/g, "");
626
+ }
627
+ catch {
628
+ return null;
629
+ }
630
+ }
631
+ async pathExists(filePath) {
632
+ try {
633
+ await node_fs_1.promises.access(filePath);
634
+ return true;
635
+ }
636
+ catch {
637
+ return false;
638
+ }
639
+ }
640
+ async captureSessionFileCheckpoint(sessionInfo) {
641
+ if (!sessionInfo) {
642
+ return null;
643
+ }
644
+ const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
645
+ return {
646
+ sessionInfo,
647
+ size,
648
+ mtimeMs,
649
+ };
650
+ }
651
+ async readSessionFileStat(sessionFilePath) {
652
+ try {
653
+ const stats = await node_fs_1.promises.stat(sessionFilePath);
654
+ return {
655
+ size: stats.size,
656
+ mtimeMs: stats.mtimeMs,
657
+ };
658
+ }
659
+ catch {
660
+ return {
661
+ size: 0,
662
+ mtimeMs: 0,
663
+ };
664
+ }
665
+ }
666
+ async waitForSessionFileGrowth(checkpoint) {
667
+ const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
668
+ const startedAt = Date.now();
669
+ let lastSize = checkpoint.size;
670
+ let lastMtimeMs = checkpoint.mtimeMs;
671
+ while (Date.now() - startedAt < timeoutMs) {
672
+ this.assertAliveOrThrow();
673
+ const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
674
+ const changed = current.size !== lastSize || current.mtimeMs !== lastMtimeMs;
675
+ if (changed) {
676
+ this.log(`session file growth detected: ${checkpoint.sessionInfo.sessionFilePath} (${lastSize} -> ${current.size})`);
677
+ return;
678
+ }
679
+ await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
680
+ }
681
+ throw new Error("Stream start timeout: session file did not grow");
682
+ }
683
+ async waitForSessionFileIdle(checkpoint) {
684
+ const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
685
+ const startedAt = Date.now();
686
+ let previousSize = checkpoint.size;
687
+ let previousMtimeMs = checkpoint.mtimeMs;
688
+ let observedProgress = false;
689
+ let unchangedChecks = 0;
690
+ const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
691
+ let completionMarkerSeen = false;
692
+ while (Date.now() - startedAt < timeoutMs) {
693
+ this.assertAliveOrThrow();
694
+ const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
695
+ const changed = current.size !== previousSize || current.mtimeMs !== previousMtimeMs;
696
+ if (changed) {
697
+ this.log(`session file changed: backend=${checkpoint.sessionInfo.backend} size=${previousSize}->${current.size} mtime=${previousMtimeMs}->${current.mtimeMs}`);
698
+ observedProgress = true;
699
+ unchangedChecks = 0;
700
+ previousSize = current.size;
701
+ previousMtimeMs = current.mtimeMs;
702
+ if (requireCompletionMarker && !completionMarkerSeen) {
703
+ completionMarkerSeen = await this.hasSessionCompletionMarker(checkpoint, current.size);
704
+ if (completionMarkerSeen) {
705
+ this.log(`session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
706
+ }
707
+ }
708
+ }
709
+ else {
710
+ unchangedChecks += 1;
711
+ this.log(`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
712
+ if (observedProgress && unchangedChecks >= 2) {
713
+ if (!requireCompletionMarker || completionMarkerSeen) {
714
+ this.log(`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
715
+ return;
716
+ }
717
+ }
718
+ }
719
+ await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
720
+ }
721
+ if (!observedProgress) {
722
+ throw new Error("Stream end timeout: session file did not grow");
723
+ }
724
+ if (requireCompletionMarker && !completionMarkerSeen) {
725
+ throw new Error("Stream end timeout: session completion marker not observed");
726
+ }
727
+ throw new Error("Stream end timeout: session file did not become stable");
728
+ }
729
+ async extractAnswerFromSessionFile(checkpoint) {
730
+ const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size);
731
+ const codexTaskCompleteMessage = this.extractCodexTaskCompleteMessageFromJsonLines(lines);
732
+ if (codexTaskCompleteMessage) {
733
+ this.log(`session answer source=codex.task_complete preview="${this.summarizeForLog(codexTaskCompleteMessage, 160)}"`);
734
+ return codexTaskCompleteMessage;
735
+ }
736
+ const answer = this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend);
737
+ if (answer) {
738
+ this.log(`session answer source=${checkpoint.sessionInfo.backend}.assistant preview="${this.summarizeForLog(answer, 160)}"`);
739
+ return answer;
740
+ }
741
+ throw new Error("No assistant reply found in session file");
742
+ }
743
+ extractSessionUsageSummaryFromJsonLines(lines, sessionInfo) {
744
+ const backend = sessionInfo.backend;
745
+ const baseSummary = {
746
+ backend,
747
+ sessionId: sessionInfo.sessionId,
748
+ sessionFilePath: sessionInfo.sessionFilePath,
749
+ };
750
+ const usage = backend === "codex"
751
+ ? this.extractCodexUsageFromJsonLines(lines)
752
+ : backend === "claude-code"
753
+ ? this.extractClaudeUsageFromJsonLines(lines)
754
+ : backend === "copilot"
755
+ ? this.extractCopilotUsageFromJsonLines(lines)
756
+ : {};
757
+ return {
758
+ ...baseSummary,
759
+ ...usage,
760
+ };
761
+ }
762
+ extractCodexUsageFromJsonLines(lines) {
763
+ let tokenUsagePercent;
764
+ let contextUsagePercent;
765
+ for (const line of lines) {
766
+ let entry = null;
767
+ try {
768
+ entry = JSON.parse(line);
769
+ }
770
+ catch {
771
+ continue;
772
+ }
773
+ if (!entry || entry.type !== "event_msg") {
774
+ continue;
775
+ }
776
+ const payload = entry.payload;
777
+ if (!payload || typeof payload !== "object") {
778
+ continue;
779
+ }
780
+ const secondaryUsedPercent = this.readNumberPath(payload, ["rate_limits", "secondary", "used_percent"]);
781
+ if (secondaryUsedPercent !== undefined) {
782
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
783
+ }
784
+ const inputTokens = this.readNumberPath(payload, ["info", "last_token_usage", "input_tokens"]);
785
+ const contextWindow = this.readNumberPath(payload, ["info", "model_context_window"]);
786
+ if (inputTokens !== undefined && contextWindow !== undefined && contextWindow > 0) {
787
+ contextUsagePercent = this.normalizePercent((inputTokens / contextWindow) * 100);
788
+ }
789
+ }
790
+ return {
791
+ tokenUsagePercent,
792
+ contextUsagePercent,
793
+ };
794
+ }
795
+ extractClaudeUsageFromJsonLines(lines) {
796
+ let tokenUsagePercent;
797
+ let contextUsagePercent;
798
+ let latestInputTokens;
799
+ let latestContextWindow;
800
+ for (const line of lines) {
801
+ let entry = null;
802
+ try {
803
+ entry = JSON.parse(line);
804
+ }
805
+ catch {
806
+ continue;
807
+ }
808
+ if (!entry || typeof entry !== "object") {
809
+ continue;
810
+ }
811
+ const secondaryUsedPercent = this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
812
+ this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
813
+ if (secondaryUsedPercent !== undefined) {
814
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
815
+ }
816
+ const inputTokens = this.readNumberPath(entry, ["message", "usage", "input_tokens"]) ??
817
+ this.readNumberPath(entry, ["message", "usage", "inputTokens"]) ??
818
+ this.readNumberPath(entry, ["usage", "input_tokens"]) ??
819
+ this.readNumberPath(entry, ["usage", "inputTokens"]);
820
+ if (inputTokens !== undefined) {
821
+ latestInputTokens = inputTokens;
822
+ }
823
+ const contextWindow = this.readNumberPath(entry, ["message", "model_context_window"]) ??
824
+ this.readNumberPath(entry, ["message", "modelContextWindow"]) ??
825
+ this.readNumberPath(entry, ["message", "context_window"]) ??
826
+ this.readNumberPath(entry, ["message", "contextWindow"]) ??
827
+ this.readNumberPath(entry, ["model_context_window"]) ??
828
+ this.readNumberPath(entry, ["modelContextWindow"]) ??
829
+ this.readNumberPath(entry, ["context_window"]) ??
830
+ this.readNumberPath(entry, ["contextWindow"]);
831
+ if (contextWindow !== undefined && contextWindow > 0) {
832
+ latestContextWindow = contextWindow;
833
+ }
834
+ }
835
+ if (latestInputTokens !== undefined &&
836
+ latestContextWindow !== undefined &&
837
+ latestContextWindow > 0) {
838
+ contextUsagePercent = this.normalizePercent((latestInputTokens / latestContextWindow) * 100);
839
+ }
840
+ return {
841
+ tokenUsagePercent,
842
+ contextUsagePercent,
843
+ };
844
+ }
845
+ extractCopilotUsageFromJsonLines(lines) {
846
+ let tokenUsagePercent;
847
+ let contextUsagePercent;
848
+ let latestContextTokens;
849
+ let latestContextLimit;
850
+ for (const line of lines) {
851
+ let entry = null;
852
+ try {
853
+ entry = JSON.parse(line);
854
+ }
855
+ catch {
856
+ continue;
857
+ }
858
+ if (!entry || typeof entry !== "object") {
859
+ continue;
860
+ }
861
+ const secondaryUsedPercent = this.readNumberPath(entry, ["data", "rate_limits", "secondary", "used_percent"]) ??
862
+ this.readNumberPath(entry, ["data", "rateLimits", "secondary", "usedPercent"]) ??
863
+ this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
864
+ this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
865
+ if (secondaryUsedPercent !== undefined) {
866
+ tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
867
+ }
868
+ const responseTokenLimit = this.readNumberPath(entry, ["data", "toolTelemetry", "metrics", "responseTokenLimit"]) ??
869
+ this.readNumberPath(entry, ["data", "responseTokenLimit"]) ??
870
+ this.readNumberPath(entry, ["data", "modelContextWindow"]) ??
871
+ this.readNumberPath(entry, ["data", "model_context_window"]) ??
872
+ this.readNumberPath(entry, ["data", "contextWindow"]) ??
873
+ this.readNumberPath(entry, ["data", "context_window"]) ??
874
+ this.readNumberPath(entry, ["responseTokenLimit"]);
875
+ if (responseTokenLimit !== undefined && responseTokenLimit > 0) {
876
+ latestContextLimit = responseTokenLimit;
877
+ }
878
+ const contextTokens = this.readNumberPath(entry, ["data", "preCompactionTokens"]) ??
879
+ this.readNumberPath(entry, ["data", "compactionTokensUsed", "input"]) ??
880
+ this.readNumberPath(entry, ["data", "postCompactionTokens"]) ??
881
+ this.readNumberPath(entry, ["data", "tokenUsage", "input_tokens"]) ??
882
+ this.readNumberPath(entry, ["data", "usage", "input_tokens"]) ??
883
+ this.readNumberPath(entry, ["data", "inputTokens"]);
884
+ if (contextTokens !== undefined && contextTokens >= 0) {
885
+ latestContextTokens = contextTokens;
886
+ }
887
+ }
888
+ if (latestContextTokens !== undefined &&
889
+ latestContextLimit !== undefined &&
890
+ latestContextLimit > 0) {
891
+ contextUsagePercent = this.normalizePercent((latestContextTokens / latestContextLimit) * 100);
892
+ }
893
+ return {
894
+ tokenUsagePercent,
895
+ contextUsagePercent,
896
+ };
897
+ }
898
+ readNumberPath(source, path) {
899
+ let cursor = source;
900
+ for (const key of path) {
901
+ if (!cursor || typeof cursor !== "object") {
902
+ return undefined;
903
+ }
904
+ cursor = cursor[key];
905
+ }
906
+ if (typeof cursor === "number" && Number.isFinite(cursor)) {
907
+ return cursor;
908
+ }
909
+ if (typeof cursor === "string") {
910
+ const parsed = Number(cursor);
911
+ if (Number.isFinite(parsed)) {
912
+ return parsed;
913
+ }
914
+ }
915
+ return undefined;
916
+ }
917
+ normalizePercent(value) {
918
+ if (!Number.isFinite(value)) {
919
+ return 0;
920
+ }
921
+ if (value < 0) {
922
+ return 0;
923
+ }
924
+ if (value > 100) {
925
+ return 100;
926
+ }
927
+ return value;
928
+ }
929
+ requiresSessionCompletionMarker(backend) {
930
+ return backend === "codex" || backend === "copilot";
931
+ }
932
+ async hasSessionCompletionMarker(checkpoint, endOffset) {
933
+ const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size, endOffset);
934
+ if (checkpoint.sessionInfo.backend === "codex") {
935
+ return this.hasCodexTaskCompleteFromJsonLines(lines);
936
+ }
937
+ if (checkpoint.sessionInfo.backend === "copilot") {
938
+ return this.hasCopilotTurnEndFromJsonLines(lines);
939
+ }
940
+ return false;
941
+ }
942
+ async readSessionFileJsonLines(sessionFilePath, startOffset = 0, endOffset) {
943
+ let fullBuffer;
944
+ try {
945
+ fullBuffer = await node_fs_1.promises.readFile(sessionFilePath);
946
+ }
947
+ catch {
948
+ return [];
949
+ }
950
+ const boundedStartOffset = Math.max(0, Math.min(startOffset, fullBuffer.length));
951
+ const boundedEndOffset = Number.isFinite(endOffset)
952
+ ? Math.max(boundedStartOffset, Math.min(Number(endOffset), fullBuffer.length))
953
+ : fullBuffer.length;
954
+ return fullBuffer
955
+ .subarray(boundedStartOffset, boundedEndOffset)
956
+ .toString("utf8")
957
+ .split(/\r?\n/)
958
+ .map((line) => line.trim())
959
+ .filter(Boolean);
960
+ }
961
+ extractAssistantReplyFromJsonLines(lines, backend) {
962
+ const replies = [];
963
+ for (const line of lines) {
964
+ let entry = null;
965
+ try {
966
+ entry = JSON.parse(line);
967
+ }
968
+ catch {
969
+ continue;
970
+ }
971
+ if (!entry || typeof entry !== "object") {
972
+ continue;
973
+ }
974
+ const text = backend === "codex"
975
+ ? this.extractCodexAssistantText(entry)
976
+ : backend === "claude-code"
977
+ ? this.extractClaudeAssistantText(entry)
978
+ : backend === "copilot"
979
+ ? this.extractCopilotAssistantText(entry)
980
+ : "";
981
+ if (text) {
982
+ replies.push(text);
983
+ }
984
+ }
985
+ return replies.length > 0 ? replies[replies.length - 1] : "";
986
+ }
987
+ hasCodexTaskCompleteFromJsonLines(lines) {
988
+ for (const line of lines) {
989
+ let entry = null;
990
+ try {
991
+ entry = JSON.parse(line);
992
+ }
993
+ catch {
994
+ continue;
995
+ }
996
+ if (entry && this.isCodexTaskCompleteEntry(entry)) {
997
+ return true;
998
+ }
999
+ }
1000
+ return false;
1001
+ }
1002
+ hasCopilotTurnEndFromJsonLines(lines) {
1003
+ for (const line of lines) {
1004
+ let entry = null;
1005
+ try {
1006
+ entry = JSON.parse(line);
1007
+ }
1008
+ catch {
1009
+ continue;
1010
+ }
1011
+ if (entry && this.isCopilotTurnEndEntry(entry)) {
1012
+ return true;
1013
+ }
1014
+ }
1015
+ return false;
1016
+ }
1017
+ extractCodexTaskCompleteMessageFromJsonLines(lines) {
1018
+ let latestMessage = "";
1019
+ for (const line of lines) {
1020
+ let entry = null;
1021
+ try {
1022
+ entry = JSON.parse(line);
1023
+ }
1024
+ catch {
1025
+ continue;
1026
+ }
1027
+ if (!entry || !this.isCodexTaskCompleteEntry(entry)) {
1028
+ continue;
1029
+ }
1030
+ const payload = entry.payload;
1031
+ const message = typeof payload?.last_agent_message === "string" ? payload.last_agent_message.trim() : "";
1032
+ if (message) {
1033
+ latestMessage = message;
1034
+ }
1035
+ }
1036
+ return latestMessage;
1037
+ }
1038
+ isCodexTaskCompleteEntry(entry) {
1039
+ if (entry.type !== "event_msg") {
1040
+ return false;
1041
+ }
1042
+ const payload = entry.payload;
1043
+ return payload?.type === "task_complete";
1044
+ }
1045
+ isCopilotTurnEndEntry(entry) {
1046
+ return entry.type === "assistant.turn_end";
1047
+ }
1048
+ summarizeForLog(value, maxLen = 160) {
1049
+ const normalized = String(value || "").replace(/\s+/g, " ").trim();
1050
+ if (!normalized) {
1051
+ return "";
1052
+ }
1053
+ if (normalized.length <= maxLen) {
1054
+ return normalized;
1055
+ }
1056
+ return `${normalized.slice(0, maxLen)}...`;
1057
+ }
1058
+ extractCodexAssistantText(entry) {
1059
+ if (entry.type !== "response_item") {
1060
+ return "";
1061
+ }
1062
+ const payload = entry.payload;
1063
+ if (!payload || payload.type !== "message" || payload.role !== "assistant") {
1064
+ return "";
1065
+ }
1066
+ const content = payload.content;
1067
+ if (!Array.isArray(content)) {
1068
+ return "";
1069
+ }
1070
+ const text = content
1071
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
1072
+ .filter(Boolean)
1073
+ .join("\n")
1074
+ .trim();
1075
+ return text;
1076
+ }
1077
+ extractClaudeAssistantText(entry) {
1078
+ if (entry.type !== "assistant") {
1079
+ return "";
1080
+ }
1081
+ const message = entry.message;
1082
+ if (!message || message.role !== "assistant") {
1083
+ return "";
1084
+ }
1085
+ const content = message.content;
1086
+ if (typeof content === "string") {
1087
+ return content.trim();
1088
+ }
1089
+ if (!Array.isArray(content)) {
1090
+ return "";
1091
+ }
1092
+ const text = content
1093
+ .map((block) => {
1094
+ const typed = block;
1095
+ return typed?.type === "text" && typeof typed?.text === "string" ? typed.text : "";
1096
+ })
1097
+ .filter(Boolean)
1098
+ .join("\n")
1099
+ .trim();
1100
+ return text;
1101
+ }
1102
+ extractCopilotAssistantText(entry) {
1103
+ if (entry.type !== "assistant.message") {
1104
+ return "";
1105
+ }
1106
+ const data = entry.data;
1107
+ if (!data || typeof data.content !== "string") {
1108
+ return "";
1109
+ }
1110
+ return data.content.trim();
1111
+ }
299
1112
  async waitStreamStart(previousSnapshot) {
300
1113
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
301
1114
  if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
@@ -571,12 +1384,88 @@ class TuiDriver extends events_1.EventEmitter {
571
1384
  }
572
1385
  async restart() {
573
1386
  this.log("Restarting PTY...");
1387
+ const restartSession = await this.resolveRestartSessionInfo();
1388
+ const restartArgs = this.resolveRestartArgs(restartSession?.sessionId);
1389
+ if (restartSession?.sessionId) {
1390
+ this.log(`restart resume target: backend=${this.profile.name} session=${restartSession.sessionId} args=${JSON.stringify(restartArgs)}`);
1391
+ }
1392
+ else {
1393
+ this.log(`restart without resume: backend=${this.profile.name} args=${JSON.stringify(restartArgs)}`);
1394
+ }
574
1395
  this.pty.kill();
1396
+ this.pty.setCommandArgs(this.initialCommand, restartArgs);
575
1397
  this.screen.reset();
576
1398
  this.isBooted = false;
577
1399
  await this.sleep(500);
578
1400
  await this.boot();
579
1401
  }
1402
+ async resolveRestartSessionInfo() {
1403
+ const cached = this.sessionInfo ?? this.lastSessionInfo;
1404
+ if (cached?.sessionId) {
1405
+ return { ...cached };
1406
+ }
1407
+ if (!this.supportsSessionFileTracking()) {
1408
+ return null;
1409
+ }
1410
+ const detected = await this.detectSessionInfoByBackend();
1411
+ if (!detected?.sessionId) {
1412
+ return null;
1413
+ }
1414
+ this.lastSessionInfo = detected;
1415
+ return { ...detected };
1416
+ }
1417
+ resolveRestartArgs(sessionId) {
1418
+ const normalizedSessionId = String(sessionId || "").trim();
1419
+ if (!normalizedSessionId) {
1420
+ return [...this.initialArgs];
1421
+ }
1422
+ const baseArgs = this.stripResumeArgs(this.initialArgs, this.profile.name);
1423
+ const resumeArgs = this.buildResumeArgsForBackend(this.profile.name, normalizedSessionId);
1424
+ if (resumeArgs.length === 0) {
1425
+ return [...this.initialArgs];
1426
+ }
1427
+ return [...baseArgs, ...resumeArgs];
1428
+ }
1429
+ stripResumeArgs(args, backendName) {
1430
+ const result = [];
1431
+ const backend = String(backendName || "").toLowerCase();
1432
+ for (let index = 0; index < args.length; index += 1) {
1433
+ const current = String(args[index] || "");
1434
+ const next = args[index + 1];
1435
+ if (current === "--resume") {
1436
+ index += 1;
1437
+ continue;
1438
+ }
1439
+ if (current.startsWith("--resume=")) {
1440
+ continue;
1441
+ }
1442
+ if ((backend === "codex" || backend === "code") && current === "resume") {
1443
+ if (typeof next === "string" && next.length > 0) {
1444
+ index += 1;
1445
+ }
1446
+ continue;
1447
+ }
1448
+ result.push(current);
1449
+ }
1450
+ return result;
1451
+ }
1452
+ buildResumeArgsForBackend(backendName, sessionId) {
1453
+ const normalizedBackend = String(backendName || "").toLowerCase();
1454
+ const normalizedSessionId = String(sessionId || "").trim();
1455
+ if (!normalizedSessionId) {
1456
+ return [];
1457
+ }
1458
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
1459
+ return ["resume", normalizedSessionId];
1460
+ }
1461
+ if (normalizedBackend === "claude-code" || normalizedBackend === "claude") {
1462
+ return ["--resume", normalizedSessionId];
1463
+ }
1464
+ if (normalizedBackend === "copilot") {
1465
+ return [`--resume=${normalizedSessionId}`];
1466
+ }
1467
+ return [];
1468
+ }
580
1469
  captureSnapshot(label) {
581
1470
  const snapshot = this.screen.snapshot();
582
1471
  if (this.onSnapshot) {
@@ -743,11 +1632,16 @@ class TuiDriver extends events_1.EventEmitter {
743
1632
  async write(data) {
744
1633
  this.pty.write(data);
745
1634
  }
1635
+ async forceRestart() {
1636
+ await this.restart();
1637
+ }
746
1638
  kill() {
747
1639
  this.isKilled = true;
748
1640
  this.pty.kill();
749
1641
  this.screen.dispose();
750
1642
  this.isBooted = false;
1643
+ this.sessionInfo = null;
1644
+ this.lastSessionInfo = null;
751
1645
  }
752
1646
  createSessionClosedError() {
753
1647
  const error = new Error("TUI session closed");
@@ -765,13 +1659,35 @@ class TuiDriver extends events_1.EventEmitter {
765
1659
  terminateSessionForLoginRequired() {
766
1660
  this.pty.kill();
767
1661
  this.isBooted = false;
1662
+ this.sessionInfo = null;
1663
+ this.lastSessionInfo = null;
768
1664
  }
769
1665
  resolveTimeout(timeoutMs, defaultTimeoutMs) {
770
- const value = timeoutMs ?? defaultTimeoutMs;
771
- if (!Number.isFinite(value) || value <= 0) {
772
- return Number.POSITIVE_INFINITY;
1666
+ const fallback = this.normalizeTimeoutValue(defaultTimeoutMs, Math.max(MIN_STAGE_TIMEOUT_MS, defaultTimeoutMs));
1667
+ return this.normalizeTimeoutValue(timeoutMs, fallback);
1668
+ }
1669
+ normalizeTimeoutValue(timeoutMs, fallback) {
1670
+ const parsed = Number(timeoutMs);
1671
+ if (!Number.isFinite(parsed)) {
1672
+ return fallback;
773
1673
  }
774
- return value;
1674
+ // `0` means "disable hard timeout" for long-running turns.
1675
+ if (parsed === 0) {
1676
+ return this.resolveMaxStageTimeoutMs();
1677
+ }
1678
+ if (parsed < 0) {
1679
+ return fallback;
1680
+ }
1681
+ const bounded = Math.max(MIN_STAGE_TIMEOUT_MS, Math.round(parsed));
1682
+ return Math.min(bounded, this.resolveMaxStageTimeoutMs());
1683
+ }
1684
+ resolveMaxStageTimeoutMs() {
1685
+ const raw = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
1686
+ const parsed = Number.parseInt(String(raw || ""), 10);
1687
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1688
+ return DEFAULT_STAGE_TIMEOUT_MAX_MS;
1689
+ }
1690
+ return Math.min(Math.max(parsed, MIN_STAGE_TIMEOUT_MS), ABSOLUTE_STAGE_TIMEOUT_MAX_MS);
775
1691
  }
776
1692
  async waitForScrollbackIdle(idleMs, timeoutMs, completionHint) {
777
1693
  const startTime = Date.now();