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