@love-moon/conductor-cli 0.2.15 → 0.2.16

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.
@@ -12,16 +12,20 @@ import { createHash } from "node:crypto";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import process from "node:process";
15
- import readline from "node:readline";
16
15
  import { setTimeout as delay } from "node:timers/promises";
17
16
  import { fileURLToPath } from "node:url";
18
17
 
19
18
  import yargs from "yargs/yargs";
20
19
  import { hideBin } from "yargs/helpers";
21
20
  import yaml from "js-yaml";
22
- import { TuiDriver, claudeCodeProfile, codexProfile, copilotProfile } from "@love-moon/tui-driver";
21
+ import { createAiSession } from "@love-moon/ai-sdk";
23
22
  import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
24
- import { findSessionPath } from "../src/fire/history.js";
23
+ import {
24
+ buildResumeArgsForBackend as buildCliResumeArgsForBackend,
25
+ resolveResumeContext as resolveCliResumeContext,
26
+ resolveSessionRunDirectory as resolveCliSessionRunDirectory,
27
+ resumeProviderForBackend as resumeProviderForCliBackend,
28
+ } from "../src/fire/resume.js";
25
29
 
26
30
  const __filename = fileURLToPath(import.meta.url);
27
31
  const __dirname = path.dirname(__filename);
@@ -54,59 +58,10 @@ function loadAllowCliList(configFilePath) {
54
58
  return {};
55
59
  }
56
60
 
57
- // Load envs config from config file
58
- function loadEnvConfig(configFilePath) {
59
- try {
60
- const home = os.homedir();
61
- const configPath = configFilePath || process.env.CONDUCTOR_CONFIG || path.join(home, ".conductor", "config.yaml");
62
- if (fs.existsSync(configPath)) {
63
- const content = fs.readFileSync(configPath, "utf8");
64
- const parsed = yaml.load(content);
65
- if (parsed && typeof parsed === "object" && parsed.envs) {
66
- return parsed.envs;
67
- }
68
- }
69
- } catch (error) {
70
- // ignore error
71
- }
72
- return null;
73
- }
74
-
75
- // Convert proxy env values to standard proxy environment variables
76
- function proxyToEnv(envConfig) {
77
- if (!envConfig || typeof envConfig !== "object") {
78
- return {};
79
- }
80
- const env = {};
81
- // Support both snake_case and lowercase keys
82
- const mappings = {
83
- http_proxy: ["HTTP_PROXY", "http_proxy"],
84
- https_proxy: ["HTTPS_PROXY", "https_proxy"],
85
- all_proxy: ["ALL_PROXY", "all_proxy"],
86
- no_proxy: ["NO_PROXY", "no_proxy"],
87
- };
88
- for (const [key, envKeys] of Object.entries(mappings)) {
89
- const value = envConfig[key] || envConfig[key.toUpperCase()];
90
- if (value) {
91
- for (const envKey of envKeys) {
92
- env[envKey] = value;
93
- }
94
- }
95
- }
96
- return env;
97
- }
98
-
99
- // Get CLI command from environment variable (set by daemon) or from config
100
- const CUSTOM_CLI_COMMAND = process.env.CONDUCTOR_CLI_COMMAND;
101
- const DEFAULT_ALLOW_CLI_LIST = loadAllowCliList();
102
-
103
61
  const DEFAULT_POLL_INTERVAL_MS = parseInt(
104
62
  process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
105
63
  10,
106
64
  );
107
- const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
108
- const MIN_TURN_DEADLINE_MS = 30 * 1000;
109
- const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
110
65
  const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
111
66
  const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
112
67
  const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
@@ -368,32 +323,14 @@ async function main() {
368
323
  }
369
324
  }
370
325
 
371
- let resolvedResumeSessionId = cliArgs.resumeSessionId;
372
- if (!resolvedResumeSessionId) {
373
- try {
374
- const localTaskRecord = await conductor.getLocalTaskRecord({
375
- task_id: taskContext.taskId,
376
- });
377
- const taskSessionFilePath = typeof localTaskRecord?.session_file_path === "string"
378
- ? localTaskRecord.session_file_path.trim()
379
- : "";
380
- const taskSessionId = typeof localTaskRecord?.session_id === "string"
381
- ? localTaskRecord.session_id.trim()
382
- : "";
383
- if (taskSessionId && taskSessionFilePath) {
384
- resolvedResumeSessionId = taskSessionId;
385
- log(`Using bound session ${taskSessionId} for task ${taskContext.taskId}`);
386
- }
387
- } catch {
388
- // no local task binding yet; start a fresh backend session
389
- }
390
- }
326
+ const resolvedResumeSessionId = cliArgs.resumeSessionId;
391
327
 
392
- backendSession = new TuiDriverSession(cliArgs.backend, {
328
+ backendSession = createAiSession(cliArgs.backend, {
393
329
  initialImages: cliArgs.initialImages,
394
330
  cwd: runtimeProjectPath,
395
331
  resumeSessionId: resolvedResumeSessionId,
396
332
  configFile: cliArgs.configFile,
333
+ logger: { log },
397
334
  });
398
335
 
399
336
  log(`Using backend: ${cliArgs.backend}`);
@@ -417,7 +354,7 @@ async function main() {
417
354
  includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
418
355
  cliArgs: cliArgs.rawBackendArgs,
419
356
  backendName: cliArgs.backend,
420
- resumeMode: Boolean(resolvedResumeSessionId),
357
+ resumeSessionId: resolvedResumeSessionId,
421
358
  daemonName: configuredDaemonName,
422
359
  });
423
360
  reconnectRunner = runner;
@@ -897,75 +834,16 @@ function deriveTaskTitle(prompt, explicit, backend = "codex") {
897
834
  return `${backendName} Task`;
898
835
  }
899
836
 
900
- const BACKEND_PROFILE_MAP = {
901
- codex: "codex",
902
- code: "codex",
903
- claude: "claude-code",
904
- "claude-code": "claude-code",
905
- copilot: "copilot",
906
- };
907
-
908
- function profileNameForBackend(backend) {
909
- const normalized = String(backend || "").trim().toLowerCase();
910
- return BACKEND_PROFILE_MAP[normalized] || null;
911
- }
912
-
913
- function parseCommandParts(commandLine) {
914
- const normalized = String(commandLine || "").trim();
915
- if (!normalized) {
916
- return { command: "", args: [] };
917
- }
918
- const parts = normalized.split(/\s+/);
919
- return {
920
- command: parts[0],
921
- args: parts.slice(1),
922
- };
923
- }
924
-
925
837
  export function buildResumeArgsForBackend(backend, sessionId) {
926
- const resumeSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
927
- if (!resumeSessionId) {
928
- return [];
929
- }
930
- const normalizedBackend = String(backend || "").trim().toLowerCase();
931
- if (normalizedBackend === "codex" || normalizedBackend === "code") {
932
- return ["resume", resumeSessionId];
933
- }
934
- if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
935
- return ["--resume", resumeSessionId];
936
- }
937
- if (normalizedBackend === "copilot") {
938
- return [`--resume=${resumeSessionId}`];
939
- }
940
- throw new Error(`--resume is not supported for backend "${backend}"`);
838
+ return buildCliResumeArgsForBackend(backend, sessionId);
941
839
  }
942
840
 
943
841
  export function resumeProviderForBackend(backend) {
944
- const normalizedBackend = String(backend || "").trim().toLowerCase();
945
- if (normalizedBackend === "codex" || normalizedBackend === "code") {
946
- return "codex";
947
- }
948
- if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
949
- return "claude";
950
- }
951
- if (normalizedBackend === "copilot") {
952
- return "copilot";
953
- }
954
- return null;
842
+ return resumeProviderForCliBackend(backend);
955
843
  }
956
844
 
957
845
  export async function resolveSessionRunDirectory(sessionPath) {
958
- const normalizedPath = typeof sessionPath === "string" ? sessionPath.trim() : "";
959
- if (!normalizedPath) {
960
- throw new Error("Invalid session path");
961
- }
962
- let stats;
963
- try {
964
- stats = await fs.promises.stat(normalizedPath);
965
- } catch {
966
- throw new Error(`Session path does not exist: ${normalizedPath}`);
967
- }
968
- return stats.isDirectory() ? normalizedPath : path.dirname(normalizedPath);
846
+ return resolveCliSessionRunDirectory(sessionPath);
969
847
  }
970
848
 
971
849
  async function isExistingDirectory(targetPath) {
@@ -981,151 +859,8 @@ async function isExistingDirectory(targetPath) {
981
859
  }
982
860
  }
983
861
 
984
- async function extractCodexResumeCwd(sessionPath) {
985
- if (!sessionPath.endsWith(".jsonl")) {
986
- return null;
987
- }
988
- const rl = readline.createInterface({
989
- input: fs.createReadStream(sessionPath),
990
- crlfDelay: Infinity,
991
- });
992
- for await (const line of rl) {
993
- const trimmed = line.trim();
994
- if (!trimmed) {
995
- continue;
996
- }
997
- let entry;
998
- try {
999
- entry = JSON.parse(trimmed);
1000
- } catch {
1001
- continue;
1002
- }
1003
- const maybeCwd = entry?.type === "session_meta" ? entry?.payload?.cwd : null;
1004
- if (typeof maybeCwd === "string" && maybeCwd.trim()) {
1005
- return maybeCwd.trim();
1006
- }
1007
- }
1008
- return null;
1009
- }
1010
-
1011
- async function extractClaudeResumeCwd(sessionPath, sessionId) {
1012
- if (!sessionPath.endsWith(".jsonl")) {
1013
- return null;
1014
- }
1015
- const rl = readline.createInterface({
1016
- input: fs.createReadStream(sessionPath),
1017
- crlfDelay: Infinity,
1018
- });
1019
- for await (const line of rl) {
1020
- const trimmed = line.trim();
1021
- if (!trimmed) {
1022
- continue;
1023
- }
1024
- let entry;
1025
- try {
1026
- entry = JSON.parse(trimmed);
1027
- } catch {
1028
- continue;
1029
- }
1030
- const idMatches = String(entry?.sessionId || "").trim() === sessionId;
1031
- const maybeCwd = entry?.cwd;
1032
- if (idMatches && typeof maybeCwd === "string" && maybeCwd.trim()) {
1033
- return maybeCwd.trim();
1034
- }
1035
- }
1036
- return null;
1037
- }
1038
-
1039
- async function extractCopilotResumeCwd(sessionPath) {
1040
- let stats;
1041
- try {
1042
- stats = await fs.promises.stat(sessionPath);
1043
- } catch {
1044
- return null;
1045
- }
1046
-
1047
- if (stats.isDirectory()) {
1048
- const workspaceYamlPath = path.join(sessionPath, "workspace.yaml");
1049
- try {
1050
- const yamlContent = await fs.promises.readFile(workspaceYamlPath, "utf8");
1051
- const parsed = yaml.load(yamlContent);
1052
- const maybeCwd = parsed && typeof parsed === "object" ? parsed.cwd : null;
1053
- if (typeof maybeCwd === "string" && maybeCwd.trim()) {
1054
- return maybeCwd.trim();
1055
- }
1056
- } catch {
1057
- return null;
1058
- }
1059
- return null;
1060
- }
1061
-
1062
- if (!sessionPath.endsWith(".jsonl")) {
1063
- return null;
1064
- }
1065
-
1066
- const rl = readline.createInterface({
1067
- input: fs.createReadStream(sessionPath),
1068
- crlfDelay: Infinity,
1069
- });
1070
- for await (const line of rl) {
1071
- const trimmed = line.trim();
1072
- if (!trimmed) {
1073
- continue;
1074
- }
1075
- let entry;
1076
- try {
1077
- entry = JSON.parse(trimmed);
1078
- } catch {
1079
- continue;
1080
- }
1081
- const maybeCwd = entry?.data?.context?.cwd || entry?.data?.cwd;
1082
- if (typeof maybeCwd === "string" && maybeCwd.trim()) {
1083
- return maybeCwd.trim();
1084
- }
1085
- }
1086
- return null;
1087
- }
1088
-
1089
- async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
1090
- if (provider === "codex") {
1091
- return extractCodexResumeCwd(sessionPath);
1092
- }
1093
- if (provider === "claude") {
1094
- return extractClaudeResumeCwd(sessionPath, sessionId);
1095
- }
1096
- if (provider === "copilot") {
1097
- return extractCopilotResumeCwd(sessionPath);
1098
- }
1099
- return null;
1100
- }
1101
-
1102
862
  export async function resolveResumeContext(backend, sessionId, options = {}) {
1103
- const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
1104
- if (!normalizedSessionId) {
1105
- throw new Error("--resume requires a session id");
1106
- }
1107
- const provider = resumeProviderForBackend(backend);
1108
- if (!provider) {
1109
- throw new Error(`--resume is not supported for backend "${backend}"`);
1110
- }
1111
-
1112
- const sessionPath = await findSessionPath(provider, normalizedSessionId, options);
1113
- if (!sessionPath) {
1114
- throw new Error(`Invalid --resume session id for ${provider}: ${normalizedSessionId}`);
1115
- }
1116
-
1117
- const cwdFromSession = await extractResumeCwdFromSession(provider, sessionPath, normalizedSessionId);
1118
- const fallbackCwd = await resolveSessionRunDirectory(sessionPath);
1119
- const cwd = cwdFromSession || fallbackCwd;
1120
- if (!(await isExistingDirectory(cwd))) {
1121
- throw new Error(`Resume workspace path does not exist: ${cwd}`);
1122
- }
1123
- return {
1124
- provider,
1125
- sessionId: normalizedSessionId,
1126
- sessionPath,
1127
- cwd,
1128
- };
863
+ return resolveCliResumeContext(backend, sessionId, options);
1129
864
  }
1130
865
 
1131
866
  export async function applyWorkingDirectory(targetPath) {
@@ -1199,866 +934,6 @@ function normalizeExecutionErrorKey(errorMessage) {
1199
934
  return normalized;
1200
935
  }
1201
936
 
1202
- function tailLines(value, count = 6) {
1203
- if (!value) return "";
1204
- const lines = String(value).split(/\r?\n/);
1205
- return lines.slice(Math.max(0, lines.length - Math.max(1, count))).join("\n");
1206
- }
1207
-
1208
- class TuiDriverSession {
1209
- constructor(backend, options = {}) {
1210
- this.backend = backend;
1211
- this.options = options;
1212
- this.cwd =
1213
- typeof options.cwd === "string" && options.cwd.trim()
1214
- ? options.cwd.trim()
1215
- : INITIAL_CLI_PROJECT_PATH;
1216
- const resumeSessionId = typeof options.resumeSessionId === "string" ? options.resumeSessionId.trim() : "";
1217
- this.sessionId = resumeSessionId || `${backend}-${Date.now()}`;
1218
- this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
1219
- this.pendingHistorySeed = this.history.length > 0;
1220
- this.sessionInfo = null;
1221
-
1222
- const allowCliList = options.configFile ? loadAllowCliList(options.configFile) : DEFAULT_ALLOW_CLI_LIST;
1223
- const cliCommand = CUSTOM_CLI_COMMAND || allowCliList[backend] || backend;
1224
- const { command, args } = parseCommandParts(cliCommand);
1225
- if (!command) {
1226
- throw new Error(`Invalid command for backend "${backend}"`);
1227
- }
1228
- const resumeArgs = buildResumeArgsForBackend(backend, resumeSessionId);
1229
- this.command = command;
1230
- this.args = [...args, ...resumeArgs];
1231
- this.tuiDebug = isTruthyEnv(process.env.CONDUCTOR_TUI_DEBUG);
1232
- this.tuiTrace = this.tuiDebug || isTruthyEnv(process.env.CONDUCTOR_TUI_TRACE);
1233
- this.tuiTraceLines = Number.isFinite(Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES || "", 10))
1234
- ? Math.max(2, Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES, 10))
1235
- : 8;
1236
- this.lastSignalSignature = "";
1237
- this.lastPollSignature = "";
1238
- this.lastSnapshotHash = "";
1239
- this.closeRequested = false;
1240
- this.closed = false;
1241
- this.closeWaiters = new Set();
1242
- this.sessionMessageHandler = null;
1243
- this.sessionMonitorPromise = null;
1244
- this.sessionMonitorStopRequested = false;
1245
- this.sessionMonitorCursor = 0;
1246
- this.sessionMonitorSessionId = "";
1247
- this.sessionMonitorSessionFilePath = "";
1248
- this.sessionMonitorActiveReplyTo = "";
1249
- this.sessionMonitorHasActiveReplyTarget = false;
1250
- this.sessionMonitorLastReplyTo = "";
1251
- this.sessionMonitorAwaitingFirstReply = false;
1252
- this.workingStatusHandler = null;
1253
- this.workingStatusMonitorPromise = null;
1254
- this.workingStatusMonitorStopRequested = false;
1255
- this.lastReportedWorkingStatusLine = "";
1256
- this.turnDeadlineMs = getBoundedEnvInt(
1257
- "CONDUCTOR_TURN_DEADLINE_MS",
1258
- DEFAULT_TURN_DEADLINE_MS,
1259
- MIN_TURN_DEADLINE_MS,
1260
- MAX_TURN_DEADLINE_MS,
1261
- );
1262
-
1263
- const profileName = profileNameForBackend(backend);
1264
- if (!profileName) {
1265
- throw new Error(`Backend "${backend}" is not supported by tui-driver`);
1266
- }
1267
- this.useSessionFileReplyStream =
1268
- profileName === "codex" || profileName === "copilot";
1269
- this.sessionMonitorFastPollMs = getBoundedEnvInt(
1270
- "CONDUCTOR_CODEX_SESSION_FAST_POLL_MS",
1271
- 700,
1272
- 100,
1273
- 10 * 1000,
1274
- );
1275
- this.sessionMonitorSlowPollMs = getBoundedEnvInt(
1276
- "CONDUCTOR_CODEX_SESSION_SLOW_POLL_MS",
1277
- 2500,
1278
- 300,
1279
- 60 * 1000,
1280
- );
1281
- this.workingStatusPollMs = getBoundedEnvInt(
1282
- "CONDUCTOR_CODEX_TUI_STATUS_POLL_MS",
1283
- 700,
1284
- 100,
1285
- 10 * 1000,
1286
- );
1287
-
1288
- const profileMap = {
1289
- codex: codexProfile,
1290
- "claude-code": claudeCodeProfile,
1291
- copilot: copilotProfile,
1292
- };
1293
- const baseProfile = profileMap[profileName];
1294
- const effectiveDriverArgs = [...(baseProfile.args || []), ...this.args];
1295
- const envConfig = loadEnvConfig(options.configFile);
1296
- const proxyEnv = proxyToEnv(envConfig);
1297
- const cliEnv = envConfig && typeof envConfig === "object" ? { ...envConfig, ...proxyEnv } : proxyEnv;
1298
-
1299
- if (Object.keys(proxyEnv).length > 0) {
1300
- log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
1301
- }
1302
-
1303
- log(`Using TUI command for ${backend}: ${[this.command, ...effectiveDriverArgs].join(" ")} (cwd: ${this.cwd})`);
1304
- if (this.tuiTrace) {
1305
- log(
1306
- `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(effectiveDriverArgs)}`,
1307
- );
1308
- log(
1309
- `[${this.backend}] [tui-trace] timeouts=${JSON.stringify(baseProfile.timeouts || {})} size=${baseProfile.cols || 120}x${baseProfile.rows || 40}`,
1310
- );
1311
- }
1312
-
1313
- this.driver = new TuiDriver({
1314
- profile: {
1315
- ...baseProfile,
1316
- command: this.command,
1317
- args: effectiveDriverArgs,
1318
- env: {
1319
- ...process.env,
1320
- ...(baseProfile.env || {}),
1321
- ...cliEnv,
1322
- },
1323
- },
1324
- cwd: this.cwd,
1325
- expectedSessionId: resumeSessionId || undefined,
1326
- debug: this.tuiDebug,
1327
- onSnapshot: this.tuiTrace
1328
- ? (snapshot, state) => {
1329
- this.logSnapshot(state, snapshot);
1330
- }
1331
- : undefined,
1332
- onSignals: this.tuiTrace
1333
- ? (signals, snapshot, state) => {
1334
- this.logSignals(state, signals, snapshot);
1335
- }
1336
- : undefined,
1337
- });
1338
-
1339
- // 监听登录需求事件
1340
- this.driver.on("login_required", (health) => {
1341
- log(`[${this.backend}] [WARN] Login required detected: ${health.message || health.reason}`);
1342
- if (health.matchedPattern) {
1343
- log(`[${this.backend}] [WARN] Matched pattern: "${health.matchedPattern}"`);
1344
- }
1345
- log(`[${this.backend}] [WARN] Please run "${this.command} login" or authenticate manually.`);
1346
- });
1347
-
1348
- this.driver.on("session", (session) => {
1349
- this.applySessionInfo(session);
1350
- });
1351
- }
1352
-
1353
- get threadId() {
1354
- return this.sessionId;
1355
- }
1356
-
1357
- get threadOptions() {
1358
- return { model: this.backend };
1359
- }
1360
-
1361
- applySessionInfo(session) {
1362
- if (!session || typeof session !== "object") {
1363
- return;
1364
- }
1365
- const sessionId = typeof session.sessionId === "string" ? session.sessionId.trim() : "";
1366
- const sessionFilePath =
1367
- typeof session.sessionFilePath === "string" ? session.sessionFilePath.trim() : "";
1368
- if (!sessionId) {
1369
- return;
1370
- }
1371
- this.sessionId = sessionId;
1372
- this.sessionInfo = {
1373
- backend: this.backend,
1374
- sessionId,
1375
- sessionFilePath: sessionFilePath || undefined,
1376
- };
1377
- this.trace(
1378
- `session id=${sessionId} file="${sanitizeForLog(sessionFilePath, 180)}"`,
1379
- );
1380
- if (this.useSessionFileReplyStream) {
1381
- void this.ensureSessionFileMonitor();
1382
- }
1383
- }
1384
-
1385
- getSessionInfo() {
1386
- if (this.sessionInfo) {
1387
- return { ...this.sessionInfo };
1388
- }
1389
- return null;
1390
- }
1391
-
1392
- async ensureSessionInfo() {
1393
- if (!this.driver) {
1394
- return null;
1395
- }
1396
- try {
1397
- await this.driver.boot();
1398
- } catch (error) {
1399
- this.trace(`session boot failed: ${sanitizeForLog(error?.message || error, 180)}`);
1400
- return this.getSessionInfo();
1401
- }
1402
-
1403
- try {
1404
- if (typeof this.driver.ensureSessionInfo === "function") {
1405
- const detected = await this.driver.ensureSessionInfo();
1406
- this.applySessionInfo(detected);
1407
- } else if (typeof this.driver.getSessionInfo === "function") {
1408
- this.applySessionInfo(this.driver.getSessionInfo());
1409
- }
1410
- } catch (error) {
1411
- this.trace(`session detect failed: ${sanitizeForLog(error?.message || error, 180)}`);
1412
- }
1413
-
1414
- return this.getSessionInfo();
1415
- }
1416
-
1417
- async getSessionUsageSummary() {
1418
- if (!this.driver || typeof this.driver.getSessionUsageSummary !== "function") {
1419
- return null;
1420
- }
1421
- try {
1422
- const summary = await this.driver.getSessionUsageSummary();
1423
- return summary && typeof summary === "object" ? summary : null;
1424
- } catch (error) {
1425
- this.trace(`session usage detect failed: ${sanitizeForLog(error?.message || error, 180)}`);
1426
- return null;
1427
- }
1428
- }
1429
-
1430
- usesSessionFileReplyStream() {
1431
- return Boolean(this.useSessionFileReplyStream);
1432
- }
1433
-
1434
- setSessionMessageHandler(handler) {
1435
- this.sessionMessageHandler = typeof handler === "function" ? handler : null;
1436
- if (this.useSessionFileReplyStream) {
1437
- void this.ensureSessionFileMonitor();
1438
- }
1439
- }
1440
-
1441
- setWorkingStatusHandler(handler) {
1442
- this.workingStatusHandler = typeof handler === "function" ? handler : null;
1443
- if (this.useSessionFileReplyStream) {
1444
- void this.ensureWorkingStatusMonitor();
1445
- }
1446
- }
1447
-
1448
- setSessionReplyTarget(replyTo) {
1449
- if (!this.useSessionFileReplyStream) {
1450
- return;
1451
- }
1452
- const normalizedReplyTo = typeof replyTo === "string" ? replyTo.trim() : "";
1453
- this.sessionMonitorActiveReplyTo = normalizedReplyTo;
1454
- this.sessionMonitorHasActiveReplyTarget = true;
1455
- if (normalizedReplyTo) {
1456
- this.sessionMonitorLastReplyTo = normalizedReplyTo;
1457
- }
1458
- this.sessionMonitorAwaitingFirstReply = true;
1459
- void this.ensureSessionFileMonitor();
1460
- }
1461
-
1462
- async ensureSessionFileMonitor() {
1463
- if (!this.useSessionFileReplyStream || this.sessionMonitorPromise) {
1464
- return;
1465
- }
1466
- this.sessionMonitorStopRequested = false;
1467
- const monitorPromise = this.runSessionFileMonitor();
1468
- this.sessionMonitorPromise = monitorPromise;
1469
- monitorPromise.catch((error) => {
1470
- this.trace(`session monitor exited: ${sanitizeForLog(error?.message || error, 180)}`);
1471
- });
1472
- }
1473
-
1474
- async ensureWorkingStatusMonitor() {
1475
- if (!this.useSessionFileReplyStream || this.workingStatusMonitorPromise) {
1476
- return;
1477
- }
1478
- this.workingStatusMonitorStopRequested = false;
1479
- const monitorPromise = this.runWorkingStatusMonitor();
1480
- this.workingStatusMonitorPromise = monitorPromise;
1481
- monitorPromise.catch((error) => {
1482
- this.trace(`working status monitor exited: ${sanitizeForLog(error?.message || error, 180)}`);
1483
- });
1484
- }
1485
-
1486
- async runSessionFileMonitor() {
1487
- try {
1488
- while (!this.closeRequested && !this.sessionMonitorStopRequested) {
1489
- try {
1490
- await this.pollSessionFileMessages();
1491
- } catch (error) {
1492
- this.trace(`session monitor poll failed: ${sanitizeForLog(error?.message || error, 180)}`);
1493
- }
1494
- await delay(this.resolveSessionMonitorPollMs());
1495
- }
1496
- } finally {
1497
- this.sessionMonitorPromise = null;
1498
- }
1499
- }
1500
-
1501
- async runWorkingStatusMonitor() {
1502
- try {
1503
- while (!this.closeRequested && !this.workingStatusMonitorStopRequested) {
1504
- try {
1505
- await this.pollWorkingStatus();
1506
- } catch (error) {
1507
- this.trace(`working status monitor poll failed: ${sanitizeForLog(error?.message || error, 180)}`);
1508
- }
1509
- await delay(this.workingStatusPollMs);
1510
- }
1511
- } finally {
1512
- this.workingStatusMonitorPromise = null;
1513
- }
1514
- }
1515
-
1516
- resolveSessionMonitorPollMs() {
1517
- return this.sessionMonitorAwaitingFirstReply
1518
- ? this.sessionMonitorFastPollMs
1519
- : this.sessionMonitorSlowPollMs;
1520
- }
1521
-
1522
- normalizeCodexWorkingStatusLine(statusLine) {
1523
- const normalized = typeof statusLine === "string" ? statusLine.trim() : "";
1524
- if (!normalized) {
1525
- return "";
1526
- }
1527
- if (/^\s*[•◦]\s*Working\b/i.test(normalized)) {
1528
- return normalized;
1529
- }
1530
- if (/\bWorking\s*\([^)]*\)/i.test(normalized)) {
1531
- return normalized;
1532
- }
1533
- return "";
1534
- }
1535
-
1536
- normalizeCopilotWorkingStatusLine(statusLine) {
1537
- const normalized = typeof statusLine === "string" ? statusLine.trim() : "";
1538
- if (!normalized) {
1539
- return "";
1540
- }
1541
- // Copilot busy lines are prefixed with spinner-like bullets and include "(Esc to cancel ...)".
1542
- if (/^\s*[∙◉◎◐◑◒◓◔◕●○•◦]\s*.+Esc to cancel/i.test(normalized)) {
1543
- return normalized;
1544
- }
1545
- if (/^\s*.+Esc to cancel/i.test(normalized)) {
1546
- return normalized;
1547
- }
1548
- return "";
1549
- }
1550
-
1551
- normalizeWorkingStatusLine(statusLine) {
1552
- if (this.backend === "copilot") {
1553
- return this.normalizeCopilotWorkingStatusLine(statusLine);
1554
- }
1555
- return this.normalizeCodexWorkingStatusLine(statusLine);
1556
- }
1557
-
1558
- getCurrentReplyTarget() {
1559
- if (this.sessionMonitorHasActiveReplyTarget) {
1560
- return this.sessionMonitorActiveReplyTo || undefined;
1561
- }
1562
- return this.sessionMonitorLastReplyTo || undefined;
1563
- }
1564
-
1565
- async pollWorkingStatus() {
1566
- if (!this.useSessionFileReplyStream || !this.driver || !this.driver.running) {
1567
- return;
1568
- }
1569
- const signals = this.driver.getSignals();
1570
- const workingStatusLine = this.normalizeWorkingStatusLine(signals.statusLine);
1571
- if (workingStatusLine === this.lastReportedWorkingStatusLine) {
1572
- return;
1573
- }
1574
- this.lastReportedWorkingStatusLine = workingStatusLine;
1575
- if (typeof this.workingStatusHandler !== "function") {
1576
- return;
1577
- }
1578
- if (workingStatusLine) {
1579
- await this.workingStatusHandler({
1580
- phase: "working_status_monitor",
1581
- source: "tui-driver",
1582
- reply_in_progress: true,
1583
- status_line: workingStatusLine,
1584
- replyTo: this.getCurrentReplyTarget(),
1585
- });
1586
- return;
1587
- }
1588
- await this.workingStatusHandler({
1589
- phase: "working_status_clear",
1590
- source: "tui-driver",
1591
- reply_in_progress: false,
1592
- replyTo: this.getCurrentReplyTarget(),
1593
- });
1594
- }
1595
-
1596
- async pollSessionFileMessages() {
1597
- if (!this.useSessionFileReplyStream || !this.driver) {
1598
- return;
1599
- }
1600
- const sessionInfo = this.getSessionInfo();
1601
- if (!sessionInfo?.sessionId || !sessionInfo?.sessionFilePath) {
1602
- return;
1603
- }
1604
-
1605
- const sessionId = String(sessionInfo.sessionId).trim();
1606
- const sessionFilePath = String(sessionInfo.sessionFilePath).trim();
1607
- if (!sessionId || !sessionFilePath) {
1608
- return;
1609
- }
1610
-
1611
- const sessionChanged =
1612
- sessionId !== this.sessionMonitorSessionId ||
1613
- sessionFilePath !== this.sessionMonitorSessionFilePath;
1614
- if (sessionChanged) {
1615
- this.sessionMonitorSessionId = sessionId;
1616
- this.sessionMonitorSessionFilePath = sessionFilePath;
1617
- this.sessionMonitorCursor = this.sessionMonitorAwaitingFirstReply
1618
- ? 0
1619
- : await this.driver.getSessionFileSize(sessionInfo);
1620
- this.trace(
1621
- `session monitor bound id=${sessionId} file="${sanitizeForLog(sessionFilePath, 180)}" cursor=${this.sessionMonitorCursor}`,
1622
- );
1623
- }
1624
-
1625
- const batch = await this.driver.readSessionAssistantMessagesSince(
1626
- sessionInfo,
1627
- this.sessionMonitorCursor,
1628
- );
1629
- this.sessionMonitorCursor = Number.isFinite(batch?.nextOffset)
1630
- ? batch.nextOffset
1631
- : this.sessionMonitorCursor;
1632
-
1633
- const messages = Array.isArray(batch?.messages) ? batch.messages : [];
1634
- if (messages.length === 0) {
1635
- return;
1636
- }
1637
-
1638
- for (const message of messages) {
1639
- const text = typeof message?.text === "string" ? message.text.trim() : "";
1640
- if (!text) {
1641
- continue;
1642
- }
1643
- this.history.push({ role: "assistant", content: text });
1644
- this.sessionMonitorAwaitingFirstReply = false;
1645
- if (typeof this.sessionMessageHandler !== "function") {
1646
- continue;
1647
- }
1648
- await this.sessionMessageHandler({
1649
- ...message,
1650
- replyTo: this.sessionMonitorHasActiveReplyTarget
1651
- ? this.sessionMonitorActiveReplyTo || undefined
1652
- : this.sessionMonitorLastReplyTo || undefined,
1653
- });
1654
- }
1655
- }
1656
-
1657
- createSessionClosedError() {
1658
- const error = new Error("TUI session closed");
1659
- error.reason = "session_closed";
1660
- return error;
1661
- }
1662
-
1663
- createTurnTimeoutError(timeoutMs) {
1664
- const seconds = Math.max(1, Math.round(timeoutMs / 1000));
1665
- const error = new Error(`Turn exceeded hard deadline (${seconds}s)`);
1666
- error.reason = "turn_timeout";
1667
- error.timeoutMs = timeoutMs;
1668
- return error;
1669
- }
1670
-
1671
- createCloseGuard() {
1672
- if (this.closeRequested) {
1673
- return {
1674
- promise: Promise.reject(this.createSessionClosedError()),
1675
- cleanup: () => {},
1676
- };
1677
- }
1678
- let waiter = null;
1679
- const promise = new Promise((_, reject) => {
1680
- waiter = () => {
1681
- reject(this.createSessionClosedError());
1682
- };
1683
- this.closeWaiters.add(waiter);
1684
- });
1685
- return {
1686
- promise,
1687
- cleanup: () => {
1688
- if (waiter) {
1689
- this.closeWaiters.delete(waiter);
1690
- }
1691
- },
1692
- };
1693
- }
1694
-
1695
- createTurnTimeoutGuard() {
1696
- if (!Number.isFinite(this.turnDeadlineMs) || this.turnDeadlineMs <= 0) {
1697
- return {
1698
- promise: new Promise(() => {}),
1699
- cleanup: () => {},
1700
- };
1701
- }
1702
- let timer = null;
1703
- const promise = new Promise((_, reject) => {
1704
- timer = setTimeout(() => {
1705
- reject(this.createTurnTimeoutError(this.turnDeadlineMs));
1706
- }, this.turnDeadlineMs);
1707
- if (typeof timer.unref === "function") {
1708
- timer.unref();
1709
- }
1710
- });
1711
- return {
1712
- promise,
1713
- cleanup: () => {
1714
- if (timer) {
1715
- clearTimeout(timer);
1716
- }
1717
- },
1718
- };
1719
- }
1720
-
1721
- flushCloseWaiters() {
1722
- if (!this.closeWaiters || this.closeWaiters.size === 0) {
1723
- return;
1724
- }
1725
- for (const waiter of this.closeWaiters) {
1726
- try {
1727
- waiter();
1728
- } catch {
1729
- // best effort
1730
- }
1731
- }
1732
- this.closeWaiters.clear();
1733
- }
1734
-
1735
- async close() {
1736
- if (this.closed) {
1737
- return;
1738
- }
1739
- this.closed = true;
1740
- this.closeRequested = true;
1741
- this.sessionMonitorStopRequested = true;
1742
- this.workingStatusMonitorStopRequested = true;
1743
- this.flushCloseWaiters();
1744
- if (this.driver) {
1745
- this.driver.kill();
1746
- }
1747
- }
1748
-
1749
- /**
1750
- * 获取当前健康状态
1751
- * @returns {Object} 健康状态对象 { healthy, reason, message, matchedPattern }
1752
- */
1753
- getHealthStatus() {
1754
- if (!this.driver) {
1755
- return { healthy: false, reason: "not_initialized", message: "Driver not initialized" };
1756
- }
1757
- return this.driver.healthCheck();
1758
- }
1759
-
1760
- buildPrompt(promptText, { useInitialImages = false } = {}) {
1761
- let effectivePrompt = String(promptText || "").trim();
1762
- if (!effectivePrompt) {
1763
- return "";
1764
- }
1765
-
1766
- if (this.pendingHistorySeed) {
1767
- const historyText = this.history
1768
- .map((item) => {
1769
- const role = String(item?.role || "").toLowerCase() === "assistant" ? "Assistant" : "User";
1770
- return `${role}: ${String(item?.content || "").trim()}`;
1771
- })
1772
- .filter(Boolean)
1773
- .join("\n\n");
1774
- if (historyText) {
1775
- effectivePrompt = [
1776
- "Continue the existing conversation with this history.",
1777
- "",
1778
- historyText,
1779
- "",
1780
- `User: ${effectivePrompt}`,
1781
- ].join("\n");
1782
- }
1783
- this.pendingHistorySeed = false;
1784
- }
1785
-
1786
- const images = Array.isArray(this.options.initialImages) ? this.options.initialImages : [];
1787
- if (useInitialImages && images.length > 0) {
1788
- const imageContext = images.map((item, idx) => `${idx + 1}. ${item}`).join("\n");
1789
- effectivePrompt = `${effectivePrompt}\n\nAttached image files:\n${imageContext}`;
1790
- }
1791
-
1792
- return effectivePrompt;
1793
- }
1794
-
1795
- emitProgress(onProgress, payload) {
1796
- if (typeof onProgress !== "function") {
1797
- return;
1798
- }
1799
- try {
1800
- onProgress(payload);
1801
- } catch {
1802
- // best effort
1803
- }
1804
- }
1805
-
1806
- trace(message) {
1807
- if (!this.tuiTrace) {
1808
- return;
1809
- }
1810
- log(`[${this.backend}] [tui-trace] ${message}`);
1811
- }
1812
-
1813
- formatSignalSummary(signals = {}) {
1814
- return {
1815
- prompt: sanitizeForLog(signals.promptLine || "", 100) || undefined,
1816
- replyInProgress: Boolean(signals.replyInProgress),
1817
- status: sanitizeForLog(signals.statusLine || "", 140) || undefined,
1818
- done: sanitizeForLog(signals.statusDoneLine || "", 140) || undefined,
1819
- replyPreview: sanitizeForLog(signals.replyText || "", 180) || undefined,
1820
- blocks: Array.isArray(signals.replyBlocks) ? signals.replyBlocks.length : 0,
1821
- };
1822
- }
1823
-
1824
- logSignals(state, signals, snapshot) {
1825
- const summary = this.formatSignalSummary(signals);
1826
- const signature = JSON.stringify(summary);
1827
- if (signature === this.lastSignalSignature) {
1828
- return;
1829
- }
1830
- this.lastSignalSignature = signature;
1831
- this.trace(
1832
- `signals state=${state} hash=${snapshot?.hash || "n/a"} prompt="${summary.prompt || ""}" status="${summary.status || ""}" done="${summary.done || ""}" replyInProgress=${summary.replyInProgress} blocks=${summary.blocks} preview="${summary.replyPreview || ""}"`,
1833
- );
1834
- }
1835
-
1836
- logSnapshot(state, snapshot) {
1837
- if (!snapshot || snapshot.hash === this.lastSnapshotHash) {
1838
- return;
1839
- }
1840
- this.lastSnapshotHash = snapshot.hash;
1841
- const viewportTail = sanitizeForLog(tailLines(snapshot.viewportText, this.tuiTraceLines), 400);
1842
- this.trace(
1843
- `snapshot state=${state} hash=${snapshot.hash} cursor=${snapshot.cursor?.x || 0},${snapshot.cursor?.y || 0} tail="${viewportTail}"`,
1844
- );
1845
- }
1846
-
1847
- async runTurn(promptText, { useInitialImages = false, onProgress } = {}) {
1848
- if (this.closeRequested) {
1849
- throw this.createSessionClosedError();
1850
- }
1851
-
1852
- const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
1853
- if (!effectivePrompt) {
1854
- return {
1855
- text: "",
1856
- usage: null,
1857
- items: [],
1858
- events: [],
1859
- };
1860
- }
1861
-
1862
- log(`[${this.backend}] Running prompt: ${truncateText(effectivePrompt, 100)}...`);
1863
- this.trace(`runTurn start promptLen=${effectivePrompt.length} useInitialImages=${Boolean(useInitialImages)}`);
1864
- const useSessionFileReplyStream = this.usesSessionFileReplyStream();
1865
-
1866
- const handleStateChange = (transition) => {
1867
- this.trace(`state ${transition.from} -> ${transition.to}`);
1868
- if (useSessionFileReplyStream) {
1869
- return;
1870
- }
1871
- this.emitProgress(onProgress, {
1872
- state: transition.to,
1873
- phase: "state_change",
1874
- source: "tui-driver",
1875
- });
1876
- };
1877
-
1878
- this.driver.on("stateChange", handleStateChange);
1879
-
1880
- const signalTimer = setInterval(() => {
1881
- const signals = this.driver.getSignals();
1882
- if (this.tuiTrace) {
1883
- const pollSummary = this.formatSignalSummary(signals);
1884
- const pollSignature = JSON.stringify({
1885
- state: this.driver.state,
1886
- replyInProgress: pollSummary.replyInProgress,
1887
- status: pollSummary.status,
1888
- done: pollSummary.done,
1889
- preview: pollSummary.replyPreview,
1890
- });
1891
- if (pollSignature !== this.lastPollSignature) {
1892
- this.lastPollSignature = pollSignature;
1893
- this.trace(
1894
- `poll state=${this.driver.state} replyInProgress=${pollSummary.replyInProgress} status="${pollSummary.status || ""}" done="${pollSummary.done || ""}" preview="${pollSummary.replyPreview || ""}"`,
1895
- );
1896
- }
1897
- }
1898
- if (useSessionFileReplyStream) {
1899
- return;
1900
- }
1901
- this.emitProgress(onProgress, {
1902
- state: this.driver.state,
1903
- phase: "signal_poll",
1904
- source: "tui-driver",
1905
- reply_in_progress: Boolean(signals.replyInProgress),
1906
- status_line: signals.statusLine || undefined,
1907
- status_done_line: signals.statusDoneLine || undefined,
1908
- reply_preview: truncateText(signals.replyText || "", 240) || undefined,
1909
- });
1910
- }, 700);
1911
- if (typeof signalTimer.unref === "function") {
1912
- signalTimer.unref();
1913
- }
1914
-
1915
- const previousCwd = process.cwd();
1916
- const shouldSwitchCwd = this.cwd && this.cwd !== previousCwd;
1917
- if (shouldSwitchCwd) {
1918
- try {
1919
- process.chdir(this.cwd);
1920
- } catch (error) {
1921
- throw new Error(`Failed to switch backend cwd to ${this.cwd}: ${error?.message || error}`);
1922
- }
1923
- }
1924
- const closeGuard = this.createCloseGuard();
1925
- const turnTimeoutGuard = this.createTurnTimeoutGuard();
1926
- if (useSessionFileReplyStream) {
1927
- this.history.push({ role: "user", content: promptText });
1928
- void this.ensureSessionFileMonitor();
1929
- void this.ensureWorkingStatusMonitor();
1930
- }
1931
-
1932
- try {
1933
- const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise, turnTimeoutGuard.promise]);
1934
- const answer = String(result.answer || result.replyText || "").trim();
1935
- this.trace(
1936
- `runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
1937
- );
1938
-
1939
- if (!useSessionFileReplyStream) {
1940
- this.history.push({ role: "user", content: promptText });
1941
- }
1942
- if (answer && !useSessionFileReplyStream) {
1943
- this.history.push({ role: "assistant", content: answer });
1944
- }
1945
-
1946
- if (!useSessionFileReplyStream) {
1947
- this.emitProgress(onProgress, {
1948
- state: result.success ? "DONE" : "ERROR",
1949
- phase: "turn_result",
1950
- source: "tui-driver",
1951
- reply_in_progress: false,
1952
- status_line: result.statusLine || undefined,
1953
- status_done_line: result.statusDoneLine || undefined,
1954
- reply_preview: truncateText(result.replyText || answer, 240) || undefined,
1955
- });
1956
- }
1957
-
1958
- if (!result.success) {
1959
- const error = result.error || new Error("tui-driver failed to complete this turn");
1960
- throw error;
1961
- }
1962
-
1963
- log(`[${this.backend}] Response received: ${truncateText(answer, 100)}...`);
1964
- this.trace(`runTurn reply preview="${sanitizeForLog(answer, 220)}"`);
1965
-
1966
- return {
1967
- text: answer,
1968
- usage: null,
1969
- items: [],
1970
- events: [],
1971
- provider: this.backend,
1972
- metadata: {
1973
- source: "tui-driver",
1974
- elapsed_ms: result.elapsedMs,
1975
- signals: result.signals ?? null,
1976
- },
1977
- };
1978
- } catch (error) {
1979
- const errorMessage = error instanceof Error ? error.message : String(error);
1980
- const errorReason = error?.reason || "unknown";
1981
- if (errorReason === "session_closed") {
1982
- this.trace("runTurn interrupted because backend session is closing");
1983
- throw error instanceof Error ? error : new Error(errorMessage);
1984
- }
1985
-
1986
- if (errorReason === "turn_timeout") {
1987
- this.emitProgress(onProgress, {
1988
- state: "ERROR",
1989
- phase: "timeout_recovered",
1990
- source: "tui-driver",
1991
- error: errorMessage,
1992
- reason: errorReason,
1993
- timeout_ms: error?.timeoutMs,
1994
- });
1995
- log(`[${this.backend}] Turn timed out (${error?.timeoutMs || this.turnDeadlineMs}ms), restarting TUI session`);
1996
- try {
1997
- await this.driver.forceRestart();
1998
- } catch (restartError) {
1999
- log(`[${this.backend}] Failed to restart TUI after timeout: ${restartError?.message || restartError}`);
2000
- }
2001
- log(`[${this.backend}] Error: ${errorMessage}`);
2002
- } else if (errorReason === "login_required") {
2003
- this.emitProgress(onProgress, {
2004
- state: "ERROR",
2005
- phase: "login_required",
2006
- source: "tui-driver",
2007
- error: errorMessage,
2008
- reason: errorReason,
2009
- matched_pattern: error?.matchedPattern,
2010
- });
2011
- log(`[${this.backend}] Login required: ${errorMessage}`);
2012
- log(`[${this.backend}] Please run "${this.command} login" or authenticate manually.`);
2013
- } else if (errorReason === "permission_required") {
2014
- this.emitProgress(onProgress, {
2015
- state: "ERROR",
2016
- phase: "permission_required",
2017
- source: "tui-driver",
2018
- error: errorMessage,
2019
- reason: errorReason,
2020
- matched_pattern: error?.matchedPattern,
2021
- });
2022
- log(`[${this.backend}] Permission required: ${errorMessage}`);
2023
- } else {
2024
- this.emitProgress(onProgress, {
2025
- state: "ERROR",
2026
- phase: "exception",
2027
- source: "tui-driver",
2028
- error: errorMessage,
2029
- reason: errorReason,
2030
- });
2031
- log(`[${this.backend}] Error: ${errorMessage}`);
2032
- }
2033
-
2034
- let latestSignals = {};
2035
- try {
2036
- latestSignals = this.driver.getSignals();
2037
- } catch {
2038
- // driver may already be disposed while closing
2039
- }
2040
- const summary = this.formatSignalSummary(latestSignals);
2041
- this.trace(
2042
- `runTurn exception state=${this.driver.state} error="${sanitizeForLog(errorMessage, 220)}" status="${summary.status || ""}" done="${summary.done || ""}" preview="${summary.replyPreview || ""}"`,
2043
- );
2044
- throw error instanceof Error ? error : new Error(errorMessage);
2045
- } finally {
2046
- if (shouldSwitchCwd) {
2047
- try {
2048
- process.chdir(previousCwd);
2049
- } catch (error) {
2050
- log(`Failed to restore cwd to ${previousCwd}: ${error?.message || error}`);
2051
- }
2052
- }
2053
- clearInterval(signalTimer);
2054
- this.driver.off("stateChange", handleStateChange);
2055
- turnTimeoutGuard.cleanup();
2056
- closeGuard.cleanup();
2057
- this.trace(`runTurn cleanup state=${this.driver.state}`);
2058
- }
2059
- }
2060
- }
2061
-
2062
937
  export class BridgeRunner {
2063
938
  constructor({
2064
939
  backendSession,
@@ -2069,7 +944,7 @@ export class BridgeRunner {
2069
944
  includeInitialImages,
2070
945
  cliArgs,
2071
946
  backendName,
2072
- resumeMode,
947
+ resumeSessionId,
2073
948
  daemonName,
2074
949
  }) {
2075
950
  this.backendSession = backendSession;
@@ -2080,7 +955,10 @@ export class BridgeRunner {
2080
955
  this.includeInitialImages = includeInitialImages;
2081
956
  this.cliArgs = cliArgs;
2082
957
  this.backendName = backendName || "codex";
2083
- this.resumeMode = Boolean(resumeMode);
958
+ this.resumeSessionId =
959
+ typeof resumeSessionId === "string" && resumeSessionId.trim()
960
+ ? resumeSessionId.trim()
961
+ : "";
2084
962
  this.isCopilotBackend = String(this.backendName).toLowerCase() === "copilot";
2085
963
  this.copilotDebug =
2086
964
  this.isCopilotBackend &&
@@ -2089,6 +967,7 @@ export class BridgeRunner {
2089
967
  this.runningTurn = false;
2090
968
  this.processedMessageIds = new Set();
2091
969
  this.inFlightMessageIds = new Set();
970
+ this.sessionStreamReplyCounts = new Map();
2092
971
  this.lastRuntimeStatusSignature = null;
2093
972
  this.lastRuntimeStatusPayload = null;
2094
973
  this.runtimeContextSnapshot = null;
@@ -2172,9 +1051,7 @@ export class BridgeRunner {
2172
1051
  return;
2173
1052
  }
2174
1053
  const discoveredSessionId = String(sessionInfo?.sessionId || "").trim();
2175
- const fallbackSessionId = this.resumeMode
2176
- ? String(this.backendSession.threadId || "").trim()
2177
- : "";
1054
+ const fallbackSessionId = this.resumeSessionId;
2178
1055
  const sessionId = discoveredSessionId || fallbackSessionId;
2179
1056
  const sessionFilePath = sessionInfo?.sessionFilePath ? String(sessionInfo.sessionFilePath).trim() : "";
2180
1057
  const hasRealSessionId = Boolean(sessionId);
@@ -2207,13 +1084,15 @@ export class BridgeRunner {
2207
1084
  {
2208
1085
  state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
2209
1086
  phase: "session_started",
2210
- source: "tui-driver",
1087
+ source: "ai-sdk",
2211
1088
  reply_in_progress: false,
2212
1089
  status_done_line: this.useSessionFileReplyStream
2213
1090
  ? undefined
2214
1091
  : `${this.backendName} session started`,
2215
1092
  backend: this.backendName,
2216
1093
  thread_id: hasRealSessionId ? sessionId : undefined,
1094
+ session_id: hasRealSessionId ? sessionId : undefined,
1095
+ session_file_path: sessionFilePath || undefined,
2217
1096
  },
2218
1097
  undefined,
2219
1098
  );
@@ -2244,13 +1123,6 @@ export class BridgeRunner {
2244
1123
  if (this.stopped) {
2245
1124
  return;
2246
1125
  }
2247
- if (this.resumeMode) {
2248
- await this.drainBufferedMessagesForResume();
2249
- }
2250
- if (this.stopped) {
2251
- return;
2252
- }
2253
-
2254
1126
  while (!this.stopped) {
2255
1127
  if (this.needsReconnectRecovery && !this.runningTurn) {
2256
1128
  await this.recoverAfterReconnect();
@@ -2325,41 +1197,6 @@ export class BridgeRunner {
2325
1197
  await this.replayLastRuntimeStatus();
2326
1198
  }
2327
1199
 
2328
- async drainBufferedMessagesForResume() {
2329
- let drainedCount = 0;
2330
- let drainedBatches = 0;
2331
-
2332
- while (!this.stopped) {
2333
- const result = await this.conductor.receiveMessages(this.taskId, 50);
2334
- const messages = Array.isArray(result?.messages) ? result.messages : [];
2335
- if (messages.length === 0) {
2336
- break;
2337
- }
2338
-
2339
- drainedBatches += 1;
2340
- for (const message of messages) {
2341
- const replyTo = message?.message_id ? String(message.message_id) : "";
2342
- if (replyTo) {
2343
- this.processedMessageIds.add(replyTo);
2344
- }
2345
- drainedCount += 1;
2346
- }
2347
-
2348
- const ackToken = result.next_ack_token || result.nextAckToken;
2349
- if (ackToken) {
2350
- await this.conductor.ackMessages(this.taskId, ackToken);
2351
- }
2352
- }
2353
-
2354
- if (drainedCount > 0) {
2355
- log(
2356
- `Resume startup skipped ${drainedCount} buffered message(s) in ${drainedBatches} batch(es) for task ${this.taskId}`,
2357
- );
2358
- } else {
2359
- this.copilotLog("resume startup found no buffered messages to skip");
2360
- }
2361
- }
2362
-
2363
1200
  async processIncomingBatch() {
2364
1201
  const result = await this.conductor.receiveMessages(this.taskId, 20);
2365
1202
  const messages = Array.isArray(result?.messages) ? result.messages : [];
@@ -2457,15 +1294,6 @@ export class BridgeRunner {
2457
1294
  .filter((item) => String(item?.role || "").toLowerCase() === "user")
2458
1295
  .filter((item) => typeof item?.content === "string" && item.content.trim());
2459
1296
 
2460
- if (this.resumeMode) {
2461
- for (const historyId of historyUserIds) {
2462
- this.processedMessageIds.add(historyId);
2463
- }
2464
- this.copilotLog(
2465
- `resume mode: seeded processed ids=${historyUserIds.length}, skip startup backfill replay`,
2466
- );
2467
- return;
2468
- }
2469
1297
  this.copilotLog(
2470
1298
  `backfill loaded history=${history.length} handledUsers=${handledUserIds.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
2471
1299
  );
@@ -2574,7 +1402,7 @@ export class BridgeRunner {
2574
1402
  return {
2575
1403
  state: state || undefined,
2576
1404
  phase: phase || undefined,
2577
- source: "tui-driver",
1405
+ source: payload.source ? String(payload.source) : "ai-sdk",
2578
1406
  reply_in_progress: Boolean(payload.reply_in_progress),
2579
1407
  status_line: statusLine || undefined,
2580
1408
  status_done_line: statusDoneLine || undefined,
@@ -2583,12 +1411,13 @@ export class BridgeRunner {
2583
1411
  backend: this.backendName,
2584
1412
  thread_id:
2585
1413
  String(
2586
- payload.thread_id || runtimeContext?.session_id || "",
1414
+ payload.thread_id || payload.session_id || runtimeContext?.session_id || "",
2587
1415
  ).trim() || undefined,
2588
1416
  daemon: runtimeContext?.daemon,
2589
1417
  pid: runtimeContext?.pid,
2590
- session_id: runtimeContext?.session_id,
2591
- session_file_path: runtimeContext?.session_file_path,
1418
+ session_id:
1419
+ String(payload.session_id || runtimeContext?.session_id || "").trim() || undefined,
1420
+ session_file_path: payload.session_file_path || runtimeContext?.session_file_path,
2592
1421
  token_usage_percent: runtimeContext?.token_usage_percent,
2593
1422
  context_usage_percent: runtimeContext?.context_usage_percent,
2594
1423
  };
@@ -2643,8 +1472,10 @@ export class BridgeRunner {
2643
1472
  if (this.stopped) {
2644
1473
  return;
2645
1474
  }
2646
- const text = typeof payload?.text === "string" ? payload.text.trim() : "";
2647
- if (!text) {
1475
+ const preserveWhitespace = Boolean(payload?.preserveWhitespace);
1476
+ const rawText = typeof payload?.text === "string" ? payload.text : "";
1477
+ const text = preserveWhitespace ? rawText : rawText.trim();
1478
+ if (!text || (!preserveWhitespace && !text.trim())) {
2648
1479
  return;
2649
1480
  }
2650
1481
 
@@ -2653,11 +1484,32 @@ export class BridgeRunner {
2653
1484
  const sessionFilePath =
2654
1485
  typeof payload?.sessionFilePath === "string" ? payload.sessionFilePath.trim() : "";
2655
1486
 
2656
- logBackendReply(this.backendName, text, {
1487
+ await this.sendSessionStreamMessage({
1488
+ text,
1489
+ replyTo,
1490
+ sessionId,
1491
+ sessionFilePath,
1492
+ timestamp: payload?.timestamp,
1493
+ });
1494
+ }
1495
+
1496
+ async sendSessionStreamMessage({
1497
+ text,
1498
+ replyTo,
1499
+ sessionId,
1500
+ sessionFilePath,
1501
+ timestamp,
1502
+ }) {
1503
+ const normalizedText = typeof text === "string" ? text : "";
1504
+ if (!normalizedText) {
1505
+ return;
1506
+ }
1507
+
1508
+ logBackendReply(this.backendName, normalizedText, {
2657
1509
  usage: null,
2658
1510
  replyTo: replyTo || "latest",
2659
1511
  });
2660
- await this.conductor.sendMessage(this.taskId, text, {
1512
+ await this.conductor.sendMessage(this.taskId, normalizedText, {
2661
1513
  model: this.backendSession.threadOptions?.model || this.backendName,
2662
1514
  backend: this.backendName,
2663
1515
  thread_id: sessionId || this.backendSession.threadId,
@@ -2666,10 +1518,15 @@ export class BridgeRunner {
2666
1518
  reply_to: replyTo || undefined,
2667
1519
  cli_args: this.cliArgs,
2668
1520
  session_stream: true,
2669
- timestamp: payload?.timestamp || undefined,
1521
+ timestamp: timestamp || undefined,
2670
1522
  });
1523
+ const replyKey = this.normalizeSessionStreamReplyKey(replyTo);
1524
+ this.sessionStreamReplyCounts.set(
1525
+ replyKey,
1526
+ Number(this.sessionStreamReplyCounts.get(replyKey) || 0) + 1,
1527
+ );
2671
1528
  this.copilotLog(
2672
- `session_file sdk_message sent replyTo=${replyTo || "latest"} responseLen=${text.length}`,
1529
+ `session_file sdk_message sent replyTo=${replyTo || "latest"} responseLen=${normalizedText.length}`,
2673
1530
  );
2674
1531
  }
2675
1532
 
@@ -2677,6 +1534,55 @@ export class BridgeRunner {
2677
1534
  await this.reportRuntimeStatus(payload, payload?.replyTo);
2678
1535
  }
2679
1536
 
1537
+ normalizeSessionStreamReplyKey(replyTo) {
1538
+ const normalizedReplyTo = typeof replyTo === "string" ? replyTo.trim() : "";
1539
+ return normalizedReplyTo || "__latest__";
1540
+ }
1541
+
1542
+ getSessionStreamReplyCount(replyTo) {
1543
+ return Number(
1544
+ this.sessionStreamReplyCounts.get(this.normalizeSessionStreamReplyKey(replyTo)) || 0,
1545
+ );
1546
+ }
1547
+
1548
+ isCodexCheckpointUnavailableError(errorMessage) {
1549
+ return String(errorMessage || "")
1550
+ .toLowerCase()
1551
+ .includes("codex session file checkpoint unavailable");
1552
+ }
1553
+
1554
+ async settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage, { markProcessed = true } = {}) {
1555
+ if (!this.useSessionFileReplyStream) {
1556
+ return false;
1557
+ }
1558
+ if (String(this.backendName || "").toLowerCase() !== "codex") {
1559
+ return false;
1560
+ }
1561
+ if (!this.isCodexCheckpointUnavailableError(errorMessage)) {
1562
+ return false;
1563
+ }
1564
+ const streamedReplyCount = this.getSessionStreamReplyCount(replyTo);
1565
+ if (streamedReplyCount <= 0) {
1566
+ return false;
1567
+ }
1568
+
1569
+ this.copilotLog(
1570
+ `suppress checkpoint unavailable after streamed replies replyTo=${replyTo || "latest"} count=${streamedReplyCount}`,
1571
+ );
1572
+ await this.reportRuntimeStatus(
1573
+ {
1574
+ phase: "session_stream_settled",
1575
+ reply_in_progress: false,
1576
+ },
1577
+ replyTo,
1578
+ );
1579
+ if (markProcessed && replyTo) {
1580
+ this.processedMessageIds.add(replyTo);
1581
+ }
1582
+ this.resetErrorLoop();
1583
+ return true;
1584
+ }
1585
+
2680
1586
  resetErrorLoop() {
2681
1587
  this.errorLoop = null;
2682
1588
  }
@@ -2864,6 +1770,9 @@ export class BridgeRunner {
2864
1770
  );
2865
1771
  return;
2866
1772
  }
1773
+ if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
1774
+ return;
1775
+ }
2867
1776
  this.copilotLog(
2868
1777
  `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
2869
1778
  );
@@ -2969,14 +1878,18 @@ export class BridgeRunner {
2969
1878
  this.copilotLog("synthetic session_file turn settled");
2970
1879
  }
2971
1880
  } catch (error) {
1881
+ const errorMessage = error instanceof Error ? error.message : String(error);
2972
1882
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2973
1883
  this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
2974
1884
  return;
2975
1885
  }
1886
+ if (await this.settleCodexCheckpointUnavailableAfterStream("initial", errorMessage, { markProcessed: false })) {
1887
+ return;
1888
+ }
2976
1889
  this.copilotLog(
2977
- `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(error?.message || error, 200)}"`,
1890
+ `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
2978
1891
  );
2979
- await this.reportError(`初始提示执行失败: ${error.message}`);
1892
+ await this.reportError(`初始提示执行失败: ${errorMessage}`);
2980
1893
  } finally {
2981
1894
  this.copilotLog(`synthetic turn end elapsedMs=${Date.now() - startedAt}`);
2982
1895
  this.runningTurn = false;