@love-moon/tui-driver 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/driver/TuiDriver.d.ts +73 -0
- package/dist/driver/TuiDriver.d.ts.map +1 -1
- package/dist/driver/TuiDriver.js +1122 -42
- package/dist/driver/TuiDriver.js.map +1 -1
- package/dist/driver/TuiProfile.d.ts +2 -0
- package/dist/driver/TuiProfile.d.ts.map +1 -1
- package/dist/driver/TuiProfile.js.map +1 -1
- package/dist/driver/behavior/claude.behavior.d.ts +4 -0
- package/dist/driver/behavior/claude.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/claude.behavior.js +48 -0
- package/dist/driver/behavior/claude.behavior.js.map +1 -0
- package/dist/driver/behavior/copilot.behavior.d.ts +4 -0
- package/dist/driver/behavior/copilot.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/copilot.behavior.js +52 -0
- package/dist/driver/behavior/copilot.behavior.js.map +1 -0
- package/dist/driver/behavior/default.behavior.d.ts +4 -0
- package/dist/driver/behavior/default.behavior.d.ts.map +1 -0
- package/dist/driver/behavior/default.behavior.js +13 -0
- package/dist/driver/behavior/default.behavior.js.map +1 -0
- package/dist/driver/behavior/index.d.ts +5 -0
- package/dist/driver/behavior/index.d.ts.map +1 -0
- package/dist/driver/behavior/index.js +10 -0
- package/dist/driver/behavior/index.js.map +1 -0
- package/dist/driver/behavior/types.d.ts +57 -0
- package/dist/driver/behavior/types.d.ts.map +1 -0
- package/dist/driver/behavior/types.js +3 -0
- package/dist/driver/behavior/types.js.map +1 -0
- package/dist/driver/index.d.ts +4 -1
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js +5 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/profiles/claudeCode.profile.d.ts.map +1 -1
- package/dist/driver/profiles/claudeCode.profile.js +7 -3
- package/dist/driver/profiles/claudeCode.profile.js.map +1 -1
- package/dist/driver/profiles/copilot.profile.d.ts.map +1 -1
- package/dist/driver/profiles/copilot.profile.js +4 -0
- package/dist/driver/profiles/copilot.profile.js.map +1 -1
- package/dist/extract/OutputExtractor.d.ts +16 -0
- package/dist/extract/OutputExtractor.d.ts.map +1 -1
- package/dist/extract/OutputExtractor.js +113 -5
- package/dist/extract/OutputExtractor.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/pty/PtySession.d.ts +1 -0
- package/dist/pty/PtySession.d.ts.map +1 -1
- package/dist/pty/PtySession.js +9 -0
- package/dist/pty/PtySession.js.map +1 -1
- package/docs/how-to-add-a-new-backend.md +212 -0
- package/package.json +1 -1
- package/src/driver/TuiDriver.ts +1332 -45
- package/src/driver/TuiProfile.ts +3 -0
- package/src/driver/behavior/claude.behavior.ts +54 -0
- package/src/driver/behavior/copilot.behavior.ts +63 -0
- package/src/driver/behavior/default.behavior.ts +12 -0
- package/src/driver/behavior/index.ts +14 -0
- package/src/driver/behavior/types.ts +64 -0
- package/src/driver/index.ts +20 -1
- package/src/driver/profiles/claudeCode.profile.ts +7 -3
- package/src/driver/profiles/copilot.profile.ts +4 -0
- package/src/extract/OutputExtractor.ts +145 -5
- package/src/index.ts +15 -0
- package/src/pty/PtySession.ts +10 -0
- package/test/claude-profile.test.ts +41 -0
- package/test/claude-signals.test.ts +80 -0
- package/test/codex-session-discovery.test.ts +101 -0
- package/test/copilot-profile.test.ts +12 -0
- package/test/copilot-signals.test.ts +70 -0
- package/test/output-extractor.test.ts +79 -0
- package/test/session-file-extraction.test.ts +257 -0
- package/test/stream-detection.test.ts +28 -0
- package/test/timeout-resolution.test.ts +37 -0
package/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");
|
|
@@ -10,26 +15,45 @@ const Matchers_js_1 = require("../expect/Matchers.js");
|
|
|
10
15
|
const OutputExtractor_js_1 = require("../extract/OutputExtractor.js");
|
|
11
16
|
const Diff_js_1 = require("../extract/Diff.js");
|
|
12
17
|
const StateMachine_js_1 = require("./StateMachine.js");
|
|
18
|
+
const index_js_1 = require("./behavior/index.js");
|
|
19
|
+
const DEFAULT_STAGE_TIMEOUT_MAX_MS = 15 * 60 * 1000;
|
|
20
|
+
const ABSOLUTE_STAGE_TIMEOUT_MAX_MS = 60 * 60 * 1000;
|
|
21
|
+
const MIN_STAGE_TIMEOUT_MS = 100;
|
|
22
|
+
const DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
23
|
+
const DEFAULT_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
24
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
13
25
|
class TuiDriver extends events_1.EventEmitter {
|
|
14
26
|
pty;
|
|
15
27
|
screen;
|
|
16
28
|
expect;
|
|
17
29
|
stateMachine;
|
|
18
30
|
profile;
|
|
31
|
+
behavior;
|
|
19
32
|
debug;
|
|
20
33
|
onSnapshot;
|
|
21
34
|
onSignals;
|
|
22
35
|
isBooted = false;
|
|
36
|
+
isKilled = false;
|
|
37
|
+
sessionCwd;
|
|
38
|
+
sessionInfo = null;
|
|
39
|
+
lastSessionInfo = null;
|
|
40
|
+
sessionUsageCache = null;
|
|
41
|
+
initialCommand;
|
|
42
|
+
initialArgs;
|
|
23
43
|
constructor(options) {
|
|
24
44
|
super();
|
|
25
45
|
this.profile = options.profile;
|
|
46
|
+
this.behavior = options.profile.behavior ?? index_js_1.defaultTuiDriverBehavior;
|
|
26
47
|
this.debug = options.debug ?? false;
|
|
27
48
|
this.onSnapshot = options.onSnapshot;
|
|
28
49
|
this.onSignals = options.onSignals;
|
|
50
|
+
this.sessionCwd = options.cwd ?? process.cwd();
|
|
51
|
+
this.initialCommand = this.profile.command;
|
|
52
|
+
this.initialArgs = Array.isArray(this.profile.args) ? [...this.profile.args] : [];
|
|
29
53
|
const cols = this.profile.cols ?? 120;
|
|
30
54
|
const rows = this.profile.rows ?? 40;
|
|
31
55
|
const scrollback = this.profile.scrollback ?? 5000;
|
|
32
|
-
this.pty = new PtySession_js_1.PtySession(this.
|
|
56
|
+
this.pty = new PtySession_js_1.PtySession(this.initialCommand, this.initialArgs, { cols, rows, env: this.profile.env, cwd: this.sessionCwd });
|
|
33
57
|
this.screen = new HeadlessScreen_js_1.HeadlessScreen({
|
|
34
58
|
cols,
|
|
35
59
|
rows,
|
|
@@ -50,6 +74,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
50
74
|
this.pty.onExit((code, signal) => {
|
|
51
75
|
this.log(`PTY exited: code=${code}, signal=${signal}`);
|
|
52
76
|
this.isBooted = false;
|
|
77
|
+
this.sessionInfo = null;
|
|
53
78
|
this.emit("exit", code, signal);
|
|
54
79
|
});
|
|
55
80
|
this.stateMachine.on("stateChange", (transition) => {
|
|
@@ -72,11 +97,59 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
72
97
|
get running() {
|
|
73
98
|
return this.pty.isRunning;
|
|
74
99
|
}
|
|
100
|
+
getSessionInfo() {
|
|
101
|
+
const current = this.sessionInfo ?? this.lastSessionInfo;
|
|
102
|
+
return current ? { ...current } : null;
|
|
103
|
+
}
|
|
104
|
+
async ensureSessionInfo(timeoutMs = DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS) {
|
|
105
|
+
if (!this.supportsSessionFileTracking()) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (this.sessionInfo) {
|
|
109
|
+
return { ...this.sessionInfo };
|
|
110
|
+
}
|
|
111
|
+
const boundedTimeoutMs = this.resolveTimeout(timeoutMs, DEFAULT_SESSION_DISCOVERY_TIMEOUT_MS);
|
|
112
|
+
const discovered = await this.discoverSessionInfo(boundedTimeoutMs);
|
|
113
|
+
return discovered ? { ...discovered } : null;
|
|
114
|
+
}
|
|
115
|
+
async getSessionUsageSummary() {
|
|
116
|
+
if (!this.supportsSessionFileTracking()) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const sessionInfo = this.sessionInfo ?? this.lastSessionInfo ?? (await this.ensureSessionInfo());
|
|
120
|
+
if (!sessionInfo) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
124
|
+
if (this.sessionUsageCache &&
|
|
125
|
+
this.sessionUsageCache.backend === sessionInfo.backend &&
|
|
126
|
+
this.sessionUsageCache.sessionId === sessionInfo.sessionId &&
|
|
127
|
+
this.sessionUsageCache.sessionFilePath === sessionInfo.sessionFilePath &&
|
|
128
|
+
this.sessionUsageCache.size === size &&
|
|
129
|
+
this.sessionUsageCache.mtimeMs === mtimeMs) {
|
|
130
|
+
return { ...this.sessionUsageCache.summary };
|
|
131
|
+
}
|
|
132
|
+
const lines = await this.readSessionFileJsonLines(sessionInfo.sessionFilePath, 0);
|
|
133
|
+
const summary = this.extractSessionUsageSummaryFromJsonLines(lines, sessionInfo);
|
|
134
|
+
this.sessionUsageCache = {
|
|
135
|
+
backend: sessionInfo.backend,
|
|
136
|
+
sessionId: sessionInfo.sessionId,
|
|
137
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
138
|
+
size,
|
|
139
|
+
mtimeMs,
|
|
140
|
+
summary,
|
|
141
|
+
};
|
|
142
|
+
return { ...summary };
|
|
143
|
+
}
|
|
75
144
|
async boot() {
|
|
145
|
+
if (this.isKilled) {
|
|
146
|
+
throw this.createSessionClosedError();
|
|
147
|
+
}
|
|
76
148
|
if (this.isBooted) {
|
|
77
149
|
return;
|
|
78
150
|
}
|
|
79
151
|
this.stateMachine.transition("BOOT");
|
|
152
|
+
this.sessionInfo = null;
|
|
80
153
|
this.pty.spawn();
|
|
81
154
|
const bootTimeout = this.resolveTimeout(this.profile.timeouts?.boot, 15000);
|
|
82
155
|
const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
|
|
@@ -161,6 +234,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
161
234
|
}
|
|
162
235
|
this.isBooted = true;
|
|
163
236
|
this.stateMachine.transition("WAIT_READY");
|
|
237
|
+
await this.ensureSessionInfo();
|
|
164
238
|
this.captureSnapshot("boot_complete");
|
|
165
239
|
}
|
|
166
240
|
async ensureReady() {
|
|
@@ -170,9 +244,13 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
170
244
|
}
|
|
171
245
|
const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
|
|
172
246
|
const readyTimeout = this.resolveTimeout(this.profile.timeouts?.ready, 10000);
|
|
247
|
+
const guardedReadyMatcher = Matchers_js_1.Matchers.custom((snapshot) => {
|
|
248
|
+
this.assertAliveOrThrow();
|
|
249
|
+
return readyMatcher(snapshot);
|
|
250
|
+
});
|
|
173
251
|
const result = await this.expect.until({
|
|
174
252
|
name: "ENSURE_READY",
|
|
175
|
-
match:
|
|
253
|
+
match: guardedReadyMatcher,
|
|
176
254
|
stableMs: 200,
|
|
177
255
|
timeoutMs: readyTimeout,
|
|
178
256
|
});
|
|
@@ -183,6 +261,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
183
261
|
}
|
|
184
262
|
async ask(prompt) {
|
|
185
263
|
const startTime = Date.now();
|
|
264
|
+
let sessionInfo = null;
|
|
186
265
|
try {
|
|
187
266
|
await this.ensureReady();
|
|
188
267
|
// 健康检查:在执行前检测异常状态
|
|
@@ -199,6 +278,17 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
199
278
|
throw error;
|
|
200
279
|
}
|
|
201
280
|
}
|
|
281
|
+
else if (health.reason === "process_exited") {
|
|
282
|
+
this.log("Health check detected exited process, attempting forced restart");
|
|
283
|
+
await this.restart();
|
|
284
|
+
const healthAfterRestart = this.healthCheck();
|
|
285
|
+
if (!healthAfterRestart.healthy) {
|
|
286
|
+
const error = new Error(`Cannot proceed: ${healthAfterRestart.message || healthAfterRestart.reason}`);
|
|
287
|
+
error.reason = healthAfterRestart.reason;
|
|
288
|
+
error.matchedPattern = healthAfterRestart.matchedPattern;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
202
292
|
else if (health.reason === "login_required") {
|
|
203
293
|
const error = new Error(`Cannot proceed: ${health.message}`);
|
|
204
294
|
error.reason = health.reason;
|
|
@@ -219,6 +309,7 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
219
309
|
}
|
|
220
310
|
}
|
|
221
311
|
}
|
|
312
|
+
sessionInfo = await this.ensureSessionInfo();
|
|
222
313
|
this.stateMachine.transition("PREPARE_TURN");
|
|
223
314
|
await this.prepareTurn();
|
|
224
315
|
this.stateMachine.transition("TYPE_PROMPT");
|
|
@@ -229,13 +320,26 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
229
320
|
await this.submit();
|
|
230
321
|
// 在 submit 之后保存快照,这样 diff 只包含 AI 的回答,不包含 prompt
|
|
231
322
|
const beforeSnapshot = this.captureSnapshot("after_submit");
|
|
323
|
+
const sessionCheckpoint = await this.captureSessionFileCheckpoint(sessionInfo);
|
|
232
324
|
this.stateMachine.transition("WAIT_STREAM_START");
|
|
233
|
-
|
|
325
|
+
if (sessionCheckpoint) {
|
|
326
|
+
await this.waitForSessionFileGrowth(sessionCheckpoint);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
await this.waitStreamStart(preSubmitSnapshot);
|
|
330
|
+
}
|
|
234
331
|
this.stateMachine.transition("WAIT_STREAM_END");
|
|
235
|
-
|
|
332
|
+
if (sessionCheckpoint) {
|
|
333
|
+
await this.waitForSessionFileIdle(sessionCheckpoint);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
await this.waitStreamEnd(beforeSnapshot);
|
|
337
|
+
}
|
|
236
338
|
this.stateMachine.transition("CAPTURE");
|
|
237
339
|
const afterSnapshot = this.captureSnapshot("after_response");
|
|
238
|
-
const answer =
|
|
340
|
+
const answer = sessionCheckpoint
|
|
341
|
+
? await this.extractAnswerFromSessionFile(sessionCheckpoint)
|
|
342
|
+
: this.extractAnswer(beforeSnapshot, afterSnapshot);
|
|
239
343
|
const signals = this.getSignals(afterSnapshot);
|
|
240
344
|
this.stateMachine.transition("DONE");
|
|
241
345
|
return {
|
|
@@ -251,6 +355,8 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
251
355
|
replyInProgress: signals.replyInProgress,
|
|
252
356
|
statusLine: signals.statusLine,
|
|
253
357
|
statusDoneLine: signals.statusDoneLine,
|
|
358
|
+
sessionId: sessionInfo?.sessionId,
|
|
359
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
254
360
|
};
|
|
255
361
|
}
|
|
256
362
|
catch (error) {
|
|
@@ -270,6 +376,8 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
270
376
|
replyInProgress: signals.replyInProgress,
|
|
271
377
|
statusLine: signals.statusLine,
|
|
272
378
|
statusDoneLine: signals.statusDoneLine,
|
|
379
|
+
sessionId: sessionInfo?.sessionId,
|
|
380
|
+
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
273
381
|
};
|
|
274
382
|
}
|
|
275
383
|
}
|
|
@@ -285,23 +393,748 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
285
393
|
async submit() {
|
|
286
394
|
await this.pty.sendKeys(this.profile.keys.submit, 50);
|
|
287
395
|
}
|
|
396
|
+
supportsSessionFileTracking() {
|
|
397
|
+
const backend = String(this.profile.name || "").toLowerCase();
|
|
398
|
+
return backend === "codex" || backend === "claude-code" || backend === "copilot";
|
|
399
|
+
}
|
|
400
|
+
async discoverSessionInfo(timeoutMs) {
|
|
401
|
+
const startedAt = Date.now();
|
|
402
|
+
const deadline = startedAt + Math.max(MIN_STAGE_TIMEOUT_MS, timeoutMs);
|
|
403
|
+
while (Date.now() < deadline) {
|
|
404
|
+
this.assertAliveOrThrow();
|
|
405
|
+
const discovered = await this.detectSessionInfoByBackend();
|
|
406
|
+
if (discovered) {
|
|
407
|
+
const changed = !this.sessionInfo ||
|
|
408
|
+
this.sessionInfo.sessionId !== discovered.sessionId ||
|
|
409
|
+
this.sessionInfo.sessionFilePath !== discovered.sessionFilePath;
|
|
410
|
+
this.sessionInfo = discovered;
|
|
411
|
+
this.lastSessionInfo = discovered;
|
|
412
|
+
if (changed) {
|
|
413
|
+
this.emit("session", { ...discovered });
|
|
414
|
+
this.log(`session discovered: id=${discovered.sessionId} file=${discovered.sessionFilePath}`);
|
|
415
|
+
}
|
|
416
|
+
return discovered;
|
|
417
|
+
}
|
|
418
|
+
await this.sleep(250);
|
|
419
|
+
}
|
|
420
|
+
return this.sessionInfo ? { ...this.sessionInfo } : null;
|
|
421
|
+
}
|
|
422
|
+
async detectSessionInfoByBackend() {
|
|
423
|
+
if (!this.supportsSessionFileTracking()) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
switch (this.profile.name) {
|
|
428
|
+
case "codex":
|
|
429
|
+
return this.detectCodexSessionInfo();
|
|
430
|
+
case "claude-code":
|
|
431
|
+
return this.detectClaudeSessionInfo();
|
|
432
|
+
case "copilot":
|
|
433
|
+
return this.detectCopilotSessionInfo();
|
|
434
|
+
default:
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
this.log(`session detect failed: ${error?.message || error}`);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async detectCodexSessionInfo() {
|
|
444
|
+
const dbPath = (0, node_path_1.join)((0, node_os_1.homedir)(), ".codex", "state_5.sqlite");
|
|
445
|
+
if (!(await this.pathExists(dbPath))) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const parseRowAsSessionInfo = async (row) => {
|
|
449
|
+
if (!row) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const [sessionIdRaw, sessionFilePathRaw] = row.split("|");
|
|
453
|
+
const sessionId = String(sessionIdRaw || "").trim();
|
|
454
|
+
const sessionFilePath = String(sessionFilePathRaw || "").trim();
|
|
455
|
+
if (!sessionId || !sessionFilePath || !(await this.pathExists(sessionFilePath))) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
backend: "codex",
|
|
460
|
+
sessionId,
|
|
461
|
+
sessionFilePath,
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
const pinnedSessionId = String(this.sessionInfo?.sessionId || this.lastSessionInfo?.sessionId || "").trim();
|
|
465
|
+
if (pinnedSessionId) {
|
|
466
|
+
const escapedSessionId = pinnedSessionId.replace(/'/g, "''");
|
|
467
|
+
const pinnedRow = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and id='${escapedSessionId}' limit 1;`);
|
|
468
|
+
const pinnedSession = await parseRowAsSessionInfo(pinnedRow);
|
|
469
|
+
if (pinnedSession) {
|
|
470
|
+
return pinnedSession;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const escapedCwd = this.sessionCwd.replace(/'/g, "''");
|
|
474
|
+
const row = await this.querySqliteRow(dbPath, `select id, rollout_path from threads where source='cli' and model_provider='openai' and cwd='${escapedCwd}' order by updated_at desc limit 1;`);
|
|
475
|
+
return parseRowAsSessionInfo(row);
|
|
476
|
+
}
|
|
477
|
+
async detectClaudeSessionInfo() {
|
|
478
|
+
const projectDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects", this.encodeClaudeProjectPath(this.sessionCwd));
|
|
479
|
+
if (!(await this.pathExists(projectDir))) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
const indexPath = (0, node_path_1.join)(projectDir, "sessions-index.json");
|
|
483
|
+
if (await this.pathExists(indexPath)) {
|
|
484
|
+
try {
|
|
485
|
+
const raw = await node_fs_1.promises.readFile(indexPath, "utf8");
|
|
486
|
+
const parsed = JSON.parse(raw);
|
|
487
|
+
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
488
|
+
const candidates = entries
|
|
489
|
+
.filter((entry) => {
|
|
490
|
+
const entrySessionId = String(entry?.sessionId || "").trim();
|
|
491
|
+
if (!entrySessionId) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
const entryProjectPath = String(entry?.projectPath || "").trim();
|
|
495
|
+
return !entryProjectPath || entryProjectPath === this.sessionCwd;
|
|
496
|
+
})
|
|
497
|
+
.sort((a, b) => {
|
|
498
|
+
const scoreA = Number(a?.fileMtime || Date.parse(String(a?.modified || "")) || 0);
|
|
499
|
+
const scoreB = Number(b?.fileMtime || Date.parse(String(b?.modified || "")) || 0);
|
|
500
|
+
return scoreB - scoreA;
|
|
501
|
+
});
|
|
502
|
+
for (const entry of candidates) {
|
|
503
|
+
const sessionId = String(entry.sessionId || "").trim();
|
|
504
|
+
const sessionFilePath = String(entry.fullPath || "").trim() || (0, node_path_1.join)(projectDir, `${sessionId}.jsonl`);
|
|
505
|
+
if (sessionId && sessionFilePath && (await this.pathExists(sessionFilePath))) {
|
|
506
|
+
return {
|
|
507
|
+
backend: "claude-code",
|
|
508
|
+
sessionId,
|
|
509
|
+
sessionFilePath,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
this.log(`claude session index parse failed: ${error?.message || error}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const dirents = await node_fs_1.promises.readdir(projectDir, { withFileTypes: true });
|
|
520
|
+
const jsonlFiles = dirents
|
|
521
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
522
|
+
.map((entry) => (0, node_path_1.join)(projectDir, entry.name));
|
|
523
|
+
const stats = await Promise.all(jsonlFiles.map(async (filePath) => ({
|
|
524
|
+
filePath,
|
|
525
|
+
mtimeMs: (await node_fs_1.promises.stat(filePath)).mtimeMs,
|
|
526
|
+
})));
|
|
527
|
+
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
528
|
+
const latest = stats[0];
|
|
529
|
+
if (!latest) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const sessionId = (0, node_path_1.basename)(latest.filePath, ".jsonl");
|
|
533
|
+
return {
|
|
534
|
+
backend: "claude-code",
|
|
535
|
+
sessionId,
|
|
536
|
+
sessionFilePath: latest.filePath,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async detectCopilotSessionInfo() {
|
|
544
|
+
const baseDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".copilot", "session-state");
|
|
545
|
+
if (!(await this.pathExists(baseDir))) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const dirents = await node_fs_1.promises.readdir(baseDir, { withFileTypes: true });
|
|
550
|
+
const candidates = [];
|
|
551
|
+
for (const entry of dirents) {
|
|
552
|
+
if (!entry.isDirectory()) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const sessionDir = (0, node_path_1.join)(baseDir, entry.name);
|
|
556
|
+
const workspacePath = (0, node_path_1.join)(sessionDir, "workspace.yaml");
|
|
557
|
+
const eventsPath = (0, node_path_1.join)(sessionDir, "events.jsonl");
|
|
558
|
+
if (!(await this.pathExists(eventsPath))) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
let workspaceCwd = "";
|
|
562
|
+
let workspaceId = "";
|
|
563
|
+
if (await this.pathExists(workspacePath)) {
|
|
564
|
+
workspaceCwd = (await this.readWorkspaceYamlValue(workspacePath, "cwd")) || "";
|
|
565
|
+
workspaceId = (await this.readWorkspaceYamlValue(workspacePath, "id")) || "";
|
|
566
|
+
}
|
|
567
|
+
if (workspaceCwd && workspaceCwd !== this.sessionCwd) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const sessionId = workspaceId || entry.name;
|
|
571
|
+
const mtimeMs = (await node_fs_1.promises.stat(eventsPath)).mtimeMs;
|
|
572
|
+
candidates.push({
|
|
573
|
+
sessionId,
|
|
574
|
+
sessionFilePath: eventsPath,
|
|
575
|
+
mtimeMs,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
579
|
+
const latest = candidates[0];
|
|
580
|
+
if (!latest) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
backend: "copilot",
|
|
585
|
+
sessionId: latest.sessionId,
|
|
586
|
+
sessionFilePath: latest.sessionFilePath,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async querySqliteRow(dbPath, query) {
|
|
594
|
+
try {
|
|
595
|
+
const { stdout } = await execFileAsync("sqlite3", [dbPath, query], {
|
|
596
|
+
timeout: 3000,
|
|
597
|
+
maxBuffer: 1024 * 1024,
|
|
598
|
+
});
|
|
599
|
+
const lines = String(stdout || "")
|
|
600
|
+
.split(/\r?\n/)
|
|
601
|
+
.map((line) => line.trim())
|
|
602
|
+
.filter(Boolean);
|
|
603
|
+
return lines[0] ?? null;
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
this.log(`sqlite query failed: ${error?.message || error}`);
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
encodeClaudeProjectPath(cwd) {
|
|
611
|
+
return String(cwd || "").replace(/\//g, "-");
|
|
612
|
+
}
|
|
613
|
+
async readWorkspaceYamlValue(filePath, key) {
|
|
614
|
+
try {
|
|
615
|
+
const raw = await node_fs_1.promises.readFile(filePath, "utf8");
|
|
616
|
+
const matcher = new RegExp(`^${key}:\\s*(.+)\\s*$`, "m");
|
|
617
|
+
const match = raw.match(matcher);
|
|
618
|
+
if (!match) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
const value = match[1].trim();
|
|
622
|
+
if (!value) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async pathExists(filePath) {
|
|
632
|
+
try {
|
|
633
|
+
await node_fs_1.promises.access(filePath);
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async captureSessionFileCheckpoint(sessionInfo) {
|
|
641
|
+
if (!sessionInfo) {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const { size, mtimeMs } = await this.readSessionFileStat(sessionInfo.sessionFilePath);
|
|
645
|
+
return {
|
|
646
|
+
sessionInfo,
|
|
647
|
+
size,
|
|
648
|
+
mtimeMs,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
async readSessionFileStat(sessionFilePath) {
|
|
652
|
+
try {
|
|
653
|
+
const stats = await node_fs_1.promises.stat(sessionFilePath);
|
|
654
|
+
return {
|
|
655
|
+
size: stats.size,
|
|
656
|
+
mtimeMs: stats.mtimeMs,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return {
|
|
661
|
+
size: 0,
|
|
662
|
+
mtimeMs: 0,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async waitForSessionFileGrowth(checkpoint) {
|
|
667
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
668
|
+
const startedAt = Date.now();
|
|
669
|
+
let lastSize = checkpoint.size;
|
|
670
|
+
let lastMtimeMs = checkpoint.mtimeMs;
|
|
671
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
672
|
+
this.assertAliveOrThrow();
|
|
673
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
674
|
+
const changed = current.size !== lastSize || current.mtimeMs !== lastMtimeMs;
|
|
675
|
+
if (changed) {
|
|
676
|
+
this.log(`session file growth detected: ${checkpoint.sessionInfo.sessionFilePath} (${lastSize} -> ${current.size})`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
680
|
+
}
|
|
681
|
+
throw new Error("Stream start timeout: session file did not grow");
|
|
682
|
+
}
|
|
683
|
+
async waitForSessionFileIdle(checkpoint) {
|
|
684
|
+
const timeoutMs = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
|
|
685
|
+
const startedAt = Date.now();
|
|
686
|
+
let previousSize = checkpoint.size;
|
|
687
|
+
let previousMtimeMs = checkpoint.mtimeMs;
|
|
688
|
+
let observedProgress = false;
|
|
689
|
+
let unchangedChecks = 0;
|
|
690
|
+
const requireCompletionMarker = this.requiresSessionCompletionMarker(checkpoint.sessionInfo.backend);
|
|
691
|
+
let completionMarkerSeen = false;
|
|
692
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
693
|
+
this.assertAliveOrThrow();
|
|
694
|
+
const current = await this.readSessionFileStat(checkpoint.sessionInfo.sessionFilePath);
|
|
695
|
+
const changed = current.size !== previousSize || current.mtimeMs !== previousMtimeMs;
|
|
696
|
+
if (changed) {
|
|
697
|
+
this.log(`session file changed: backend=${checkpoint.sessionInfo.backend} size=${previousSize}->${current.size} mtime=${previousMtimeMs}->${current.mtimeMs}`);
|
|
698
|
+
observedProgress = true;
|
|
699
|
+
unchangedChecks = 0;
|
|
700
|
+
previousSize = current.size;
|
|
701
|
+
previousMtimeMs = current.mtimeMs;
|
|
702
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
703
|
+
completionMarkerSeen = await this.hasSessionCompletionMarker(checkpoint, current.size);
|
|
704
|
+
if (completionMarkerSeen) {
|
|
705
|
+
this.log(`session completion marker observed: backend=${checkpoint.sessionInfo.backend} file=${checkpoint.sessionInfo.sessionFilePath}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
unchangedChecks += 1;
|
|
711
|
+
this.log(`session file unchanged: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
|
|
712
|
+
if (observedProgress && unchangedChecks >= 2) {
|
|
713
|
+
if (!requireCompletionMarker || completionMarkerSeen) {
|
|
714
|
+
this.log(`session file idle reached: backend=${checkpoint.sessionInfo.backend} checks=${unchangedChecks} completionMarkerSeen=${completionMarkerSeen}`);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
await this.sleep(DEFAULT_SESSION_POLL_INTERVAL_MS);
|
|
720
|
+
}
|
|
721
|
+
if (!observedProgress) {
|
|
722
|
+
throw new Error("Stream end timeout: session file did not grow");
|
|
723
|
+
}
|
|
724
|
+
if (requireCompletionMarker && !completionMarkerSeen) {
|
|
725
|
+
throw new Error("Stream end timeout: session completion marker not observed");
|
|
726
|
+
}
|
|
727
|
+
throw new Error("Stream end timeout: session file did not become stable");
|
|
728
|
+
}
|
|
729
|
+
async extractAnswerFromSessionFile(checkpoint) {
|
|
730
|
+
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size);
|
|
731
|
+
const codexTaskCompleteMessage = this.extractCodexTaskCompleteMessageFromJsonLines(lines);
|
|
732
|
+
if (codexTaskCompleteMessage) {
|
|
733
|
+
this.log(`session answer source=codex.task_complete preview="${this.summarizeForLog(codexTaskCompleteMessage, 160)}"`);
|
|
734
|
+
return codexTaskCompleteMessage;
|
|
735
|
+
}
|
|
736
|
+
const answer = this.extractAssistantReplyFromJsonLines(lines, checkpoint.sessionInfo.backend);
|
|
737
|
+
if (answer) {
|
|
738
|
+
this.log(`session answer source=${checkpoint.sessionInfo.backend}.assistant preview="${this.summarizeForLog(answer, 160)}"`);
|
|
739
|
+
return answer;
|
|
740
|
+
}
|
|
741
|
+
throw new Error("No assistant reply found in session file");
|
|
742
|
+
}
|
|
743
|
+
extractSessionUsageSummaryFromJsonLines(lines, sessionInfo) {
|
|
744
|
+
const backend = sessionInfo.backend;
|
|
745
|
+
const baseSummary = {
|
|
746
|
+
backend,
|
|
747
|
+
sessionId: sessionInfo.sessionId,
|
|
748
|
+
sessionFilePath: sessionInfo.sessionFilePath,
|
|
749
|
+
};
|
|
750
|
+
const usage = backend === "codex"
|
|
751
|
+
? this.extractCodexUsageFromJsonLines(lines)
|
|
752
|
+
: backend === "claude-code"
|
|
753
|
+
? this.extractClaudeUsageFromJsonLines(lines)
|
|
754
|
+
: backend === "copilot"
|
|
755
|
+
? this.extractCopilotUsageFromJsonLines(lines)
|
|
756
|
+
: {};
|
|
757
|
+
return {
|
|
758
|
+
...baseSummary,
|
|
759
|
+
...usage,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
extractCodexUsageFromJsonLines(lines) {
|
|
763
|
+
let tokenUsagePercent;
|
|
764
|
+
let contextUsagePercent;
|
|
765
|
+
for (const line of lines) {
|
|
766
|
+
let entry = null;
|
|
767
|
+
try {
|
|
768
|
+
entry = JSON.parse(line);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (!entry || entry.type !== "event_msg") {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const payload = entry.payload;
|
|
777
|
+
if (!payload || typeof payload !== "object") {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const secondaryUsedPercent = this.readNumberPath(payload, ["rate_limits", "secondary", "used_percent"]);
|
|
781
|
+
if (secondaryUsedPercent !== undefined) {
|
|
782
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
783
|
+
}
|
|
784
|
+
const inputTokens = this.readNumberPath(payload, ["info", "last_token_usage", "input_tokens"]);
|
|
785
|
+
const contextWindow = this.readNumberPath(payload, ["info", "model_context_window"]);
|
|
786
|
+
if (inputTokens !== undefined && contextWindow !== undefined && contextWindow > 0) {
|
|
787
|
+
contextUsagePercent = this.normalizePercent((inputTokens / contextWindow) * 100);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
tokenUsagePercent,
|
|
792
|
+
contextUsagePercent,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
extractClaudeUsageFromJsonLines(lines) {
|
|
796
|
+
let tokenUsagePercent;
|
|
797
|
+
let contextUsagePercent;
|
|
798
|
+
let latestInputTokens;
|
|
799
|
+
let latestContextWindow;
|
|
800
|
+
for (const line of lines) {
|
|
801
|
+
let entry = null;
|
|
802
|
+
try {
|
|
803
|
+
entry = JSON.parse(line);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (!entry || typeof entry !== "object") {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const secondaryUsedPercent = this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
812
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
813
|
+
if (secondaryUsedPercent !== undefined) {
|
|
814
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
815
|
+
}
|
|
816
|
+
const inputTokens = this.readNumberPath(entry, ["message", "usage", "input_tokens"]) ??
|
|
817
|
+
this.readNumberPath(entry, ["message", "usage", "inputTokens"]) ??
|
|
818
|
+
this.readNumberPath(entry, ["usage", "input_tokens"]) ??
|
|
819
|
+
this.readNumberPath(entry, ["usage", "inputTokens"]);
|
|
820
|
+
if (inputTokens !== undefined) {
|
|
821
|
+
latestInputTokens = inputTokens;
|
|
822
|
+
}
|
|
823
|
+
const contextWindow = this.readNumberPath(entry, ["message", "model_context_window"]) ??
|
|
824
|
+
this.readNumberPath(entry, ["message", "modelContextWindow"]) ??
|
|
825
|
+
this.readNumberPath(entry, ["message", "context_window"]) ??
|
|
826
|
+
this.readNumberPath(entry, ["message", "contextWindow"]) ??
|
|
827
|
+
this.readNumberPath(entry, ["model_context_window"]) ??
|
|
828
|
+
this.readNumberPath(entry, ["modelContextWindow"]) ??
|
|
829
|
+
this.readNumberPath(entry, ["context_window"]) ??
|
|
830
|
+
this.readNumberPath(entry, ["contextWindow"]);
|
|
831
|
+
if (contextWindow !== undefined && contextWindow > 0) {
|
|
832
|
+
latestContextWindow = contextWindow;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (latestInputTokens !== undefined &&
|
|
836
|
+
latestContextWindow !== undefined &&
|
|
837
|
+
latestContextWindow > 0) {
|
|
838
|
+
contextUsagePercent = this.normalizePercent((latestInputTokens / latestContextWindow) * 100);
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
tokenUsagePercent,
|
|
842
|
+
contextUsagePercent,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
extractCopilotUsageFromJsonLines(lines) {
|
|
846
|
+
let tokenUsagePercent;
|
|
847
|
+
let contextUsagePercent;
|
|
848
|
+
let latestContextTokens;
|
|
849
|
+
let latestContextLimit;
|
|
850
|
+
for (const line of lines) {
|
|
851
|
+
let entry = null;
|
|
852
|
+
try {
|
|
853
|
+
entry = JSON.parse(line);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (!entry || typeof entry !== "object") {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const secondaryUsedPercent = this.readNumberPath(entry, ["data", "rate_limits", "secondary", "used_percent"]) ??
|
|
862
|
+
this.readNumberPath(entry, ["data", "rateLimits", "secondary", "usedPercent"]) ??
|
|
863
|
+
this.readNumberPath(entry, ["rate_limits", "secondary", "used_percent"]) ??
|
|
864
|
+
this.readNumberPath(entry, ["rateLimits", "secondary", "usedPercent"]);
|
|
865
|
+
if (secondaryUsedPercent !== undefined) {
|
|
866
|
+
tokenUsagePercent = this.normalizePercent(secondaryUsedPercent);
|
|
867
|
+
}
|
|
868
|
+
const responseTokenLimit = this.readNumberPath(entry, ["data", "toolTelemetry", "metrics", "responseTokenLimit"]) ??
|
|
869
|
+
this.readNumberPath(entry, ["data", "responseTokenLimit"]) ??
|
|
870
|
+
this.readNumberPath(entry, ["data", "modelContextWindow"]) ??
|
|
871
|
+
this.readNumberPath(entry, ["data", "model_context_window"]) ??
|
|
872
|
+
this.readNumberPath(entry, ["data", "contextWindow"]) ??
|
|
873
|
+
this.readNumberPath(entry, ["data", "context_window"]) ??
|
|
874
|
+
this.readNumberPath(entry, ["responseTokenLimit"]);
|
|
875
|
+
if (responseTokenLimit !== undefined && responseTokenLimit > 0) {
|
|
876
|
+
latestContextLimit = responseTokenLimit;
|
|
877
|
+
}
|
|
878
|
+
const contextTokens = this.readNumberPath(entry, ["data", "preCompactionTokens"]) ??
|
|
879
|
+
this.readNumberPath(entry, ["data", "compactionTokensUsed", "input"]) ??
|
|
880
|
+
this.readNumberPath(entry, ["data", "postCompactionTokens"]) ??
|
|
881
|
+
this.readNumberPath(entry, ["data", "tokenUsage", "input_tokens"]) ??
|
|
882
|
+
this.readNumberPath(entry, ["data", "usage", "input_tokens"]) ??
|
|
883
|
+
this.readNumberPath(entry, ["data", "inputTokens"]);
|
|
884
|
+
if (contextTokens !== undefined && contextTokens >= 0) {
|
|
885
|
+
latestContextTokens = contextTokens;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (latestContextTokens !== undefined &&
|
|
889
|
+
latestContextLimit !== undefined &&
|
|
890
|
+
latestContextLimit > 0) {
|
|
891
|
+
contextUsagePercent = this.normalizePercent((latestContextTokens / latestContextLimit) * 100);
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
tokenUsagePercent,
|
|
895
|
+
contextUsagePercent,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
readNumberPath(source, path) {
|
|
899
|
+
let cursor = source;
|
|
900
|
+
for (const key of path) {
|
|
901
|
+
if (!cursor || typeof cursor !== "object") {
|
|
902
|
+
return undefined;
|
|
903
|
+
}
|
|
904
|
+
cursor = cursor[key];
|
|
905
|
+
}
|
|
906
|
+
if (typeof cursor === "number" && Number.isFinite(cursor)) {
|
|
907
|
+
return cursor;
|
|
908
|
+
}
|
|
909
|
+
if (typeof cursor === "string") {
|
|
910
|
+
const parsed = Number(cursor);
|
|
911
|
+
if (Number.isFinite(parsed)) {
|
|
912
|
+
return parsed;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return undefined;
|
|
916
|
+
}
|
|
917
|
+
normalizePercent(value) {
|
|
918
|
+
if (!Number.isFinite(value)) {
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
if (value < 0) {
|
|
922
|
+
return 0;
|
|
923
|
+
}
|
|
924
|
+
if (value > 100) {
|
|
925
|
+
return 100;
|
|
926
|
+
}
|
|
927
|
+
return value;
|
|
928
|
+
}
|
|
929
|
+
requiresSessionCompletionMarker(backend) {
|
|
930
|
+
return backend === "codex" || backend === "copilot";
|
|
931
|
+
}
|
|
932
|
+
async hasSessionCompletionMarker(checkpoint, endOffset) {
|
|
933
|
+
const lines = await this.readSessionFileJsonLines(checkpoint.sessionInfo.sessionFilePath, checkpoint.size, endOffset);
|
|
934
|
+
if (checkpoint.sessionInfo.backend === "codex") {
|
|
935
|
+
return this.hasCodexTaskCompleteFromJsonLines(lines);
|
|
936
|
+
}
|
|
937
|
+
if (checkpoint.sessionInfo.backend === "copilot") {
|
|
938
|
+
return this.hasCopilotTurnEndFromJsonLines(lines);
|
|
939
|
+
}
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
async readSessionFileJsonLines(sessionFilePath, startOffset = 0, endOffset) {
|
|
943
|
+
let fullBuffer;
|
|
944
|
+
try {
|
|
945
|
+
fullBuffer = await node_fs_1.promises.readFile(sessionFilePath);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
return [];
|
|
949
|
+
}
|
|
950
|
+
const boundedStartOffset = Math.max(0, Math.min(startOffset, fullBuffer.length));
|
|
951
|
+
const boundedEndOffset = Number.isFinite(endOffset)
|
|
952
|
+
? Math.max(boundedStartOffset, Math.min(Number(endOffset), fullBuffer.length))
|
|
953
|
+
: fullBuffer.length;
|
|
954
|
+
return fullBuffer
|
|
955
|
+
.subarray(boundedStartOffset, boundedEndOffset)
|
|
956
|
+
.toString("utf8")
|
|
957
|
+
.split(/\r?\n/)
|
|
958
|
+
.map((line) => line.trim())
|
|
959
|
+
.filter(Boolean);
|
|
960
|
+
}
|
|
961
|
+
extractAssistantReplyFromJsonLines(lines, backend) {
|
|
962
|
+
const replies = [];
|
|
963
|
+
for (const line of lines) {
|
|
964
|
+
let entry = null;
|
|
965
|
+
try {
|
|
966
|
+
entry = JSON.parse(line);
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (!entry || typeof entry !== "object") {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const text = backend === "codex"
|
|
975
|
+
? this.extractCodexAssistantText(entry)
|
|
976
|
+
: backend === "claude-code"
|
|
977
|
+
? this.extractClaudeAssistantText(entry)
|
|
978
|
+
: backend === "copilot"
|
|
979
|
+
? this.extractCopilotAssistantText(entry)
|
|
980
|
+
: "";
|
|
981
|
+
if (text) {
|
|
982
|
+
replies.push(text);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return replies.length > 0 ? replies[replies.length - 1] : "";
|
|
986
|
+
}
|
|
987
|
+
hasCodexTaskCompleteFromJsonLines(lines) {
|
|
988
|
+
for (const line of lines) {
|
|
989
|
+
let entry = null;
|
|
990
|
+
try {
|
|
991
|
+
entry = JSON.parse(line);
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (entry && this.isCodexTaskCompleteEntry(entry)) {
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
hasCopilotTurnEndFromJsonLines(lines) {
|
|
1003
|
+
for (const line of lines) {
|
|
1004
|
+
let entry = null;
|
|
1005
|
+
try {
|
|
1006
|
+
entry = JSON.parse(line);
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (entry && this.isCopilotTurnEndEntry(entry)) {
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
extractCodexTaskCompleteMessageFromJsonLines(lines) {
|
|
1018
|
+
let latestMessage = "";
|
|
1019
|
+
for (const line of lines) {
|
|
1020
|
+
let entry = null;
|
|
1021
|
+
try {
|
|
1022
|
+
entry = JSON.parse(line);
|
|
1023
|
+
}
|
|
1024
|
+
catch {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!entry || !this.isCodexTaskCompleteEntry(entry)) {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const payload = entry.payload;
|
|
1031
|
+
const message = typeof payload?.last_agent_message === "string" ? payload.last_agent_message.trim() : "";
|
|
1032
|
+
if (message) {
|
|
1033
|
+
latestMessage = message;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return latestMessage;
|
|
1037
|
+
}
|
|
1038
|
+
isCodexTaskCompleteEntry(entry) {
|
|
1039
|
+
if (entry.type !== "event_msg") {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
const payload = entry.payload;
|
|
1043
|
+
return payload?.type === "task_complete";
|
|
1044
|
+
}
|
|
1045
|
+
isCopilotTurnEndEntry(entry) {
|
|
1046
|
+
return entry.type === "assistant.turn_end";
|
|
1047
|
+
}
|
|
1048
|
+
summarizeForLog(value, maxLen = 160) {
|
|
1049
|
+
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
|
1050
|
+
if (!normalized) {
|
|
1051
|
+
return "";
|
|
1052
|
+
}
|
|
1053
|
+
if (normalized.length <= maxLen) {
|
|
1054
|
+
return normalized;
|
|
1055
|
+
}
|
|
1056
|
+
return `${normalized.slice(0, maxLen)}...`;
|
|
1057
|
+
}
|
|
1058
|
+
extractCodexAssistantText(entry) {
|
|
1059
|
+
if (entry.type !== "response_item") {
|
|
1060
|
+
return "";
|
|
1061
|
+
}
|
|
1062
|
+
const payload = entry.payload;
|
|
1063
|
+
if (!payload || payload.type !== "message" || payload.role !== "assistant") {
|
|
1064
|
+
return "";
|
|
1065
|
+
}
|
|
1066
|
+
const content = payload.content;
|
|
1067
|
+
if (!Array.isArray(content)) {
|
|
1068
|
+
return "";
|
|
1069
|
+
}
|
|
1070
|
+
const text = content
|
|
1071
|
+
.map((part) => (typeof part?.text === "string" ? part.text : ""))
|
|
1072
|
+
.filter(Boolean)
|
|
1073
|
+
.join("\n")
|
|
1074
|
+
.trim();
|
|
1075
|
+
return text;
|
|
1076
|
+
}
|
|
1077
|
+
extractClaudeAssistantText(entry) {
|
|
1078
|
+
if (entry.type !== "assistant") {
|
|
1079
|
+
return "";
|
|
1080
|
+
}
|
|
1081
|
+
const message = entry.message;
|
|
1082
|
+
if (!message || message.role !== "assistant") {
|
|
1083
|
+
return "";
|
|
1084
|
+
}
|
|
1085
|
+
const content = message.content;
|
|
1086
|
+
if (typeof content === "string") {
|
|
1087
|
+
return content.trim();
|
|
1088
|
+
}
|
|
1089
|
+
if (!Array.isArray(content)) {
|
|
1090
|
+
return "";
|
|
1091
|
+
}
|
|
1092
|
+
const text = content
|
|
1093
|
+
.map((block) => {
|
|
1094
|
+
const typed = block;
|
|
1095
|
+
return typed?.type === "text" && typeof typed?.text === "string" ? typed.text : "";
|
|
1096
|
+
})
|
|
1097
|
+
.filter(Boolean)
|
|
1098
|
+
.join("\n")
|
|
1099
|
+
.trim();
|
|
1100
|
+
return text;
|
|
1101
|
+
}
|
|
1102
|
+
extractCopilotAssistantText(entry) {
|
|
1103
|
+
if (entry.type !== "assistant.message") {
|
|
1104
|
+
return "";
|
|
1105
|
+
}
|
|
1106
|
+
const data = entry.data;
|
|
1107
|
+
if (!data || typeof data.content !== "string") {
|
|
1108
|
+
return "";
|
|
1109
|
+
}
|
|
1110
|
+
return data.content.trim();
|
|
1111
|
+
}
|
|
288
1112
|
async waitStreamStart(previousSnapshot) {
|
|
289
1113
|
const timeout = this.resolveTimeout(this.profile.timeouts?.streamStart, 10000);
|
|
290
|
-
const replyStartMatcher = this.profile.signals?.replyStart?.length
|
|
291
|
-
? Matchers_js_1.Matchers.anyOf(this.profile.signals.replyStart, "scrollback")
|
|
292
|
-
: null;
|
|
293
1114
|
if (this.profile.anchors.busy && this.profile.anchors.busy.length > 0) {
|
|
294
1115
|
const busyPatterns = this.profile.anchors.busy;
|
|
295
1116
|
const previousScrollback = previousSnapshot.scrollbackText;
|
|
296
1117
|
const startMatcher = (snapshot) => {
|
|
297
|
-
|
|
298
|
-
|
|
1118
|
+
this.assertAliveOrThrow();
|
|
1119
|
+
const added = this.getChangedTailLines(previousScrollback, snapshot.scrollbackText);
|
|
1120
|
+
if (this.anyAddedLineMatches(added, busyPatterns)) {
|
|
299
1121
|
return true;
|
|
300
1122
|
}
|
|
301
|
-
|
|
1123
|
+
const hasNewReplyStart = this.hasNewScrollbackPatternSince(previousScrollback, snapshot.scrollbackText, this.profile.signals?.replyStart);
|
|
1124
|
+
if (hasNewReplyStart) {
|
|
302
1125
|
this.log("waitStreamStart: replyStart detected before busy status");
|
|
303
1126
|
return true;
|
|
304
1127
|
}
|
|
1128
|
+
if (this.behavior.matchStreamStartFallback?.({
|
|
1129
|
+
previousSnapshot,
|
|
1130
|
+
snapshot,
|
|
1131
|
+
previousScrollback,
|
|
1132
|
+
addedLines: added,
|
|
1133
|
+
hasNewReplyStart,
|
|
1134
|
+
})) {
|
|
1135
|
+
this.log("waitStreamStart: matched backend fallback condition");
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
305
1138
|
return false;
|
|
306
1139
|
};
|
|
307
1140
|
const result = await this.expect.until({
|
|
@@ -321,35 +1154,79 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
321
1154
|
}
|
|
322
1155
|
}
|
|
323
1156
|
}
|
|
324
|
-
async waitStreamEnd() {
|
|
1157
|
+
async waitStreamEnd(turnStartSnapshot) {
|
|
325
1158
|
const idleMs = this.profile.timeouts?.idle ?? 800;
|
|
326
1159
|
const timeout = this.resolveTimeout(this.profile.timeouts?.streamEnd, 120000);
|
|
1160
|
+
const turnStartScrollback = turnStartSnapshot?.scrollbackText ?? "";
|
|
1161
|
+
const turnStartHash = turnStartSnapshot?.hash ?? "";
|
|
1162
|
+
const replyWaitStartAt = Date.now();
|
|
327
1163
|
const readyMatcher = Matchers_js_1.Matchers.anyOf(this.profile.anchors.ready);
|
|
328
1164
|
const busyMatcher = this.profile.anchors.busy?.length
|
|
329
1165
|
? Matchers_js_1.Matchers.anyOf(this.profile.anchors.busy)
|
|
330
1166
|
: null;
|
|
331
|
-
const
|
|
332
|
-
? Matchers_js_1.Matchers.anyOf(this.profile.signals.
|
|
333
|
-
: null;
|
|
334
|
-
const promptHintMatcher = this.profile.signals?.promptHint?.length
|
|
335
|
-
? Matchers_js_1.Matchers.anyOf(this.profile.signals.promptHint)
|
|
1167
|
+
const promptMatcher = this.profile.signals?.prompt?.length
|
|
1168
|
+
? Matchers_js_1.Matchers.anyOf(this.profile.signals.prompt)
|
|
336
1169
|
: null;
|
|
1170
|
+
const hasNewReplyStart = (snapshot) => this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.replyStart);
|
|
1171
|
+
const hasNewPromptHint = (snapshot) => this.hasNewScrollbackPatternSince(turnStartScrollback, snapshot.scrollbackText, this.profile.signals?.promptHint);
|
|
1172
|
+
const hasPromptHintSignal = Boolean(this.profile.signals?.promptHint?.length);
|
|
337
1173
|
const statusMatcher = this.profile.signals?.status?.length
|
|
338
1174
|
? Matchers_js_1.Matchers.anyOf(this.profile.signals.status)
|
|
339
1175
|
: null;
|
|
340
1176
|
const statusDoneMatcher = this.profile.signals?.statusDone?.length
|
|
341
1177
|
? Matchers_js_1.Matchers.anyOf(this.profile.signals.statusDone)
|
|
342
1178
|
: null;
|
|
1179
|
+
let sawBusyDuringWait = false;
|
|
1180
|
+
const hasAnyNewScrollbackLine = (snapshot) => {
|
|
1181
|
+
const added = this.getChangedTailLines(turnStartScrollback, snapshot.scrollbackText);
|
|
1182
|
+
return added.some((line) => line.trim().length > 0);
|
|
1183
|
+
};
|
|
343
1184
|
// 组合条件:屏幕 idle + ready anchor 出现 + busy anchor 消失
|
|
344
|
-
const
|
|
1185
|
+
const defaultCompleteMatcher = busyMatcher
|
|
345
1186
|
? Matchers_js_1.Matchers.and(readyMatcher, Matchers_js_1.Matchers.not(busyMatcher))
|
|
346
1187
|
: readyMatcher;
|
|
347
|
-
|
|
1188
|
+
const completeMatcher = this.behavior.buildStreamEndCompleteMatcher
|
|
1189
|
+
? this.behavior.buildStreamEndCompleteMatcher({
|
|
1190
|
+
readyMatcher,
|
|
1191
|
+
busyMatcher,
|
|
1192
|
+
defaultMatcher: defaultCompleteMatcher,
|
|
1193
|
+
})
|
|
1194
|
+
: defaultCompleteMatcher;
|
|
1195
|
+
if (this.profile.requireReplyStart && this.profile.signals?.replyStart?.length) {
|
|
348
1196
|
const replyOrHintResult = await this.expect.until({
|
|
349
1197
|
name: "STREAM_END_REPLY_OR_HINT",
|
|
350
|
-
match:
|
|
351
|
-
|
|
352
|
-
:
|
|
1198
|
+
match: Matchers_js_1.Matchers.custom((snapshot) => {
|
|
1199
|
+
this.assertAliveOrThrow();
|
|
1200
|
+
const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
|
|
1201
|
+
if (busyNow) {
|
|
1202
|
+
sawBusyDuringWait = true;
|
|
1203
|
+
}
|
|
1204
|
+
const fallbackMatched = this.behavior.matchStreamEndReplyFallback
|
|
1205
|
+
? this.behavior.matchStreamEndReplyFallback({
|
|
1206
|
+
snapshot,
|
|
1207
|
+
turnStartSnapshot,
|
|
1208
|
+
turnStartScrollback,
|
|
1209
|
+
turnStartHash,
|
|
1210
|
+
readyMatcher,
|
|
1211
|
+
busyMatcher,
|
|
1212
|
+
promptMatcher,
|
|
1213
|
+
sawBusyDuringWait,
|
|
1214
|
+
waitStartedAt: replyWaitStartAt,
|
|
1215
|
+
hasAnyNewScrollbackLine,
|
|
1216
|
+
})
|
|
1217
|
+
: false;
|
|
1218
|
+
if (fallbackMatched) {
|
|
1219
|
+
this.log("waitStreamEnd: matched backend reply fallback condition");
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
if (hasNewReplyStart(snapshot)) {
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
if (hasPromptHintSignal && hasNewPromptHint(snapshot)) {
|
|
1226
|
+
return true;
|
|
1227
|
+
}
|
|
1228
|
+
return false;
|
|
1229
|
+
}),
|
|
353
1230
|
stableMs: 200,
|
|
354
1231
|
timeoutMs: timeout,
|
|
355
1232
|
});
|
|
@@ -358,15 +1235,15 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
358
1235
|
}
|
|
359
1236
|
// Check if we matched promptHint but not replyStart - this indicates potential empty response
|
|
360
1237
|
const snapshot = this.screen.snapshot();
|
|
361
|
-
const hasReplyStart =
|
|
362
|
-
if (!hasReplyStart &&
|
|
363
|
-
const hasPromptHint =
|
|
1238
|
+
const hasReplyStart = hasNewReplyStart(snapshot);
|
|
1239
|
+
if (!hasReplyStart && hasPromptHintSignal) {
|
|
1240
|
+
const hasPromptHint = hasNewPromptHint(snapshot);
|
|
364
1241
|
if (hasPromptHint) {
|
|
365
1242
|
this.log(`waitStreamEnd: WARNING - promptHint matched but replyStart did not. Waiting for actual reply...`);
|
|
366
1243
|
// Wait a bit longer for the actual reply to appear
|
|
367
1244
|
const retryResult = await this.expect.until({
|
|
368
1245
|
name: "STREAM_END_REPLY_RETRY",
|
|
369
|
-
match:
|
|
1246
|
+
match: Matchers_js_1.Matchers.custom((nextSnapshot) => hasNewReplyStart(nextSnapshot)),
|
|
370
1247
|
stableMs: 200,
|
|
371
1248
|
timeoutMs: 10000, // Give it 10 more seconds
|
|
372
1249
|
});
|
|
@@ -375,13 +1252,31 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
375
1252
|
}
|
|
376
1253
|
}
|
|
377
1254
|
}
|
|
378
|
-
await this.waitForScrollbackIdle(idleMs, timeout)
|
|
1255
|
+
await this.waitForScrollbackIdle(idleMs, timeout, (snapshot) => {
|
|
1256
|
+
const hasPrompt = promptMatcher ? promptMatcher(snapshot) : readyMatcher(snapshot);
|
|
1257
|
+
const busyNow = busyMatcher ? busyMatcher(snapshot) : false;
|
|
1258
|
+
if (!hasPrompt || busyNow) {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
const currentSignals = this.getSignals(snapshot);
|
|
1262
|
+
return !currentSignals.replyInProgress;
|
|
1263
|
+
});
|
|
379
1264
|
if (statusMatcher) {
|
|
1265
|
+
const defaultStatusClearMatcher = statusDoneMatcher
|
|
1266
|
+
? Matchers_js_1.Matchers.or(Matchers_js_1.Matchers.not(statusMatcher), statusDoneMatcher)
|
|
1267
|
+
: Matchers_js_1.Matchers.not(statusMatcher);
|
|
1268
|
+
const statusClearMatcher = this.behavior.buildStreamEndStatusClearMatcher
|
|
1269
|
+
? this.behavior.buildStreamEndStatusClearMatcher({
|
|
1270
|
+
defaultMatcher: defaultStatusClearMatcher,
|
|
1271
|
+
getSignals: (snapshot) => this.getSignals(snapshot),
|
|
1272
|
+
})
|
|
1273
|
+
: defaultStatusClearMatcher;
|
|
380
1274
|
const statusClearResult = await this.expect.until({
|
|
381
1275
|
name: "STREAM_END_STATUS_CLEAR",
|
|
382
|
-
match:
|
|
383
|
-
|
|
384
|
-
|
|
1276
|
+
match: Matchers_js_1.Matchers.custom((snapshot) => {
|
|
1277
|
+
this.assertAliveOrThrow();
|
|
1278
|
+
return statusClearMatcher(snapshot);
|
|
1279
|
+
}),
|
|
385
1280
|
stableMs: 300,
|
|
386
1281
|
timeoutMs: timeout,
|
|
387
1282
|
});
|
|
@@ -405,7 +1300,10 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
405
1300
|
if (this.profile.requireReplyStart) {
|
|
406
1301
|
const readyResult = await this.expect.until({
|
|
407
1302
|
name: "STREAM_END_READY",
|
|
408
|
-
match:
|
|
1303
|
+
match: Matchers_js_1.Matchers.custom((snapshot) => {
|
|
1304
|
+
this.assertAliveOrThrow();
|
|
1305
|
+
return completeMatcher(snapshot);
|
|
1306
|
+
}),
|
|
409
1307
|
stableMs: 500,
|
|
410
1308
|
timeoutMs: 3000,
|
|
411
1309
|
});
|
|
@@ -416,7 +1314,10 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
416
1314
|
else {
|
|
417
1315
|
const readyResult = await this.expect.until({
|
|
418
1316
|
name: "STREAM_END_READY",
|
|
419
|
-
match:
|
|
1317
|
+
match: Matchers_js_1.Matchers.custom((snapshot) => {
|
|
1318
|
+
this.assertAliveOrThrow();
|
|
1319
|
+
return completeMatcher(snapshot);
|
|
1320
|
+
}),
|
|
420
1321
|
stableMs: 500,
|
|
421
1322
|
timeoutMs: 10000,
|
|
422
1323
|
});
|
|
@@ -483,12 +1384,88 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
483
1384
|
}
|
|
484
1385
|
async restart() {
|
|
485
1386
|
this.log("Restarting PTY...");
|
|
1387
|
+
const restartSession = await this.resolveRestartSessionInfo();
|
|
1388
|
+
const restartArgs = this.resolveRestartArgs(restartSession?.sessionId);
|
|
1389
|
+
if (restartSession?.sessionId) {
|
|
1390
|
+
this.log(`restart resume target: backend=${this.profile.name} session=${restartSession.sessionId} args=${JSON.stringify(restartArgs)}`);
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
this.log(`restart without resume: backend=${this.profile.name} args=${JSON.stringify(restartArgs)}`);
|
|
1394
|
+
}
|
|
486
1395
|
this.pty.kill();
|
|
1396
|
+
this.pty.setCommandArgs(this.initialCommand, restartArgs);
|
|
487
1397
|
this.screen.reset();
|
|
488
1398
|
this.isBooted = false;
|
|
489
1399
|
await this.sleep(500);
|
|
490
1400
|
await this.boot();
|
|
491
1401
|
}
|
|
1402
|
+
async resolveRestartSessionInfo() {
|
|
1403
|
+
const cached = this.sessionInfo ?? this.lastSessionInfo;
|
|
1404
|
+
if (cached?.sessionId) {
|
|
1405
|
+
return { ...cached };
|
|
1406
|
+
}
|
|
1407
|
+
if (!this.supportsSessionFileTracking()) {
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
const detected = await this.detectSessionInfoByBackend();
|
|
1411
|
+
if (!detected?.sessionId) {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
this.lastSessionInfo = detected;
|
|
1415
|
+
return { ...detected };
|
|
1416
|
+
}
|
|
1417
|
+
resolveRestartArgs(sessionId) {
|
|
1418
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1419
|
+
if (!normalizedSessionId) {
|
|
1420
|
+
return [...this.initialArgs];
|
|
1421
|
+
}
|
|
1422
|
+
const baseArgs = this.stripResumeArgs(this.initialArgs, this.profile.name);
|
|
1423
|
+
const resumeArgs = this.buildResumeArgsForBackend(this.profile.name, normalizedSessionId);
|
|
1424
|
+
if (resumeArgs.length === 0) {
|
|
1425
|
+
return [...this.initialArgs];
|
|
1426
|
+
}
|
|
1427
|
+
return [...baseArgs, ...resumeArgs];
|
|
1428
|
+
}
|
|
1429
|
+
stripResumeArgs(args, backendName) {
|
|
1430
|
+
const result = [];
|
|
1431
|
+
const backend = String(backendName || "").toLowerCase();
|
|
1432
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1433
|
+
const current = String(args[index] || "");
|
|
1434
|
+
const next = args[index + 1];
|
|
1435
|
+
if (current === "--resume") {
|
|
1436
|
+
index += 1;
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
if (current.startsWith("--resume=")) {
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if ((backend === "codex" || backend === "code") && current === "resume") {
|
|
1443
|
+
if (typeof next === "string" && next.length > 0) {
|
|
1444
|
+
index += 1;
|
|
1445
|
+
}
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
result.push(current);
|
|
1449
|
+
}
|
|
1450
|
+
return result;
|
|
1451
|
+
}
|
|
1452
|
+
buildResumeArgsForBackend(backendName, sessionId) {
|
|
1453
|
+
const normalizedBackend = String(backendName || "").toLowerCase();
|
|
1454
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
1455
|
+
if (!normalizedSessionId) {
|
|
1456
|
+
return [];
|
|
1457
|
+
}
|
|
1458
|
+
if (normalizedBackend === "codex" || normalizedBackend === "code") {
|
|
1459
|
+
return ["resume", normalizedSessionId];
|
|
1460
|
+
}
|
|
1461
|
+
if (normalizedBackend === "claude-code" || normalizedBackend === "claude") {
|
|
1462
|
+
return ["--resume", normalizedSessionId];
|
|
1463
|
+
}
|
|
1464
|
+
if (normalizedBackend === "copilot") {
|
|
1465
|
+
return [`--resume=${normalizedSessionId}`];
|
|
1466
|
+
}
|
|
1467
|
+
return [];
|
|
1468
|
+
}
|
|
492
1469
|
captureSnapshot(label) {
|
|
493
1470
|
const snapshot = this.screen.snapshot();
|
|
494
1471
|
if (this.onSnapshot) {
|
|
@@ -620,9 +1597,24 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
620
1597
|
this.log(`getSignals: DEBUG scrollback tail (last 30 lines):\n${lastLines.map((l, i) => ` [${lines.length - 30 + i}] "${l}"`).join('\n')}`);
|
|
621
1598
|
}
|
|
622
1599
|
const promptLine = this.findLastMatch(lines, signals.prompt);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
1600
|
+
const signalScopeLines = this.behavior.getSignalScopeLines
|
|
1601
|
+
? this.behavior.getSignalScopeLines({
|
|
1602
|
+
lines,
|
|
1603
|
+
signals,
|
|
1604
|
+
helpers: { findLastMatch: this.findLastMatch.bind(this) },
|
|
1605
|
+
})
|
|
1606
|
+
: lines;
|
|
1607
|
+
const defaultStatusLine = this.findLastMatch(signalScopeLines, signals.status);
|
|
1608
|
+
const statusLine = this.behavior.resolveStatusLine
|
|
1609
|
+
? this.behavior.resolveStatusLine({
|
|
1610
|
+
lines: signalScopeLines,
|
|
1611
|
+
signals,
|
|
1612
|
+
defaultStatusLine,
|
|
1613
|
+
helpers: { findLastMatch: this.findLastMatch.bind(this) },
|
|
1614
|
+
})
|
|
1615
|
+
: defaultStatusLine;
|
|
1616
|
+
const statusDoneLine = this.findLastMatch(signalScopeLines, signals.statusDone);
|
|
1617
|
+
const reply = this.extractReplyBlocks(signalScopeLines, signals);
|
|
626
1618
|
this.log(`getSignals: promptLine="${promptLine || '(none)'}" statusLine="${statusLine || '(none)'}" reply.text="${reply.text?.slice(0, 100) || '(none)'}" reply.blocks=${reply.blocks.length}`);
|
|
627
1619
|
return {
|
|
628
1620
|
hasPrompt: Boolean(promptLine),
|
|
@@ -640,10 +1632,26 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
640
1632
|
async write(data) {
|
|
641
1633
|
this.pty.write(data);
|
|
642
1634
|
}
|
|
1635
|
+
async forceRestart() {
|
|
1636
|
+
await this.restart();
|
|
1637
|
+
}
|
|
643
1638
|
kill() {
|
|
1639
|
+
this.isKilled = true;
|
|
644
1640
|
this.pty.kill();
|
|
645
1641
|
this.screen.dispose();
|
|
646
1642
|
this.isBooted = false;
|
|
1643
|
+
this.sessionInfo = null;
|
|
1644
|
+
this.lastSessionInfo = null;
|
|
1645
|
+
}
|
|
1646
|
+
createSessionClosedError() {
|
|
1647
|
+
const error = new Error("TUI session closed");
|
|
1648
|
+
error.reason = "session_closed";
|
|
1649
|
+
return error;
|
|
1650
|
+
}
|
|
1651
|
+
assertAliveOrThrow() {
|
|
1652
|
+
if (this.isKilled || !this.running) {
|
|
1653
|
+
throw this.createSessionClosedError();
|
|
1654
|
+
}
|
|
647
1655
|
}
|
|
648
1656
|
sleep(ms) {
|
|
649
1657
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -651,24 +1659,61 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
651
1659
|
terminateSessionForLoginRequired() {
|
|
652
1660
|
this.pty.kill();
|
|
653
1661
|
this.isBooted = false;
|
|
1662
|
+
this.sessionInfo = null;
|
|
1663
|
+
this.lastSessionInfo = null;
|
|
654
1664
|
}
|
|
655
1665
|
resolveTimeout(timeoutMs, defaultTimeoutMs) {
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
1666
|
+
const fallback = this.normalizeTimeoutValue(defaultTimeoutMs, Math.max(MIN_STAGE_TIMEOUT_MS, defaultTimeoutMs));
|
|
1667
|
+
return this.normalizeTimeoutValue(timeoutMs, fallback);
|
|
1668
|
+
}
|
|
1669
|
+
normalizeTimeoutValue(timeoutMs, fallback) {
|
|
1670
|
+
const parsed = Number(timeoutMs);
|
|
1671
|
+
if (!Number.isFinite(parsed)) {
|
|
1672
|
+
return fallback;
|
|
659
1673
|
}
|
|
660
|
-
|
|
1674
|
+
// `0` means "disable hard timeout" for long-running turns.
|
|
1675
|
+
if (parsed === 0) {
|
|
1676
|
+
return this.resolveMaxStageTimeoutMs();
|
|
1677
|
+
}
|
|
1678
|
+
if (parsed < 0) {
|
|
1679
|
+
return fallback;
|
|
1680
|
+
}
|
|
1681
|
+
const bounded = Math.max(MIN_STAGE_TIMEOUT_MS, Math.round(parsed));
|
|
1682
|
+
return Math.min(bounded, this.resolveMaxStageTimeoutMs());
|
|
661
1683
|
}
|
|
662
|
-
|
|
1684
|
+
resolveMaxStageTimeoutMs() {
|
|
1685
|
+
const raw = process.env.CONDUCTOR_TUI_MAX_TIMEOUT_MS;
|
|
1686
|
+
const parsed = Number.parseInt(String(raw || ""), 10);
|
|
1687
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1688
|
+
return DEFAULT_STAGE_TIMEOUT_MAX_MS;
|
|
1689
|
+
}
|
|
1690
|
+
return Math.min(Math.max(parsed, MIN_STAGE_TIMEOUT_MS), ABSOLUTE_STAGE_TIMEOUT_MAX_MS);
|
|
1691
|
+
}
|
|
1692
|
+
async waitForScrollbackIdle(idleMs, timeoutMs, completionHint) {
|
|
663
1693
|
const startTime = Date.now();
|
|
664
1694
|
let lastHash = ScreenSnapshot_js_1.ScreenSnapshot.computeHash(this.screen.snapshot().scrollbackText);
|
|
665
1695
|
let lastChangeTime = Date.now();
|
|
1696
|
+
let hintStableSince = null;
|
|
1697
|
+
const boundedTimeoutMs = Number.isFinite(timeoutMs) ? timeoutMs : 60000;
|
|
666
1698
|
while (true) {
|
|
1699
|
+
this.assertAliveOrThrow();
|
|
667
1700
|
const elapsed = Date.now() - startTime;
|
|
668
|
-
if (elapsed >=
|
|
1701
|
+
if (elapsed >= boundedTimeoutMs) {
|
|
669
1702
|
throw new Error("Stream end timeout: scrollback did not become idle");
|
|
670
1703
|
}
|
|
671
|
-
const
|
|
1704
|
+
const snapshot = this.screen.snapshot();
|
|
1705
|
+
if (completionHint && completionHint(snapshot)) {
|
|
1706
|
+
if (hintStableSince === null) {
|
|
1707
|
+
hintStableSince = Date.now();
|
|
1708
|
+
}
|
|
1709
|
+
else if (Date.now() - hintStableSince >= idleMs) {
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
hintStableSince = null;
|
|
1715
|
+
}
|
|
1716
|
+
const currentHash = ScreenSnapshot_js_1.ScreenSnapshot.computeHash(snapshot.scrollbackText);
|
|
672
1717
|
if (currentHash !== lastHash) {
|
|
673
1718
|
lastHash = currentHash;
|
|
674
1719
|
lastChangeTime = Date.now();
|
|
@@ -691,6 +1736,41 @@ class TuiDriver extends events_1.EventEmitter {
|
|
|
691
1736
|
}
|
|
692
1737
|
return null;
|
|
693
1738
|
}
|
|
1739
|
+
anyAddedLineMatches(lines, patterns) {
|
|
1740
|
+
if (!patterns || patterns.length === 0 || lines.length === 0) {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
return lines.some((line) => this.lineMatchesAny(line, patterns));
|
|
1744
|
+
}
|
|
1745
|
+
lineMatchesAny(line, patterns) {
|
|
1746
|
+
for (const pattern of patterns) {
|
|
1747
|
+
pattern.lastIndex = 0;
|
|
1748
|
+
if (pattern.test(line)) {
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
hasNewScrollbackPatternSince(previousScrollback, currentScrollback, patterns) {
|
|
1755
|
+
if (!patterns || patterns.length === 0) {
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
const added = this.getChangedTailLines(previousScrollback, currentScrollback);
|
|
1759
|
+
return this.anyAddedLineMatches(added, patterns);
|
|
1760
|
+
}
|
|
1761
|
+
getChangedTailLines(previousScrollback, currentScrollback) {
|
|
1762
|
+
const previousLines = previousScrollback.split("\n");
|
|
1763
|
+
const currentLines = currentScrollback.split("\n");
|
|
1764
|
+
const minLength = Math.min(previousLines.length, currentLines.length);
|
|
1765
|
+
let prefixLength = 0;
|
|
1766
|
+
while (prefixLength < minLength && previousLines[prefixLength] === currentLines[prefixLength]) {
|
|
1767
|
+
prefixLength += 1;
|
|
1768
|
+
}
|
|
1769
|
+
if (prefixLength >= currentLines.length) {
|
|
1770
|
+
return [];
|
|
1771
|
+
}
|
|
1772
|
+
return currentLines.slice(prefixLength);
|
|
1773
|
+
}
|
|
694
1774
|
extractReplyBlocks(lines, signals) {
|
|
695
1775
|
const startPatterns = signals.replyStart ?? [];
|
|
696
1776
|
if (startPatterns.length === 0) {
|