@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
@@ -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");
@@ -10,26 +15,45 @@ const Matchers_js_1 = require("../expect/Matchers.js");
10
15
  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");
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);
13
25
  class TuiDriver extends events_1.EventEmitter {
14
26
  pty;
15
27
  screen;
16
28
  expect;
17
29
  stateMachine;
18
30
  profile;
31
+ behavior;
19
32
  debug;
20
33
  onSnapshot;
21
34
  onSignals;
22
35
  isBooted = false;
36
+ isKilled = false;
37
+ sessionCwd;
38
+ sessionInfo = null;
39
+ lastSessionInfo = null;
40
+ sessionUsageCache = null;
41
+ initialCommand;
42
+ initialArgs;
23
43
  constructor(options) {
24
44
  super();
25
45
  this.profile = options.profile;
46
+ this.behavior = options.profile.behavior ?? index_js_1.defaultTuiDriverBehavior;
26
47
  this.debug = options.debug ?? false;
27
48
  this.onSnapshot = options.onSnapshot;
28
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] : [];
29
53
  const cols = this.profile.cols ?? 120;
30
54
  const rows = this.profile.rows ?? 40;
31
55
  const scrollback = this.profile.scrollback ?? 5000;
32
- this.pty = new PtySession_js_1.PtySession(this.profile.command, this.profile.args, { cols, rows, env: this.profile.env });
56
+ this.pty = new PtySession_js_1.PtySession(this.initialCommand, this.initialArgs, { cols, rows, env: this.profile.env, cwd: this.sessionCwd });
33
57
  this.screen = new HeadlessScreen_js_1.HeadlessScreen({
34
58
  cols,
35
59
  rows,
@@ -50,6 +74,7 @@ class TuiDriver extends events_1.EventEmitter {
50
74
  this.pty.onExit((code, signal) => {
51
75
  this.log(`PTY exited: code=${code}, signal=${signal}`);
52
76
  this.isBooted = false;
77
+ this.sessionInfo = null;
53
78
  this.emit("exit", code, signal);
54
79
  });
55
80
  this.stateMachine.on("stateChange", (transition) => {
@@ -72,11 +97,59 @@ class TuiDriver extends events_1.EventEmitter {
72
97
  get running() {
73
98
  return this.pty.isRunning;
74
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
+ }
75
144
  async boot() {
145
+ if (this.isKilled) {
146
+ throw this.createSessionClosedError();
147
+ }
76
148
  if (this.isBooted) {
77
149
  return;
78
150
  }
79
151
  this.stateMachine.transition("BOOT");
152
+ this.sessionInfo = null;
80
153
  this.pty.spawn();
81
154
  const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
82
155
  const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
@@ -161,6 +234,7 @@ class TuiDriver extends events_1.EventEmitter {
161
234
  }
162
235
  this.isBooted = true;
163
236
  this.stateMachine.transition("WAIT_READY");
237
+ await this.ensureSessionInfo();
164
238
  this.captureSnapshot("boot_complete");
165
239
  }
166
240
  async ensureReady() {
@@ -170,9 +244,13 @@ class TuiDriver extends events_1.EventEmitter {
170
244
  }
171
245
  const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
172
246
  const readyTimeout = this.resolveTimeout(this.profile.timeouts?.ready, 10000);
247
+ const guardedReadyMatcher = Matchers_js_1.Matchers.custom((snapshot) => {
248
+ this.assertAliveOrThrow();
249
+ return readyMatcher(snapshot);
250
+ });
173
251
  const result = await this.expect.until({
174
252
  name: "ENSURE_READY",
175
- match: readyMatcher,
253
+ match: guardedReadyMatcher,
176
254
  stableMs: 200,
177
255
  timeoutMs: readyTimeout,
178
256
  });
@@ -183,6 +261,7 @@ class TuiDriver extends events_1.EventEmitter {
183
261
  }
184
262
  async ask(prompt) {
185
263
  const startTime = Date.now();
264
+ let sessionInfo = null;
186
265
  try {
187
266
  await this.ensureReady();
188
267
  // 健康检查:在执行前检测异常状态
@@ -199,6 +278,17 @@ class TuiDriver extends events_1.EventEmitter {
199
278
  throw error;
200
279
  }
201
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
+ }
202
292
  else if (health.reason === "login_required") {
203
293
  const error = new Error(`Cannot proceed: ${health.message}`);
204
294
  error.reason = health.reason;
@@ -219,6 +309,7 @@ class TuiDriver extends events_1.EventEmitter {
219
309
  }
220
310
  }
221
311
  }
312
+ sessionInfo = await this.ensureSessionInfo();
222
313
  this.stateMachine.transition("PREPARE_TURN");
223
314
  await this.prepareTurn();
224
315
  this.stateMachine.transition("TYPE_PROMPT");
@@ -229,13 +320,26 @@ class TuiDriver extends events_1.EventEmitter {
229
320
  await this.submit();
230
321
  // 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
231
322
  const beforeSnapshot = this.captureSnapshot("after_submit");
323
+ const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
232
324
  this.stateMachine.transition("WAIT_STREAM_START");
233
- await this.waitStreamStart(preSubmitSnapshot);
325
+ if (sessionCheckpoint) {
326
+ await this.waitForSessionFileGrowth(sessionCheckpoint);
327
+ }
328
+ else {
329
+ await this.waitStreamStart(preSubmitSnapshot);
330
+ }
234
331
  this.stateMachine.transition("WAIT_STREAM_END");
235
- await this.waitStreamEnd();
332
+ if (sessionCheckpoint) {
333
+ await this.waitForSessionFileIdle(sessionCheckpoint);
334
+ }
335
+ else {
336
+ await this.waitStreamEnd(beforeSnapshot);
337
+ }
236
338
  this.stateMachine.transition("CAPTURE");
237
339
  const afterSnapshot = this.captureSnapshot("after_response");
238
- const answer = this.extractAnswer(beforeSnapshot, afterSnapshot);
340
+ const answer = sessionCheckpoint
341
+ ? await this.extractAnswerFromSessionFile(sessionCheckpoint)
342
+ : this.extractAnswer(beforeSnapshot, afterSnapshot);
239
343
  const signals = this.getSignals(afterSnapshot);
240
344
  this.stateMachine.transition("DONE");
241
345
  return {
@@ -251,6 +355,8 @@ class TuiDriver extends events_1.EventEmitter {
251
355
  replyInProgress: signals.replyInProgress,
252
356
  statusLine: signals.statusLine,
253
357
  statusDoneLine: signals.statusDoneLine,
358
+ sessionId: sessionInfo?.sessionId,
359
+ sessionFilePath: sessionInfo?.sessionFilePath,
254
360
  };
255
361
  }
256
362
  catch (error) {
@@ -270,6 +376,8 @@ class TuiDriver extends events_1.EventEmitter {
270
376
  replyInProgress: signals.replyInProgress,
271
377
  statusLine: signals.statusLine,
272
378
  statusDoneLine: signals.statusDoneLine,
379
+ sessionId: sessionInfo?.sessionId,
380
+ sessionFilePath: sessionInfo?.sessionFilePath,
273
381
  };
274
382
  }
275
383
  }
@@ -285,23 +393,748 @@ class TuiDriver extends events_1.EventEmitter {
285
393
  async submit() {
286
394
  await this.pty.sendKeys(this.profile.keys.submit, 50);
287
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
+ }
288
1112
  async waitStreamStart(previousSnapshot) {
289
1113
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
290
- const replyStartMatcher = this.profile.signals?.replyStart?.length
291
- ? Matchers_js_1.Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
292
- : null;
293
1114
  if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
294
1115
  const busyPatterns = this.profile.anchors.busy;
295
1116
  const previousScrollback = previousSnapshot.scrollbackText;
296
1117
  const startMatcher = (snapshot) => {
297
- const { added } = (0, Diff_js_1.computeLineDiff)(previousScrollback, snapshot.scrollbackText);
298
- if (added.some(line => busyPatterns.some(pattern => pattern.test(line)))) {
1118
+ this.assertAliveOrThrow();
1119
+ const added = this.getChangedTailLines(previousScrollback, snapshot.scrollbackText);
1120
+ if (this.anyAddedLineMatches(added, busyPatterns)) {
299
1121
  return true;
300
1122
  }
301
- if (replyStartMatcher && replyStartMatcher(snapshot)) {
1123
+ const hasNewReplyStart = this.hasNewScrollbackPatternSince(previousScrollback, snapshot.scrollbackText, this.profile.signals?.replyStart);
1124
+ if (hasNewReplyStart) {
302
1125
  this.log("waitStreamStart: replyStart detected before busy status");
303
1126
  return true;
304
1127
  }
1128
+ if (this.behavior.matchStreamStartFallback?.({
1129
+ previousSnapshot,
1130
+ snapshot,
1131
+ previousScrollback,
1132
+ addedLines: added,
1133
+ hasNewReplyStart,
1134
+ })) {
1135
+ this.log("waitStreamStart: matched backend fallback condition");
1136
+ return true;
1137
+ }
305
1138
  return false;
306
1139
  };
307
1140
  const result = await this.expect.until({
@@ -321,35 +1154,79 @@ class TuiDriver extends events_1.EventEmitter {
321
1154
  }
322
1155
  }
323
1156
  }
324
- async waitStreamEnd() {
1157
+ async waitStreamEnd(turnStartSnapshot) {
325
1158
  const idleMs = this.profile.timeouts?.idle ?? 800;
326
1159
  const timeout = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
1160
+ const turnStartScrollback = turnStartSnapshot?.scrollbackText ?? "";
1161
+ const turnStartHash = turnStartSnapshot?.hash ?? "";
1162
+ const replyWaitStartAt = Date.now();
327
1163
  const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
328
1164
  const busyMatcher = this.profile.anchors.busy?.length
329
1165
  ? Matchers_js_1.Matchers.anyOf(this.profile.anchors.busy)
330
1166
  : null;
331
- const replyStartMatcher = this.profile.signals?.replyStart?.length
332
- ? Matchers_js_1.Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
333
- : null;
334
- const promptHintMatcher = this.profile.signals?.promptHint?.length
335
- ? Matchers_js_1.Matchers.anyOf(this.profile.signals.promptHint)
1167
+ const promptMatcher = this.profile.signals?.prompt?.length
1168
+ ? Matchers_js_1.Matchers.anyOf(this.profile.signals.prompt)
336
1169
  : null;
1170
+ const hasNewReplyStart = (snapshot) => this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.replyStart);
1171
+ const hasNewPromptHint = (snapshot) => this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.promptHint);
1172
+ const hasPromptHintSignal = Boolean(this.profile.signals?.promptHint?.length);
337
1173
  const statusMatcher = this.profile.signals?.status?.length
338
1174
  ? Matchers_js_1.Matchers.anyOf(this.profile.signals.status)
339
1175
  : null;
340
1176
  const statusDoneMatcher = this.profile.signals?.statusDone?.length
341
1177
  ? Matchers_js_1.Matchers.anyOf(this.profile.signals.statusDone)
342
1178
  : null;
1179
+ let sawBusyDuringWait = false;
1180
+ const hasAnyNewScrollbackLine = (snapshot) => {
1181
+ const added = this.getChangedTailLines(turnStartScrollback, snapshot.scrollbackText);
1182
+ return added.some((line) => line.trim().length > 0);
1183
+ };
343
1184
  // 组合条件:屏幕 idle + ready anchor 出现 + busy anchor 消失
344
- const completeMatcher = busyMatcher
1185
+ const defaultCompleteMatcher = busyMatcher
345
1186
  ? Matchers_js_1.Matchers.and(readyMatcher, Matchers_js_1.Matchers.not(busyMatcher))
346
1187
  : readyMatcher;
347
- if (this.profile.requireReplyStart && replyStartMatcher) {
1188
+ const completeMatcher = this.behavior.buildStreamEndCompleteMatcher
1189
+ ? this.behavior.buildStreamEndCompleteMatcher({
1190
+ readyMatcher,
1191
+ busyMatcher,
1192
+ defaultMatcher: defaultCompleteMatcher,
1193
+ })
1194
+ : defaultCompleteMatcher;
1195
+ if (this.profile.requireReplyStart && this.profile.signals?.replyStart?.length) {
348
1196
  const replyOrHintResult = await this.expect.until({
349
1197
  name: "STREAM_END_REPLY_OR_HINT",
350
- match: promptHintMatcher
351
- ? Matchers_js_1.Matchers.or(replyStartMatcher, promptHintMatcher)
352
- : replyStartMatcher,
1198
+ match: Matchers_js_1.Matchers.custom((snapshot) => {
1199
+ this.assertAliveOrThrow();
1200
+ const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
1201
+ if (busyNow) {
1202
+ sawBusyDuringWait = true;
1203
+ }
1204
+ const fallbackMatched = this.behavior.matchStreamEndReplyFallback
1205
+ ? this.behavior.matchStreamEndReplyFallback({
1206
+ snapshot,
1207
+ turnStartSnapshot,
1208
+ turnStartScrollback,
1209
+ turnStartHash,
1210
+ readyMatcher,
1211
+ busyMatcher,
1212
+ promptMatcher,
1213
+ sawBusyDuringWait,
1214
+ waitStartedAt: replyWaitStartAt,
1215
+ hasAnyNewScrollbackLine,
1216
+ })
1217
+ : false;
1218
+ if (fallbackMatched) {
1219
+ this.log("waitStreamEnd: matched backend reply fallback condition");
1220
+ return true;
1221
+ }
1222
+ if (hasNewReplyStart(snapshot)) {
1223
+ return true;
1224
+ }
1225
+ if (hasPromptHintSignal && hasNewPromptHint(snapshot)) {
1226
+ return true;
1227
+ }
1228
+ return false;
1229
+ }),
353
1230
  stableMs: 200,
354
1231
  timeoutMs: timeout,
355
1232
  });
@@ -358,15 +1235,15 @@ class TuiDriver extends events_1.EventEmitter {
358
1235
  }
359
1236
  // Check if we matched promptHint but not replyStart - this indicates potential empty response
360
1237
  const snapshot = this.screen.snapshot();
361
- const hasReplyStart = replyStartMatcher(snapshot);
362
- if (!hasReplyStart && promptHintMatcher) {
363
- const hasPromptHint = promptHintMatcher(snapshot);
1238
+ const hasReplyStart = hasNewReplyStart(snapshot);
1239
+ if (!hasReplyStart && hasPromptHintSignal) {
1240
+ const hasPromptHint = hasNewPromptHint(snapshot);
364
1241
  if (hasPromptHint) {
365
1242
  this.log(`waitStreamEnd: WARNING - promptHint matched but replyStart did not. Waiting for actual reply...`);
366
1243
  // Wait a bit longer for the actual reply to appear
367
1244
  const retryResult = await this.expect.until({
368
1245
  name: "STREAM_END_REPLY_RETRY",
369
- match: replyStartMatcher,
1246
+ match: Matchers_js_1.Matchers.custom((nextSnapshot) => hasNewReplyStart(nextSnapshot)),
370
1247
  stableMs: 200,
371
1248
  timeoutMs: 10000, // Give it 10 more seconds
372
1249
  });
@@ -375,13 +1252,31 @@ class TuiDriver extends events_1.EventEmitter {
375
1252
  }
376
1253
  }
377
1254
  }
378
- await this.waitForScrollbackIdle(idleMs, timeout);
1255
+ await this.waitForScrollbackIdle(idleMs, timeout, (snapshot) => {
1256
+ const hasPrompt = promptMatcher ? promptMatcher(snapshot) : readyMatcher(snapshot);
1257
+ const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
1258
+ if (!hasPrompt || busyNow) {
1259
+ return false;
1260
+ }
1261
+ const currentSignals = this.getSignals(snapshot);
1262
+ return !currentSignals.replyInProgress;
1263
+ });
379
1264
  if (statusMatcher) {
1265
+ const defaultStatusClearMatcher = statusDoneMatcher
1266
+ ? Matchers_js_1.Matchers.or(Matchers_js_1.Matchers.not(statusMatcher), statusDoneMatcher)
1267
+ : Matchers_js_1.Matchers.not(statusMatcher);
1268
+ const statusClearMatcher = this.behavior.buildStreamEndStatusClearMatcher
1269
+ ? this.behavior.buildStreamEndStatusClearMatcher({
1270
+ defaultMatcher: defaultStatusClearMatcher,
1271
+ getSignals: (snapshot) => this.getSignals(snapshot),
1272
+ })
1273
+ : defaultStatusClearMatcher;
380
1274
  const statusClearResult = await this.expect.until({
381
1275
  name: "STREAM_END_STATUS_CLEAR",
382
- match: statusDoneMatcher
383
- ? Matchers_js_1.Matchers.or(Matchers_js_1.Matchers.not(statusMatcher), statusDoneMatcher)
384
- : Matchers_js_1.Matchers.not(statusMatcher),
1276
+ match: Matchers_js_1.Matchers.custom((snapshot) => {
1277
+ this.assertAliveOrThrow();
1278
+ return statusClearMatcher(snapshot);
1279
+ }),
385
1280
  stableMs: 300,
386
1281
  timeoutMs: timeout,
387
1282
  });
@@ -405,7 +1300,10 @@ class TuiDriver extends events_1.EventEmitter {
405
1300
  if (this.profile.requireReplyStart) {
406
1301
  const readyResult = await this.expect.until({
407
1302
  name: "STREAM_END_READY",
408
- match: completeMatcher,
1303
+ match: Matchers_js_1.Matchers.custom((snapshot) => {
1304
+ this.assertAliveOrThrow();
1305
+ return completeMatcher(snapshot);
1306
+ }),
409
1307
  stableMs: 500,
410
1308
  timeoutMs: 3000,
411
1309
  });
@@ -416,7 +1314,10 @@ class TuiDriver extends events_1.EventEmitter {
416
1314
  else {
417
1315
  const readyResult = await this.expect.until({
418
1316
  name: "STREAM_END_READY",
419
- match: completeMatcher,
1317
+ match: Matchers_js_1.Matchers.custom((snapshot) => {
1318
+ this.assertAliveOrThrow();
1319
+ return completeMatcher(snapshot);
1320
+ }),
420
1321
  stableMs: 500,
421
1322
  timeoutMs: 10000,
422
1323
  });
@@ -483,12 +1384,88 @@ class TuiDriver extends events_1.EventEmitter {
483
1384
  }
484
1385
  async restart() {
485
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
+ }
486
1395
  this.pty.kill();
1396
+ this.pty.setCommandArgs(this.initialCommand, restartArgs);
487
1397
  this.screen.reset();
488
1398
  this.isBooted = false;
489
1399
  await this.sleep(500);
490
1400
  await this.boot();
491
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
+ }
492
1469
  captureSnapshot(label) {
493
1470
  const snapshot = this.screen.snapshot();
494
1471
  if (this.onSnapshot) {
@@ -620,9 +1597,24 @@ class TuiDriver extends events_1.EventEmitter {
620
1597
  this.log(`getSignals: DEBUG scrollback tail (last 30 lines):\n${lastLines.map((l, i) => ` [${lines.length - 30 + i}] "${l}"`).join('\n')}`);
621
1598
  }
622
1599
  const promptLine = this.findLastMatch(lines, signals.prompt);
623
- const statusLine = this.findLastMatch(lines, signals.status);
624
- const statusDoneLine = this.findLastMatch(lines, signals.statusDone);
625
- const reply = this.extractReplyBlocks(lines, signals);
1600
+ const signalScopeLines = this.behavior.getSignalScopeLines
1601
+ ? this.behavior.getSignalScopeLines({
1602
+ lines,
1603
+ signals,
1604
+ helpers: { findLastMatch: this.findLastMatch.bind(this) },
1605
+ })
1606
+ : lines;
1607
+ const defaultStatusLine = this.findLastMatch(signalScopeLines, signals.status);
1608
+ const statusLine = this.behavior.resolveStatusLine
1609
+ ? this.behavior.resolveStatusLine({
1610
+ lines: signalScopeLines,
1611
+ signals,
1612
+ defaultStatusLine,
1613
+ helpers: { findLastMatch: this.findLastMatch.bind(this) },
1614
+ })
1615
+ : defaultStatusLine;
1616
+ const statusDoneLine = this.findLastMatch(signalScopeLines, signals.statusDone);
1617
+ const reply = this.extractReplyBlocks(signalScopeLines, signals);
626
1618
  this.log(`getSignals: promptLine="${promptLine || '(none)'}" statusLine="${statusLine || '(none)'}" reply.text="${reply.text?.slice(0, 100) || '(none)'}" reply.blocks=${reply.blocks.length}`);
627
1619
  return {
628
1620
  hasPrompt: Boolean(promptLine),
@@ -640,10 +1632,26 @@ class TuiDriver extends events_1.EventEmitter {
640
1632
  async write(data) {
641
1633
  this.pty.write(data);
642
1634
  }
1635
+ async forceRestart() {
1636
+ await this.restart();
1637
+ }
643
1638
  kill() {
1639
+ this.isKilled = true;
644
1640
  this.pty.kill();
645
1641
  this.screen.dispose();
646
1642
  this.isBooted = false;
1643
+ this.sessionInfo = null;
1644
+ this.lastSessionInfo = null;
1645
+ }
1646
+ createSessionClosedError() {
1647
+ const error = new Error("TUI session closed");
1648
+ error.reason = "session_closed";
1649
+ return error;
1650
+ }
1651
+ assertAliveOrThrow() {
1652
+ if (this.isKilled || !this.running) {
1653
+ throw this.createSessionClosedError();
1654
+ }
647
1655
  }
648
1656
  sleep(ms) {
649
1657
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -651,24 +1659,61 @@ class TuiDriver extends events_1.EventEmitter {
651
1659
  terminateSessionForLoginRequired() {
652
1660
  this.pty.kill();
653
1661
  this.isBooted = false;
1662
+ this.sessionInfo = null;
1663
+ this.lastSessionInfo = null;
654
1664
  }
655
1665
  resolveTimeout(timeoutMs, defaultTimeoutMs) {
656
- const value = timeoutMs ?? defaultTimeoutMs;
657
- if (!Number.isFinite(value) || value <= 0) {
658
- 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;
659
1673
  }
660
- 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());
661
1683
  }
662
- async waitForScrollbackIdle(idleMs, timeoutMs) {
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);
1691
+ }
1692
+ async waitForScrollbackIdle(idleMs, timeoutMs, completionHint) {
663
1693
  const startTime = Date.now();
664
1694
  let lastHash = ScreenSnapshot_js_1.ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
665
1695
  let lastChangeTime = Date.now();
1696
+ let hintStableSince = null;
1697
+ const boundedTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 60000;
666
1698
  while (true) {
1699
+ this.assertAliveOrThrow();
667
1700
  const elapsed = Date.now() - startTime;
668
- if (elapsed >= timeoutMs) {
1701
+ if (elapsed >= boundedTimeoutMs) {
669
1702
  throw new Error("Stream end timeout: scrollback did not become idle");
670
1703
  }
671
- const currentHash = ScreenSnapshot_js_1.ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
1704
+ const snapshot = this.screen.snapshot();
1705
+ if (completionHint && completionHint(snapshot)) {
1706
+ if (hintStableSince === null) {
1707
+ hintStableSince = Date.now();
1708
+ }
1709
+ else if (Date.now() - hintStableSince >= idleMs) {
1710
+ return;
1711
+ }
1712
+ }
1713
+ else {
1714
+ hintStableSince = null;
1715
+ }
1716
+ const currentHash = ScreenSnapshot_js_1.ScreenSnapshot.computeHash(snapshot.scrollbackText);
672
1717
  if (currentHash !== lastHash) {
673
1718
  lastHash = currentHash;
674
1719
  lastChangeTime = Date.now();
@@ -691,6 +1736,41 @@ class TuiDriver extends events_1.EventEmitter {
691
1736
  }
692
1737
  return null;
693
1738
  }
1739
+ anyAddedLineMatches(lines, patterns) {
1740
+ if (!patterns || patterns.length === 0 || lines.length === 0) {
1741
+ return false;
1742
+ }
1743
+ return lines.some((line) => this.lineMatchesAny(line, patterns));
1744
+ }
1745
+ lineMatchesAny(line, patterns) {
1746
+ for (const pattern of patterns) {
1747
+ pattern.lastIndex = 0;
1748
+ if (pattern.test(line)) {
1749
+ return true;
1750
+ }
1751
+ }
1752
+ return false;
1753
+ }
1754
+ hasNewScrollbackPatternSince(previousScrollback, currentScrollback, patterns) {
1755
+ if (!patterns || patterns.length === 0) {
1756
+ return false;
1757
+ }
1758
+ const added = this.getChangedTailLines(previousScrollback, currentScrollback);
1759
+ return this.anyAddedLineMatches(added, patterns);
1760
+ }
1761
+ getChangedTailLines(previousScrollback, currentScrollback) {
1762
+ const previousLines = previousScrollback.split("\n");
1763
+ const currentLines = currentScrollback.split("\n");
1764
+ const minLength = Math.min(previousLines.length, currentLines.length);
1765
+ let prefixLength = 0;
1766
+ while (prefixLength < minLength && previousLines[prefixLength] === currentLines[prefixLength]) {
1767
+ prefixLength += 1;
1768
+ }
1769
+ if (prefixLength >= currentLines.length) {
1770
+ return [];
1771
+ }
1772
+ return currentLines.slice(prefixLength);
1773
+ }
694
1774
  extractReplyBlocks(lines, signals) {
695
1775
  const startPatterns = signals.replyStart ?? [];
696
1776
  if (startPatterns.length === 0) {