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