@love-moon/tui-driver 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/driver/TuiDriver.d.ts +64 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -1
- package/dist/driver/TuiDriver.js +924 -8
- package/dist/driver/TuiDriver.js.map +1 -1
- package/dist/driver/index.d.ts +2 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -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 +1112 -10
- package/src/driver/index.ts +9 -1
- package/src/index.ts +2 -0
- package/src/pty/PtySession.ts +10 -0
- package/test/codex-session-discovery.test.ts +101 -0
- package/test/session-file-extraction.test.ts +257 -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";
|
|
@@ -33,6 +38,8 @@ export interface AskResult {
|
|
|
33
38
|
replyInProgress?: boolean;
|
|
34
39
|
statusLine?: string;
|
|
35
40
|
statusDoneLine?: string;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
sessionFilePath?: string;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export type HealthReason =
|
|
@@ -60,6 +67,43 @@ export interface TuiScreenSignals {
|
|
|
60
67
|
statusDoneLine?: string;
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
export interface TuiSessionInfo {
|
|
71
|
+
backend: string;
|
|
72
|
+
sessionId: string;
|
|
73
|
+
sessionFilePath: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TuiSessionUsageSummary {
|
|
77
|
+
backend: string;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
sessionFilePath: string;
|
|
80
|
+
tokenUsagePercent?: number;
|
|
81
|
+
contextUsagePercent?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SessionFileCheckpoint {
|
|
85
|
+
sessionInfo: TuiSessionInfo;
|
|
86
|
+
size: number;
|
|
87
|
+
mtimeMs: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SessionUsageCacheEntry {
|
|
91
|
+
backend: string;
|
|
92
|
+
sessionId: string;
|
|
93
|
+
sessionFilePath: string;
|
|
94
|
+
size: number;
|
|
95
|
+
mtimeMs: number;
|
|
96
|
+
summary: TuiSessionUsageSummary;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
|
|
100
|
+
const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
|
|
101
|
+
const MIN_STAGE_TIMEOUT_MS = 100;
|
|
102
|
+
const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
103
|
+
const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
104
|
+
|
|
105
|
+
const execFileAsync = promisify(execFile);
|
|
106
|
+
|
|
63
107
|
export class TuiDriver extends EventEmitter {
|
|
64
108
|
private pty: PtySession;
|
|
65
109
|
private screen: HeadlessScreen;
|
|
@@ -72,6 +116,12 @@ export class TuiDriver extends EventEmitter {
|
|
|
72
116
|
private onSignals?: (signals: TuiScreenSignals, snapshot: ScreenSnapshot, state: TuiState) => void;
|
|
73
117
|
private isBooted = false;
|
|
74
118
|
private isKilled = false;
|
|
119
|
+
private sessionCwd: string;
|
|
120
|
+
private sessionInfo: TuiSessionInfo | null = null;
|
|
121
|
+
private lastSessionInfo: TuiSessionInfo | null = null;
|
|
122
|
+
private sessionUsageCache: SessionUsageCacheEntry | null = null;
|
|
123
|
+
private initialCommand: string;
|
|
124
|
+
private initialArgs: string[];
|
|
75
125
|
|
|
76
126
|
constructor(options: TuiDriverOptions) {
|
|
77
127
|
super();
|
|
@@ -80,15 +130,18 @@ export class TuiDriver extends EventEmitter {
|
|
|
80
130
|
this.debug = options.debug ?? false;
|
|
81
131
|
this.onSnapshot = options.onSnapshot;
|
|
82
132
|
this.onSignals = options.onSignals;
|
|
133
|
+
this.sessionCwd = options.cwd ?? process.cwd();
|
|
134
|
+
this.initialCommand = this.profile.command;
|
|
135
|
+
this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
|
|
83
136
|
|
|
84
137
|
const cols = this.profile.cols ?? 120;
|
|
85
138
|
const rows = this.profile.rows ?? 40;
|
|
86
139
|
const scrollback = this.profile.scrollback ?? 5000;
|
|
87
140
|
|
|
88
141
|
this.pty = new PtySession(
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
91
|
-
{ cols, rows, env: this.profile.env, cwd:
|
|
142
|
+
this.initialCommand,
|
|
143
|
+
this.initialArgs,
|
|
144
|
+
{ cols, rows, env: this.profile.env, cwd: this.sessionCwd }
|
|
92
145
|
);
|
|
93
146
|
|
|
94
147
|
this.screen = new HeadlessScreen({
|
|
@@ -114,6 +167,7 @@ export class TuiDriver extends EventEmitter {
|
|
|
114
167
|
this.pty.onExit((code, signal) => {
|
|
115
168
|
this.log(`PTY exited: code=${code}, signal=${signal}`);
|
|
116
169
|
this.isBooted = false;
|
|
170
|
+
this.sessionInfo = null;
|
|
117
171
|
this.emit("exit", code, signal);
|
|
118
172
|
});
|
|
119
173
|
|
|
@@ -142,6 +196,60 @@ export class TuiDriver extends EventEmitter {
|
|
|
142
196
|
return this.pty.isRunning;
|
|
143
197
|
}
|
|
144
198
|
|
|
199
|
+
getSessionInfo(): TuiSessionInfo | null {
|
|
200
|
+
const current = this.sessionInfo ?? this.lastSessionInfo;
|
|
201
|
+
return current ? { ...current } : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async ensureSessionInfo(timeoutMs = DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS): Promise<TuiSessionInfo | null> {
|
|
205
|
+
if (!this.supportsSessionFileTracking()) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (this.sessionInfo) {
|
|
209
|
+
return { ...this.sessionInfo };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const boundedTimeoutMs = this.resolveTimeout(timeoutMs, DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS);
|
|
213
|
+
const discovered = await this.discoverSessionInfo(boundedTimeoutMs);
|
|
214
|
+
return discovered ? { ...discovered } : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async getSessionUsageSummary(): Promise<TuiSessionUsageSummary | null> {
|
|
218
|
+
if (!this.supportsSessionFileTracking()) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sessionInfo = this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
|
|
223
|
+
if (!sessionInfo) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
228
|
+
if (
|
|
229
|
+
this.sessionUsageCache &&
|
|
230
|
+
this.sessionUsageCache.backend === sessionInfo.backend &&
|
|
231
|
+
this.sessionUsageCache.sessionId === sessionInfo.sessionId &&
|
|
232
|
+
this.sessionUsageCache.sessionFilePath === sessionInfo.sessionFilePath &&
|
|
233
|
+
this.sessionUsageCache.size === size &&
|
|
234
|
+
this.sessionUsageCache.mtimeMs === mtimeMs
|
|
235
|
+
) {
|
|
236
|
+
return { ...this.sessionUsageCache.summary };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const lines = await this.readSessionFileJsonLines(sessionInfo.sessionFilePath, 0);
|
|
240
|
+
const summary = this.extractSessionUsageSummaryFromJsonLines(lines, sessionInfo);
|
|
241
|
+
this.sessionUsageCache = {
|
|
242
|
+
backend: sessionInfo.backend,
|
|
243
|
+
sessionId: sessionInfo.sessionId,
|
|
244
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
245
|
+
size,
|
|
246
|
+
mtimeMs,
|
|
247
|
+
summary,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return { ...summary };
|
|
251
|
+
}
|
|
252
|
+
|
|
145
253
|
async boot(): Promise<void> {
|
|
146
254
|
if (this.isKilled) {
|
|
147
255
|
throw this.createSessionClosedError();
|
|
@@ -152,6 +260,7 @@ export class TuiDriver extends EventEmitter {
|
|
|
152
260
|
}
|
|
153
261
|
|
|
154
262
|
this.stateMachine.transition("BOOT");
|
|
263
|
+
this.sessionInfo = null;
|
|
155
264
|
this.pty.spawn();
|
|
156
265
|
|
|
157
266
|
const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
|
|
@@ -244,6 +353,7 @@ export class TuiDriver extends EventEmitter {
|
|
|
244
353
|
|
|
245
354
|
this.isBooted = true;
|
|
246
355
|
this.stateMachine.transition("WAIT_READY");
|
|
356
|
+
await this.ensureSessionInfo();
|
|
247
357
|
this.captureSnapshot("boot_complete");
|
|
248
358
|
}
|
|
249
359
|
|
|
@@ -275,6 +385,7 @@ export class TuiDriver extends EventEmitter {
|
|
|
275
385
|
|
|
276
386
|
async ask(prompt: string): Promise<AskResult> {
|
|
277
387
|
const startTime = Date.now();
|
|
388
|
+
let sessionInfo: TuiSessionInfo | null = null;
|
|
278
389
|
|
|
279
390
|
try {
|
|
280
391
|
await this.ensureReady();
|
|
@@ -293,6 +404,16 @@ export class TuiDriver extends EventEmitter {
|
|
|
293
404
|
(error as any).matchedPattern = health.matchedPattern;
|
|
294
405
|
throw error;
|
|
295
406
|
}
|
|
407
|
+
} else if (health.reason === "process_exited") {
|
|
408
|
+
this.log("Health check detected exited process, attempting forced restart");
|
|
409
|
+
await this.restart();
|
|
410
|
+
const healthAfterRestart = this.healthCheck();
|
|
411
|
+
if (!healthAfterRestart.healthy) {
|
|
412
|
+
const error = new Error(`Cannot proceed: ${healthAfterRestart.message || healthAfterRestart.reason}`);
|
|
413
|
+
(error as any).reason = healthAfterRestart.reason;
|
|
414
|
+
(error as any).matchedPattern = healthAfterRestart.matchedPattern;
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
296
417
|
} else if (health.reason === "login_required") {
|
|
297
418
|
const error = new Error(`Cannot proceed: ${health.message}`);
|
|
298
419
|
(error as any).reason = health.reason;
|
|
@@ -313,6 +434,8 @@ export class TuiDriver extends EventEmitter {
|
|
|
313
434
|
}
|
|
314
435
|
}
|
|
315
436
|
|
|
437
|
+
sessionInfo = await this.ensureSessionInfo();
|
|
438
|
+
|
|
316
439
|
this.stateMachine.transition("PREPARE_TURN");
|
|
317
440
|
await this.prepareTurn();
|
|
318
441
|
|
|
@@ -327,17 +450,28 @@ export class TuiDriver extends EventEmitter {
|
|
|
327
450
|
|
|
328
451
|
// 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
|
|
329
452
|
const beforeSnapshot = this.captureSnapshot("after_submit");
|
|
453
|
+
const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
|
|
330
454
|
|
|
331
455
|
this.stateMachine.transition("WAIT_STREAM_START");
|
|
332
|
-
|
|
456
|
+
if (sessionCheckpoint) {
|
|
457
|
+
await this.waitForSessionFileGrowth(sessionCheckpoint);
|
|
458
|
+
} else {
|
|
459
|
+
await this.waitStreamStart(preSubmitSnapshot);
|
|
460
|
+
}
|
|
333
461
|
|
|
334
462
|
this.stateMachine.transition("WAIT_STREAM_END");
|
|
335
|
-
|
|
463
|
+
if (sessionCheckpoint) {
|
|
464
|
+
await this.waitForSessionFileIdle(sessionCheckpoint);
|
|
465
|
+
} else {
|
|
466
|
+
await this.waitStreamEnd(beforeSnapshot);
|
|
467
|
+
}
|
|
336
468
|
|
|
337
469
|
this.stateMachine.transition("CAPTURE");
|
|
338
470
|
const afterSnapshot = this.captureSnapshot("after_response");
|
|
339
471
|
|
|
340
|
-
const answer =
|
|
472
|
+
const answer = sessionCheckpoint
|
|
473
|
+
? await this.extractAnswerFromSessionFile(sessionCheckpoint)
|
|
474
|
+
: this.extractAnswer(beforeSnapshot, afterSnapshot);
|
|
341
475
|
const signals = this.getSignals(afterSnapshot);
|
|
342
476
|
|
|
343
477
|
this.stateMachine.transition("DONE");
|
|
@@ -355,6 +489,8 @@ export class TuiDriver extends EventEmitter {
|
|
|
355
489
|
replyInProgress: signals.replyInProgress,
|
|
356
490
|
statusLine: signals.statusLine,
|
|
357
491
|
statusDoneLine: signals.statusDoneLine,
|
|
492
|
+
sessionId: sessionInfo?.sessionId,
|
|
493
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
358
494
|
};
|
|
359
495
|
} catch (error) {
|
|
360
496
|
this.stateMachine.error(error as Error);
|
|
@@ -373,6 +509,8 @@ export class TuiDriver extends EventEmitter {
|
|
|
373
509
|
replyInProgress: signals.replyInProgress,
|
|
374
510
|
statusLine: signals.statusLine,
|
|
375
511
|
statusDoneLine: signals.statusDoneLine,
|
|
512
|
+
sessionId: sessionInfo?.sessionId,
|
|
513
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
376
514
|
};
|
|
377
515
|
}
|
|
378
516
|
}
|
|
@@ -392,6 +530,855 @@ export class TuiDriver extends EventEmitter {
|
|
|
392
530
|
await this.pty.sendKeys(this.profile.keys.submit, 50);
|
|
393
531
|
}
|
|
394
532
|
|
|
533
|
+
private supportsSessionFileTracking(): boolean {
|
|
534
|
+
const backend = String(this.profile.name || "").toLowerCase();
|
|
535
|
+
return backend === "codex" || backend === "claude-code" || backend === "copilot";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async discoverSessionInfo(timeoutMs: number): Promise<TuiSessionInfo | null> {
|
|
539
|
+
const startedAt = Date.now();
|
|
540
|
+
const deadline = startedAt + Math.max(MIN_STAGE_TIMEOUT_MS, timeoutMs);
|
|
541
|
+
|
|
542
|
+
while (Date.now() < deadline) {
|
|
543
|
+
this.assertAliveOrThrow();
|
|
544
|
+
const discovered = await this.detectSessionInfoByBackend();
|
|
545
|
+
if (discovered) {
|
|
546
|
+
const changed =
|
|
547
|
+
!this.sessionInfo ||
|
|
548
|
+
this.sessionInfo.sessionId !== discovered.sessionId ||
|
|
549
|
+
this.sessionInfo.sessionFilePath !== discovered.sessionFilePath;
|
|
550
|
+
this.sessionInfo = discovered;
|
|
551
|
+
this.lastSessionInfo = discovered;
|
|
552
|
+
if (changed) {
|
|
553
|
+
this.emit("session", { ...discovered });
|
|
554
|
+
this.log(
|
|
555
|
+
`session discovered: id=${discovered.sessionId} file=${discovered.sessionFilePath}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return discovered;
|
|
559
|
+
}
|
|
560
|
+
await this.sleep(250);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return this.sessionInfo ? { ...this.sessionInfo } : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async detectSessionInfoByBackend(): Promise<TuiSessionInfo | null> {
|
|
567
|
+
if (!this.supportsSessionFileTracking()) {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
switch (this.profile.name) {
|
|
572
|
+
case "codex":
|
|
573
|
+
return this.detectCodexSessionInfo();
|
|
574
|
+
case "claude-code":
|
|
575
|
+
return this.detectClaudeSessionInfo();
|
|
576
|
+
case "copilot":
|
|
577
|
+
return this.detectCopilotSessionInfo();
|
|
578
|
+
default:
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
} catch (error) {
|
|
582
|
+
this.log(`session detect failed: ${(error as Error)?.message || error}`);
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async detectCodexSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
588
|
+
const dbPath = join(homedir(), ".codex", "state_5.sqlite");
|
|
589
|
+
if (!(await this.pathExists(dbPath))) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const parseRowAsSessionInfo = async (row: string | null): Promise<TuiSessionInfo | null> => {
|
|
594
|
+
if (!row) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const [sessionIdRaw, sessionFilePathRaw] = row.split("|");
|
|
598
|
+
const sessionId = String(sessionIdRaw || "").trim();
|
|
599
|
+
const sessionFilePath = String(sessionFilePathRaw || "").trim();
|
|
600
|
+
if (!sessionId || !sessionFilePath || !(await this.pathExists(sessionFilePath))) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
backend: "codex",
|
|
605
|
+
sessionId,
|
|
606
|
+
sessionFilePath,
|
|
607
|
+
};
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
|
|
611
|
+
if (pinnedSessionId) {
|
|
612
|
+
const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
|
|
613
|
+
const pinnedRow = await this.querySqliteRow(
|
|
614
|
+
dbPath,
|
|
615
|
+
`select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`,
|
|
616
|
+
);
|
|
617
|
+
const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
|
|
618
|
+
if (pinnedSession) {
|
|
619
|
+
return pinnedSession;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const escapedCwd = this.sessionCwd.replace(/'/g, "''");
|
|
624
|
+
const row = await this.querySqliteRow(
|
|
625
|
+
dbPath,
|
|
626
|
+
`select id, rollout_path from threads where source='cli' and model_provider='openai' and cwd='${escapedCwd}' order by updated_at desc limit 1;`,
|
|
627
|
+
);
|
|
628
|
+
return parseRowAsSessionInfo(row);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async detectClaudeSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
632
|
+
const projectDir = join(
|
|
633
|
+
homedir(),
|
|
634
|
+
".claude",
|
|
635
|
+
"projects",
|
|
636
|
+
this.encodeClaudeProjectPath(this.sessionCwd),
|
|
637
|
+
);
|
|
638
|
+
if (!(await this.pathExists(projectDir))) {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const indexPath = join(projectDir, "sessions-index.json");
|
|
643
|
+
if (await this.pathExists(indexPath)) {
|
|
644
|
+
try {
|
|
645
|
+
const raw = await fs.readFile(indexPath, "utf8");
|
|
646
|
+
const parsed = JSON.parse(raw) as {
|
|
647
|
+
entries?: Array<{
|
|
648
|
+
sessionId?: string;
|
|
649
|
+
fullPath?: string;
|
|
650
|
+
fileMtime?: number;
|
|
651
|
+
modified?: string;
|
|
652
|
+
projectPath?: string;
|
|
653
|
+
}>;
|
|
654
|
+
};
|
|
655
|
+
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
656
|
+
const candidates = entries
|
|
657
|
+
.filter((entry) => {
|
|
658
|
+
const entrySessionId = String(entry?.sessionId || "").trim();
|
|
659
|
+
if (!entrySessionId) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
const entryProjectPath = String(entry?.projectPath || "").trim();
|
|
663
|
+
return !entryProjectPath || entryProjectPath === this.sessionCwd;
|
|
664
|
+
})
|
|
665
|
+
.sort((a, b) => {
|
|
666
|
+
const scoreA = Number(a?.fileMtime || Date.parse(String(a?.modified || "")) || 0);
|
|
667
|
+
const scoreB = Number(b?.fileMtime || Date.parse(String(b?.modified || "")) || 0);
|
|
668
|
+
return scoreB - scoreA;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
for (const entry of candidates) {
|
|
672
|
+
const sessionId = String(entry.sessionId || "").trim();
|
|
673
|
+
const sessionFilePath = String(entry.fullPath || "").trim() || join(projectDir, `${sessionId}.jsonl`);
|
|
674
|
+
if (sessionId && sessionFilePath && (await this.pathExists(sessionFilePath))) {
|
|
675
|
+
return {
|
|
676
|
+
backend: "claude-code",
|
|
677
|
+
sessionId,
|
|
678
|
+
sessionFilePath,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} catch (error) {
|
|
683
|
+
this.log(`claude session index parse failed: ${(error as Error)?.message || error}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
const dirents = await fs.readdir(projectDir, { withFileTypes: true });
|
|
689
|
+
const jsonlFiles = dirents
|
|
690
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
691
|
+
.map((entry) => join(projectDir, entry.name));
|
|
692
|
+
const stats = await Promise.all(
|
|
693
|
+
jsonlFiles.map(async (filePath) => ({
|
|
694
|
+
filePath,
|
|
695
|
+
mtimeMs: (await fs.stat(filePath)).mtimeMs,
|
|
696
|
+
})),
|
|
697
|
+
);
|
|
698
|
+
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
699
|
+
const latest = stats[0];
|
|
700
|
+
if (!latest) {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
const sessionId = basename(latest.filePath, ".jsonl");
|
|
704
|
+
return {
|
|
705
|
+
backend: "claude-code",
|
|
706
|
+
sessionId,
|
|
707
|
+
sessionFilePath: latest.filePath,
|
|
708
|
+
};
|
|
709
|
+
} catch {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private async detectCopilotSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
715
|
+
const baseDir = join(homedir(), ".copilot", "session-state");
|
|
716
|
+
if (!(await this.pathExists(baseDir))) {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
const dirents = await fs.readdir(baseDir, { withFileTypes: true });
|
|
722
|
+
const candidates: Array<{ sessionId: string; sessionFilePath: string; mtimeMs: number }> = [];
|
|
723
|
+
|
|
724
|
+
for (const entry of dirents) {
|
|
725
|
+
if (!entry.isDirectory()) {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
const sessionDir = join(baseDir, entry.name);
|
|
729
|
+
const workspacePath = join(sessionDir, "workspace.yaml");
|
|
730
|
+
const eventsPath = join(sessionDir, "events.jsonl");
|
|
731
|
+
if (!(await this.pathExists(eventsPath))) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
let workspaceCwd = "";
|
|
736
|
+
let workspaceId = "";
|
|
737
|
+
if (await this.pathExists(workspacePath)) {
|
|
738
|
+
workspaceCwd = (await this.readWorkspaceYamlValue(workspacePath, "cwd")) || "";
|
|
739
|
+
workspaceId = (await this.readWorkspaceYamlValue(workspacePath, "id")) || "";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (workspaceCwd && workspaceCwd !== this.sessionCwd) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const sessionId = workspaceId || entry.name;
|
|
747
|
+
const mtimeMs = (await fs.stat(eventsPath)).mtimeMs;
|
|
748
|
+
candidates.push({
|
|
749
|
+
sessionId,
|
|
750
|
+
sessionFilePath: eventsPath,
|
|
751
|
+
mtimeMs,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
756
|
+
const latest = candidates[0];
|
|
757
|
+
if (!latest) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
backend: "copilot",
|
|
762
|
+
sessionId: latest.sessionId,
|
|
763
|
+
sessionFilePath: latest.sessionFilePath,
|
|
764
|
+
};
|
|
765
|
+
} catch {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private async querySqliteRow(dbPath: string, query: string): Promise<string | null> {
|
|
771
|
+
try {
|
|
772
|
+
const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
|
|
773
|
+
timeout: 3000,
|
|
774
|
+
maxBuffer: 1024 * 1024,
|
|
775
|
+
});
|
|
776
|
+
const lines = String(stdout || "")
|
|
777
|
+
.split(/\r?\n/)
|
|
778
|
+
.map((line) => line.trim())
|
|
779
|
+
.filter(Boolean);
|
|
780
|
+
return lines[0] ?? null;
|
|
781
|
+
} catch (error) {
|
|
782
|
+
this.log(`sqlite query failed: ${(error as Error)?.message || error}`);
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private encodeClaudeProjectPath(cwd: string): string {
|
|
788
|
+
return String(cwd || "").replace(/\//g, "-");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private async readWorkspaceYamlValue(filePath: string, key: string): Promise<string | null> {
|
|
792
|
+
try {
|
|
793
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
794
|
+
const matcher = new RegExp(`^${key}:\\s*(.+)\\s*$`, "m");
|
|
795
|
+
const match = raw.match(matcher);
|
|
796
|
+
if (!match) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
const value = match[1].trim();
|
|
800
|
+
if (!value) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
804
|
+
} catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private async pathExists(filePath: string): Promise<boolean> {
|
|
810
|
+
try {
|
|
811
|
+
await fs.access(filePath);
|
|
812
|
+
return true;
|
|
813
|
+
} catch {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private async captureSessionFileCheckpoint(
|
|
819
|
+
sessionInfo: TuiSessionInfo | null,
|
|
820
|
+
): Promise<SessionFileCheckpoint | null> {
|
|
821
|
+
if (!sessionInfo) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
825
|
+
return {
|
|
826
|
+
sessionInfo,
|
|
827
|
+
size,
|
|
828
|
+
mtimeMs,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private async readSessionFileStat(
|
|
833
|
+
sessionFilePath: string,
|
|
834
|
+
): Promise<{ size: number; mtimeMs: number }> {
|
|
835
|
+
try {
|
|
836
|
+
const stats = await fs.stat(sessionFilePath);
|
|
837
|
+
return {
|
|
838
|
+
size: stats.size,
|
|
839
|
+
mtimeMs: stats.mtimeMs,
|
|
840
|
+
};
|
|
841
|
+
} catch {
|
|
842
|
+
return {
|
|
843
|
+
size: 0,
|
|
844
|
+
mtimeMs: 0,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private async waitForSessionFileGrowth(checkpoint: SessionFileCheckpoint): Promise<void> {
|
|
850
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
851
|
+
const startedAt = Date.now();
|
|
852
|
+
let lastSize = checkpoint.size;
|
|
853
|
+
let lastMtimeMs = checkpoint.mtimeMs;
|
|
854
|
+
|
|
855
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
856
|
+
this.assertAliveOrThrow();
|
|
857
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
858
|
+
const changed = current.size !== lastSize || current.mtimeMs !== lastMtimeMs;
|
|
859
|
+
if (changed) {
|
|
860
|
+
this.log(
|
|
861
|
+
`session file growth detected: ${checkpoint.sessionInfo.sessionFilePath} (${lastSize} -> ${current.size})`,
|
|
862
|
+
);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
throw new Error("Stream start timeout: session file did not grow");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private async waitForSessionFileIdle(checkpoint: SessionFileCheckpoint): Promise<void> {
|
|
872
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
|
|
873
|
+
const startedAt = Date.now();
|
|
874
|
+
let previousSize = checkpoint.size;
|
|
875
|
+
let previousMtimeMs = checkpoint.mtimeMs;
|
|
876
|
+
let observedProgress = false;
|
|
877
|
+
let unchangedChecks = 0;
|
|
878
|
+
const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
|
|
879
|
+
let completionMarkerSeen = false;
|
|
880
|
+
|
|
881
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
882
|
+
this.assertAliveOrThrow();
|
|
883
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
884
|
+
const changed = current.size !== previousSize || current.mtimeMs !== previousMtimeMs;
|
|
885
|
+
if (changed) {
|
|
886
|
+
this.log(
|
|
887
|
+
`session file changed: backend=${checkpoint.sessionInfo.backend} size=${previousSize}->${current.size} mtime=${previousMtimeMs}->${current.mtimeMs}`,
|
|
888
|
+
);
|
|
889
|
+
observedProgress = true;
|
|
890
|
+
unchangedChecks = 0;
|
|
891
|
+
previousSize = current.size;
|
|
892
|
+
previousMtimeMs = current.mtimeMs;
|
|
893
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
894
|
+
completionMarkerSeen = await this.hasSessionCompletionMarker(checkpoint, current.size);
|
|
895
|
+
if (completionMarkerSeen) {
|
|
896
|
+
this.log(
|
|
897
|
+
`session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
unchangedChecks += 1;
|
|
903
|
+
this.log(
|
|
904
|
+
`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
|
|
905
|
+
);
|
|
906
|
+
if (observedProgress && unchangedChecks >= 2) {
|
|
907
|
+
if (!requireCompletionMarker || completionMarkerSeen) {
|
|
908
|
+
this.log(
|
|
909
|
+
`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`,
|
|
910
|
+
);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (!observedProgress) {
|
|
919
|
+
throw new Error("Stream end timeout: session file did not grow");
|
|
920
|
+
}
|
|
921
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
922
|
+
throw new Error("Stream end timeout: session completion marker not observed");
|
|
923
|
+
}
|
|
924
|
+
throw new Error("Stream end timeout: session file did not become stable");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private async extractAnswerFromSessionFile(checkpoint: SessionFileCheckpoint): Promise<string> {
|
|
928
|
+
const lines = await this.readSessionFileJsonLines(
|
|
929
|
+
checkpoint.sessionInfo.sessionFilePath,
|
|
930
|
+
checkpoint.size,
|
|
931
|
+
);
|
|
932
|
+
const codexTaskCompleteMessage = this.extractCodexTaskCompleteMessageFromJsonLines(lines);
|
|
933
|
+
if (codexTaskCompleteMessage) {
|
|
934
|
+
this.log(
|
|
935
|
+
`session answer source=codex.task_complete preview="${this.summarizeForLog(codexTaskCompleteMessage, 160)}"`,
|
|
936
|
+
);
|
|
937
|
+
return codexTaskCompleteMessage;
|
|
938
|
+
}
|
|
939
|
+
const answer = this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend);
|
|
940
|
+
if (answer) {
|
|
941
|
+
this.log(
|
|
942
|
+
`session answer source=${checkpoint.sessionInfo.backend}.assistant preview="${this.summarizeForLog(answer, 160)}"`,
|
|
943
|
+
);
|
|
944
|
+
return answer;
|
|
945
|
+
}
|
|
946
|
+
throw new Error("No assistant reply found in session file");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private extractSessionUsageSummaryFromJsonLines(
|
|
950
|
+
lines: string[],
|
|
951
|
+
sessionInfo: TuiSessionInfo,
|
|
952
|
+
): TuiSessionUsageSummary {
|
|
953
|
+
const backend = sessionInfo.backend;
|
|
954
|
+
const baseSummary: TuiSessionUsageSummary = {
|
|
955
|
+
backend,
|
|
956
|
+
sessionId: sessionInfo.sessionId,
|
|
957
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const usage =
|
|
961
|
+
backend === "codex"
|
|
962
|
+
? this.extractCodexUsageFromJsonLines(lines)
|
|
963
|
+
: backend === "claude-code"
|
|
964
|
+
? this.extractClaudeUsageFromJsonLines(lines)
|
|
965
|
+
: backend === "copilot"
|
|
966
|
+
? this.extractCopilotUsageFromJsonLines(lines)
|
|
967
|
+
: {};
|
|
968
|
+
|
|
969
|
+
return {
|
|
970
|
+
...baseSummary,
|
|
971
|
+
...usage,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private extractCodexUsageFromJsonLines(
|
|
976
|
+
lines: string[],
|
|
977
|
+
): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
|
|
978
|
+
let tokenUsagePercent: number | undefined;
|
|
979
|
+
let contextUsagePercent: number | undefined;
|
|
980
|
+
|
|
981
|
+
for (const line of lines) {
|
|
982
|
+
let entry: Record<string, unknown> | null = null;
|
|
983
|
+
try {
|
|
984
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
985
|
+
} catch {
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (!entry || entry.type !== "event_msg") {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
992
|
+
if (!payload || typeof payload !== "object") {
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const secondaryUsedPercent = this.readNumberPath(payload, ["rate_limits", "secondary", "used_percent"]);
|
|
997
|
+
if (secondaryUsedPercent !== undefined) {
|
|
998
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const inputTokens = this.readNumberPath(payload, ["info", "last_token_usage", "input_tokens"]);
|
|
1002
|
+
const contextWindow = this.readNumberPath(payload, ["info", "model_context_window"]);
|
|
1003
|
+
if (inputTokens !== undefined && contextWindow !== undefined && contextWindow > 0) {
|
|
1004
|
+
contextUsagePercent = this.normalizePercent((inputTokens / contextWindow) * 100);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return {
|
|
1009
|
+
tokenUsagePercent,
|
|
1010
|
+
contextUsagePercent,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private extractClaudeUsageFromJsonLines(
|
|
1015
|
+
lines: string[],
|
|
1016
|
+
): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
|
|
1017
|
+
let tokenUsagePercent: number | undefined;
|
|
1018
|
+
let contextUsagePercent: number | undefined;
|
|
1019
|
+
let latestInputTokens: number | undefined;
|
|
1020
|
+
let latestContextWindow: number | undefined;
|
|
1021
|
+
|
|
1022
|
+
for (const line of lines) {
|
|
1023
|
+
let entry: Record<string, unknown> | null = null;
|
|
1024
|
+
try {
|
|
1025
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1026
|
+
} catch {
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (!entry || typeof entry !== "object") {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const secondaryUsedPercent =
|
|
1034
|
+
this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
1035
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
1036
|
+
if (secondaryUsedPercent !== undefined) {
|
|
1037
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const inputTokens =
|
|
1041
|
+
this.readNumberPath(entry, ["message", "usage", "input_tokens"]) ??
|
|
1042
|
+
this.readNumberPath(entry, ["message", "usage", "inputTokens"]) ??
|
|
1043
|
+
this.readNumberPath(entry, ["usage", "input_tokens"]) ??
|
|
1044
|
+
this.readNumberPath(entry, ["usage", "inputTokens"]);
|
|
1045
|
+
if (inputTokens !== undefined) {
|
|
1046
|
+
latestInputTokens = inputTokens;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const contextWindow =
|
|
1050
|
+
this.readNumberPath(entry, ["message", "model_context_window"]) ??
|
|
1051
|
+
this.readNumberPath(entry, ["message", "modelContextWindow"]) ??
|
|
1052
|
+
this.readNumberPath(entry, ["message", "context_window"]) ??
|
|
1053
|
+
this.readNumberPath(entry, ["message", "contextWindow"]) ??
|
|
1054
|
+
this.readNumberPath(entry, ["model_context_window"]) ??
|
|
1055
|
+
this.readNumberPath(entry, ["modelContextWindow"]) ??
|
|
1056
|
+
this.readNumberPath(entry, ["context_window"]) ??
|
|
1057
|
+
this.readNumberPath(entry, ["contextWindow"]);
|
|
1058
|
+
if (contextWindow !== undefined && contextWindow > 0) {
|
|
1059
|
+
latestContextWindow = contextWindow;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (
|
|
1064
|
+
latestInputTokens !== undefined &&
|
|
1065
|
+
latestContextWindow !== undefined &&
|
|
1066
|
+
latestContextWindow > 0
|
|
1067
|
+
) {
|
|
1068
|
+
contextUsagePercent = this.normalizePercent((latestInputTokens / latestContextWindow) * 100);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
tokenUsagePercent,
|
|
1073
|
+
contextUsagePercent,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private extractCopilotUsageFromJsonLines(
|
|
1078
|
+
lines: string[],
|
|
1079
|
+
): Pick<TuiSessionUsageSummary, "tokenUsagePercent" | "contextUsagePercent"> {
|
|
1080
|
+
let tokenUsagePercent: number | undefined;
|
|
1081
|
+
let contextUsagePercent: number | undefined;
|
|
1082
|
+
let latestContextTokens: number | undefined;
|
|
1083
|
+
let latestContextLimit: number | undefined;
|
|
1084
|
+
|
|
1085
|
+
for (const line of lines) {
|
|
1086
|
+
let entry: Record<string, unknown> | null = null;
|
|
1087
|
+
try {
|
|
1088
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1089
|
+
} catch {
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
if (!entry || typeof entry !== "object") {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const secondaryUsedPercent =
|
|
1097
|
+
this.readNumberPath(entry, ["data", "rate_limits", "secondary", "used_percent"]) ??
|
|
1098
|
+
this.readNumberPath(entry, ["data", "rateLimits", "secondary", "usedPercent"]) ??
|
|
1099
|
+
this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
1100
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
1101
|
+
if (secondaryUsedPercent !== undefined) {
|
|
1102
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const responseTokenLimit =
|
|
1106
|
+
this.readNumberPath(entry, ["data", "toolTelemetry", "metrics", "responseTokenLimit"]) ??
|
|
1107
|
+
this.readNumberPath(entry, ["data", "responseTokenLimit"]) ??
|
|
1108
|
+
this.readNumberPath(entry, ["data", "modelContextWindow"]) ??
|
|
1109
|
+
this.readNumberPath(entry, ["data", "model_context_window"]) ??
|
|
1110
|
+
this.readNumberPath(entry, ["data", "contextWindow"]) ??
|
|
1111
|
+
this.readNumberPath(entry, ["data", "context_window"]) ??
|
|
1112
|
+
this.readNumberPath(entry, ["responseTokenLimit"]);
|
|
1113
|
+
if (responseTokenLimit !== undefined && responseTokenLimit > 0) {
|
|
1114
|
+
latestContextLimit = responseTokenLimit;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const contextTokens =
|
|
1118
|
+
this.readNumberPath(entry, ["data", "preCompactionTokens"]) ??
|
|
1119
|
+
this.readNumberPath(entry, ["data", "compactionTokensUsed", "input"]) ??
|
|
1120
|
+
this.readNumberPath(entry, ["data", "postCompactionTokens"]) ??
|
|
1121
|
+
this.readNumberPath(entry, ["data", "tokenUsage", "input_tokens"]) ??
|
|
1122
|
+
this.readNumberPath(entry, ["data", "usage", "input_tokens"]) ??
|
|
1123
|
+
this.readNumberPath(entry, ["data", "inputTokens"]);
|
|
1124
|
+
if (contextTokens !== undefined && contextTokens >= 0) {
|
|
1125
|
+
latestContextTokens = contextTokens;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (
|
|
1130
|
+
latestContextTokens !== undefined &&
|
|
1131
|
+
latestContextLimit !== undefined &&
|
|
1132
|
+
latestContextLimit > 0
|
|
1133
|
+
) {
|
|
1134
|
+
contextUsagePercent = this.normalizePercent((latestContextTokens / latestContextLimit) * 100);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return {
|
|
1138
|
+
tokenUsagePercent,
|
|
1139
|
+
contextUsagePercent,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private readNumberPath(source: unknown, path: string[]): number | undefined {
|
|
1144
|
+
let cursor: unknown = source;
|
|
1145
|
+
for (const key of path) {
|
|
1146
|
+
if (!cursor || typeof cursor !== "object") {
|
|
1147
|
+
return undefined;
|
|
1148
|
+
}
|
|
1149
|
+
cursor = (cursor as Record<string, unknown>)[key];
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof cursor === "number" && Number.isFinite(cursor)) {
|
|
1152
|
+
return cursor;
|
|
1153
|
+
}
|
|
1154
|
+
if (typeof cursor === "string") {
|
|
1155
|
+
const parsed = Number(cursor);
|
|
1156
|
+
if (Number.isFinite(parsed)) {
|
|
1157
|
+
return parsed;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private normalizePercent(value: number): number {
|
|
1164
|
+
if (!Number.isFinite(value)) {
|
|
1165
|
+
return 0;
|
|
1166
|
+
}
|
|
1167
|
+
if (value < 0) {
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
if (value > 100) {
|
|
1171
|
+
return 100;
|
|
1172
|
+
}
|
|
1173
|
+
return value;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private requiresSessionCompletionMarker(backend: string): boolean {
|
|
1177
|
+
return backend === "codex" || backend === "copilot";
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private async hasSessionCompletionMarker(
|
|
1181
|
+
checkpoint: SessionFileCheckpoint,
|
|
1182
|
+
endOffset?: number,
|
|
1183
|
+
): Promise<boolean> {
|
|
1184
|
+
const lines = await this.readSessionFileJsonLines(
|
|
1185
|
+
checkpoint.sessionInfo.sessionFilePath,
|
|
1186
|
+
checkpoint.size,
|
|
1187
|
+
endOffset,
|
|
1188
|
+
);
|
|
1189
|
+
if (checkpoint.sessionInfo.backend === "codex") {
|
|
1190
|
+
return this.hasCodexTaskCompleteFromJsonLines(lines);
|
|
1191
|
+
}
|
|
1192
|
+
if (checkpoint.sessionInfo.backend === "copilot") {
|
|
1193
|
+
return this.hasCopilotTurnEndFromJsonLines(lines);
|
|
1194
|
+
}
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private async readSessionFileJsonLines(
|
|
1199
|
+
sessionFilePath: string,
|
|
1200
|
+
startOffset = 0,
|
|
1201
|
+
endOffset?: number,
|
|
1202
|
+
): Promise<string[]> {
|
|
1203
|
+
let fullBuffer: Buffer;
|
|
1204
|
+
try {
|
|
1205
|
+
fullBuffer = await fs.readFile(sessionFilePath);
|
|
1206
|
+
} catch {
|
|
1207
|
+
return [];
|
|
1208
|
+
}
|
|
1209
|
+
const boundedStartOffset = Math.max(0, Math.min(startOffset, fullBuffer.length));
|
|
1210
|
+
const boundedEndOffset = Number.isFinite(endOffset)
|
|
1211
|
+
? Math.max(boundedStartOffset, Math.min(Number(endOffset), fullBuffer.length))
|
|
1212
|
+
: fullBuffer.length;
|
|
1213
|
+
return fullBuffer
|
|
1214
|
+
.subarray(boundedStartOffset, boundedEndOffset)
|
|
1215
|
+
.toString("utf8")
|
|
1216
|
+
.split(/\r?\n/)
|
|
1217
|
+
.map((line) => line.trim())
|
|
1218
|
+
.filter(Boolean);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
private extractAssistantReplyFromJsonLines(lines: string[], backend: string): string {
|
|
1222
|
+
const replies: string[] = [];
|
|
1223
|
+
|
|
1224
|
+
for (const line of lines) {
|
|
1225
|
+
let entry: Record<string, unknown> | null = null;
|
|
1226
|
+
try {
|
|
1227
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1228
|
+
} catch {
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (!entry || typeof entry !== "object") {
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const text =
|
|
1236
|
+
backend === "codex"
|
|
1237
|
+
? this.extractCodexAssistantText(entry)
|
|
1238
|
+
: backend === "claude-code"
|
|
1239
|
+
? this.extractClaudeAssistantText(entry)
|
|
1240
|
+
: backend === "copilot"
|
|
1241
|
+
? this.extractCopilotAssistantText(entry)
|
|
1242
|
+
: "";
|
|
1243
|
+
if (text) {
|
|
1244
|
+
replies.push(text);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
return replies.length > 0 ? replies[replies.length - 1] : "";
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private hasCodexTaskCompleteFromJsonLines(lines: string[]): boolean {
|
|
1252
|
+
for (const line of lines) {
|
|
1253
|
+
let entry: Record<string, unknown> | null = null;
|
|
1254
|
+
try {
|
|
1255
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1256
|
+
} catch {
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (entry && this.isCodexTaskCompleteEntry(entry)) {
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private hasCopilotTurnEndFromJsonLines(lines: string[]): boolean {
|
|
1267
|
+
for (const line of lines) {
|
|
1268
|
+
let entry: Record<string, unknown> | null = null;
|
|
1269
|
+
try {
|
|
1270
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1271
|
+
} catch {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
if (entry && this.isCopilotTurnEndEntry(entry)) {
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
private extractCodexTaskCompleteMessageFromJsonLines(lines: string[]): string {
|
|
1282
|
+
let latestMessage = "";
|
|
1283
|
+
for (const line of lines) {
|
|
1284
|
+
let entry: Record<string, unknown> | null = null;
|
|
1285
|
+
try {
|
|
1286
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
1287
|
+
} catch {
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (!entry || !this.isCodexTaskCompleteEntry(entry)) {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
1294
|
+
const message = typeof payload?.last_agent_message === "string" ? payload.last_agent_message.trim() : "";
|
|
1295
|
+
if (message) {
|
|
1296
|
+
latestMessage = message;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return latestMessage;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
private isCodexTaskCompleteEntry(entry: Record<string, unknown>): boolean {
|
|
1303
|
+
if (entry.type !== "event_msg") {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
1307
|
+
return payload?.type === "task_complete";
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private isCopilotTurnEndEntry(entry: Record<string, unknown>): boolean {
|
|
1311
|
+
return entry.type === "assistant.turn_end";
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
private summarizeForLog(value: string, maxLen = 160): string {
|
|
1315
|
+
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
1316
|
+
if (!normalized) {
|
|
1317
|
+
return "";
|
|
1318
|
+
}
|
|
1319
|
+
if (normalized.length <= maxLen) {
|
|
1320
|
+
return normalized;
|
|
1321
|
+
}
|
|
1322
|
+
return `${normalized.slice(0, maxLen)}...`;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
private extractCodexAssistantText(entry: Record<string, unknown>): string {
|
|
1326
|
+
if (entry.type !== "response_item") {
|
|
1327
|
+
return "";
|
|
1328
|
+
}
|
|
1329
|
+
const payload = entry.payload as Record<string, unknown> | undefined;
|
|
1330
|
+
if (!payload || payload.type !== "message" || payload.role !== "assistant") {
|
|
1331
|
+
return "";
|
|
1332
|
+
}
|
|
1333
|
+
const content = payload.content as Array<Record<string, unknown>> | undefined;
|
|
1334
|
+
if (!Array.isArray(content)) {
|
|
1335
|
+
return "";
|
|
1336
|
+
}
|
|
1337
|
+
const text = content
|
|
1338
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
1339
|
+
.filter(Boolean)
|
|
1340
|
+
.join("\n")
|
|
1341
|
+
.trim();
|
|
1342
|
+
return text;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
private extractClaudeAssistantText(entry: Record<string, unknown>): string {
|
|
1346
|
+
if (entry.type !== "assistant") {
|
|
1347
|
+
return "";
|
|
1348
|
+
}
|
|
1349
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
1350
|
+
if (!message || message.role !== "assistant") {
|
|
1351
|
+
return "";
|
|
1352
|
+
}
|
|
1353
|
+
const content = message.content;
|
|
1354
|
+
if (typeof content === "string") {
|
|
1355
|
+
return content.trim();
|
|
1356
|
+
}
|
|
1357
|
+
if (!Array.isArray(content)) {
|
|
1358
|
+
return "";
|
|
1359
|
+
}
|
|
1360
|
+
const text = content
|
|
1361
|
+
.map((block) => {
|
|
1362
|
+
const typed = block as Record<string, unknown>;
|
|
1363
|
+
return typed?.type === "text" && typeof typed?.text === "string" ? typed.text : "";
|
|
1364
|
+
})
|
|
1365
|
+
.filter(Boolean)
|
|
1366
|
+
.join("\n")
|
|
1367
|
+
.trim();
|
|
1368
|
+
return text;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
private extractCopilotAssistantText(entry: Record<string, unknown>): string {
|
|
1372
|
+
if (entry.type !== "assistant.message") {
|
|
1373
|
+
return "";
|
|
1374
|
+
}
|
|
1375
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
1376
|
+
if (!data || typeof data.content !== "string") {
|
|
1377
|
+
return "";
|
|
1378
|
+
}
|
|
1379
|
+
return data.content.trim();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
395
1382
|
private async waitStreamStart(previousSnapshot: ScreenSnapshot): Promise<void> {
|
|
396
1383
|
const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
397
1384
|
|
|
@@ -690,13 +1677,98 @@ export class TuiDriver extends EventEmitter {
|
|
|
690
1677
|
|
|
691
1678
|
private async restart(): Promise<void> {
|
|
692
1679
|
this.log("Restarting PTY...");
|
|
1680
|
+
const restartSession = await this.resolveRestartSessionInfo();
|
|
1681
|
+
const restartArgs = this.resolveRestartArgs(restartSession?.sessionId);
|
|
1682
|
+
if (restartSession?.sessionId) {
|
|
1683
|
+
this.log(
|
|
1684
|
+
`restart resume target: backend=${this.profile.name} session=${restartSession.sessionId} args=${JSON.stringify(restartArgs)}`,
|
|
1685
|
+
);
|
|
1686
|
+
} else {
|
|
1687
|
+
this.log(`restart without resume: backend=${this.profile.name} args=${JSON.stringify(restartArgs)}`);
|
|
1688
|
+
}
|
|
693
1689
|
this.pty.kill();
|
|
1690
|
+
this.pty.setCommandArgs(this.initialCommand, restartArgs);
|
|
694
1691
|
this.screen.reset();
|
|
695
1692
|
this.isBooted = false;
|
|
696
1693
|
await this.sleep(500);
|
|
697
1694
|
await this.boot();
|
|
698
1695
|
}
|
|
699
1696
|
|
|
1697
|
+
private async resolveRestartSessionInfo(): Promise<TuiSessionInfo | null> {
|
|
1698
|
+
const cached = this.sessionInfo ?? this.lastSessionInfo;
|
|
1699
|
+
if (cached?.sessionId) {
|
|
1700
|
+
return { ...cached };
|
|
1701
|
+
}
|
|
1702
|
+
if (!this.supportsSessionFileTracking()) {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
const detected = await this.detectSessionInfoByBackend();
|
|
1706
|
+
if (!detected?.sessionId) {
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
this.lastSessionInfo = detected;
|
|
1710
|
+
return { ...detected };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
private resolveRestartArgs(sessionId?: string): string[] {
|
|
1714
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1715
|
+
if (!normalizedSessionId) {
|
|
1716
|
+
return [...this.initialArgs];
|
|
1717
|
+
}
|
|
1718
|
+
const baseArgs = this.stripResumeArgs(this.initialArgs, this.profile.name);
|
|
1719
|
+
const resumeArgs = this.buildResumeArgsForBackend(this.profile.name, normalizedSessionId);
|
|
1720
|
+
if (resumeArgs.length === 0) {
|
|
1721
|
+
return [...this.initialArgs];
|
|
1722
|
+
}
|
|
1723
|
+
return [...baseArgs, ...resumeArgs];
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private stripResumeArgs(args: string[], backendName: string): string[] {
|
|
1727
|
+
const result: string[] = [];
|
|
1728
|
+
const backend = String(backendName || "").toLowerCase();
|
|
1729
|
+
|
|
1730
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1731
|
+
const current = String(args[index] || "");
|
|
1732
|
+
const next = args[index + 1];
|
|
1733
|
+
|
|
1734
|
+
if (current === "--resume") {
|
|
1735
|
+
index += 1;
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (current.startsWith("--resume=")) {
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
if ((backend === "codex" || backend === "code") && current === "resume") {
|
|
1742
|
+
if (typeof next === "string" && next.length > 0) {
|
|
1743
|
+
index += 1;
|
|
1744
|
+
}
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
result.push(current);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
return result;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
private buildResumeArgsForBackend(backendName: string, sessionId: string): string[] {
|
|
1755
|
+
const normalizedBackend = String(backendName || "").toLowerCase();
|
|
1756
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1757
|
+
if (!normalizedSessionId) {
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
1760
|
+
if (normalizedBackend === "codex" || normalizedBackend === "code") {
|
|
1761
|
+
return ["resume", normalizedSessionId];
|
|
1762
|
+
}
|
|
1763
|
+
if (normalizedBackend === "claude-code" || normalizedBackend === "claude") {
|
|
1764
|
+
return ["--resume", normalizedSessionId];
|
|
1765
|
+
}
|
|
1766
|
+
if (normalizedBackend === "copilot") {
|
|
1767
|
+
return [`--resume=${normalizedSessionId}`];
|
|
1768
|
+
}
|
|
1769
|
+
return [];
|
|
1770
|
+
}
|
|
1771
|
+
|
|
700
1772
|
private captureSnapshot(label: string): ScreenSnapshot {
|
|
701
1773
|
const snapshot = this.screen.snapshot();
|
|
702
1774
|
if (this.onSnapshot) {
|
|
@@ -886,11 +1958,17 @@ export class TuiDriver extends EventEmitter {
|
|
|
886
1958
|
this.pty.write(data);
|
|
887
1959
|
}
|
|
888
1960
|
|
|
1961
|
+
async forceRestart(): Promise<void> {
|
|
1962
|
+
await this.restart();
|
|
1963
|
+
}
|
|
1964
|
+
|
|
889
1965
|
kill(): void {
|
|
890
1966
|
this.isKilled = true;
|
|
891
1967
|
this.pty.kill();
|
|
892
1968
|
this.screen.dispose();
|
|
893
1969
|
this.isBooted = false;
|
|
1970
|
+
this.sessionInfo = null;
|
|
1971
|
+
this.lastSessionInfo = null;
|
|
894
1972
|
}
|
|
895
1973
|
|
|
896
1974
|
private createSessionClosedError(): Error {
|
|
@@ -912,14 +1990,38 @@ export class TuiDriver extends EventEmitter {
|
|
|
912
1990
|
private terminateSessionForLoginRequired(): void {
|
|
913
1991
|
this.pty.kill();
|
|
914
1992
|
this.isBooted = false;
|
|
1993
|
+
this.sessionInfo = null;
|
|
1994
|
+
this.lastSessionInfo = null;
|
|
915
1995
|
}
|
|
916
1996
|
|
|
917
1997
|
private resolveTimeout(timeoutMs: number | undefined, defaultTimeoutMs: number): number {
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
1998
|
+
const fallback = this.normalizeTimeoutValue(defaultTimeoutMs, Math.max(MIN_STAGE_TIMEOUT_MS, defaultTimeoutMs));
|
|
1999
|
+
return this.normalizeTimeoutValue(timeoutMs, fallback);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
private normalizeTimeoutValue(timeoutMs: number | undefined, fallback: number): number {
|
|
2003
|
+
const parsed = Number(timeoutMs);
|
|
2004
|
+
if (!Number.isFinite(parsed)) {
|
|
2005
|
+
return fallback;
|
|
921
2006
|
}
|
|
922
|
-
|
|
2007
|
+
// `0` means "disable hard timeout" for long-running turns.
|
|
2008
|
+
if (parsed === 0) {
|
|
2009
|
+
return this.resolveMaxStageTimeoutMs();
|
|
2010
|
+
}
|
|
2011
|
+
if (parsed < 0) {
|
|
2012
|
+
return fallback;
|
|
2013
|
+
}
|
|
2014
|
+
const bounded = Math.max(MIN_STAGE_TIMEOUT_MS, Math.round(parsed));
|
|
2015
|
+
return Math.min(bounded, this.resolveMaxStageTimeoutMs());
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
private resolveMaxStageTimeoutMs(): number {
|
|
2019
|
+
const raw = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
|
|
2020
|
+
const parsed = Number.parseInt(String(raw || ""), 10);
|
|
2021
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
2022
|
+
return DEFAULT_STAGE_TIMEOUT_MAX_MS;
|
|
2023
|
+
}
|
|
2024
|
+
return Math.min(Math.max(parsed, MIN_STAGE_TIMEOUT_MS), ABSOLUTE_STAGE_TIMEOUT_MAX_MS);
|
|
923
2025
|
}
|
|
924
2026
|
|
|
925
2027
|
private async waitForScrollbackIdle(
|