@love-moon/conductor-cli 0.2.12 → 0.2.14

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.
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import fs from "node:fs";
11
+ import { createHash } from "node:crypto";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
13
14
  import process from "node:process";
@@ -103,6 +104,95 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
103
104
  process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
104
105
  10,
105
106
  );
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
+ const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
111
+ const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
112
+ const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
113
+ const SESSION_BOOTSTRAP_LOCK_TIMEOUT_MS = 15_000;
114
+ const SESSION_BOOTSTRAP_LOCK_RETRY_MS = 50;
115
+
116
+ function sleepSync(ms) {
117
+ if (!Number.isFinite(ms) || ms <= 0) {
118
+ return;
119
+ }
120
+ try {
121
+ const buffer = new SharedArrayBuffer(4);
122
+ const arr = new Int32Array(buffer);
123
+ Atomics.wait(arr, 0, 0, ms);
124
+ } catch {
125
+ const startedAt = Date.now();
126
+ while (Date.now() - startedAt < ms) {
127
+ // busy wait fallback
128
+ }
129
+ }
130
+ }
131
+
132
+ function resolveLockWorkingDirectory(workingDirectory) {
133
+ const normalizedPath =
134
+ typeof workingDirectory === "string" && workingDirectory.trim()
135
+ ? path.resolve(workingDirectory.trim())
136
+ : process.cwd();
137
+ try {
138
+ return fs.realpathSync(normalizedPath);
139
+ } catch {
140
+ return normalizedPath;
141
+ }
142
+ }
143
+
144
+ export function resolveFreshSessionBootstrapLockPath(backendName, workingDirectory) {
145
+ const normalizedBackend = String(backendName || "").trim().toLowerCase();
146
+ if (normalizedBackend !== "codex") {
147
+ return null;
148
+ }
149
+ const lockKey = `${normalizedBackend}:${resolveLockWorkingDirectory(workingDirectory)}`;
150
+ const digest = createHash("sha1").update(lockKey).digest("hex");
151
+ return path.join(os.homedir(), ".conductor", "locks", `session-bootstrap-${digest}.lock`);
152
+ }
153
+
154
+ function acquireFileLock(lockPath) {
155
+ const startedAt = Date.now();
156
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
157
+ while (true) {
158
+ try {
159
+ const fd = fs.openSync(lockPath, "wx");
160
+ return () => {
161
+ try {
162
+ fs.closeSync(fd);
163
+ } catch {
164
+ // ignore close failure
165
+ }
166
+ try {
167
+ fs.unlinkSync(lockPath);
168
+ } catch {
169
+ // ignore unlink failure
170
+ }
171
+ };
172
+ } catch (error) {
173
+ if (error?.code !== "EEXIST") {
174
+ throw error;
175
+ }
176
+ if (Date.now() - startedAt > SESSION_BOOTSTRAP_LOCK_TIMEOUT_MS) {
177
+ throw new Error(`Timed out waiting for fresh session lock: ${lockPath}`);
178
+ }
179
+ sleepSync(SESSION_BOOTSTRAP_LOCK_RETRY_MS);
180
+ }
181
+ }
182
+ }
183
+
184
+ export async function withFreshSessionBootstrapLock(backendName, workingDirectory, fn) {
185
+ const lockPath = resolveFreshSessionBootstrapLockPath(backendName, workingDirectory);
186
+ if (!lockPath) {
187
+ return await fn();
188
+ }
189
+ const release = acquireFileLock(lockPath);
190
+ try {
191
+ return await fn();
192
+ } finally {
193
+ release();
194
+ }
195
+ }
106
196
 
107
197
  function appendFireLocalLog(line) {
108
198
  if (!ENABLE_FIRE_LOCAL_LOG) {
@@ -118,6 +208,7 @@ function appendFireLocalLog(line) {
118
208
  async function main() {
119
209
  const cliArgs = parseCliArgs();
120
210
  let runtimeProjectPath = process.cwd();
211
+ let backendSession = null;
121
212
 
122
213
  if (cliArgs.showHelp) {
123
214
  return;
@@ -155,16 +246,6 @@ async function main() {
155
246
  log(`Switched working directory to ${runtimeProjectPath} before Conductor connect`);
156
247
  }
157
248
 
158
- // Create backend session using tui-driver
159
- const backendSession = new TuiDriverSession(cliArgs.backend, {
160
- initialImages: cliArgs.initialImages,
161
- cwd: runtimeProjectPath,
162
- resumeSessionId: cliArgs.resumeSessionId,
163
- configFile: cliArgs.configFile,
164
- });
165
-
166
- log(`Using backend: ${cliArgs.backend}`);
167
-
168
249
  const env = buildEnv();
169
250
  const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
170
251
  let reconnectRunner = null;
@@ -224,6 +305,7 @@ async function main() {
224
305
  if (cliArgs.configFile) {
225
306
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
226
307
  }
308
+ let configuredDaemonName = "";
227
309
  try {
228
310
  const config = loadConfig(cliArgs.configFile);
229
311
  if (config.backendUrl && !env.CONDUCTOR_BACKEND_URL) {
@@ -235,6 +317,9 @@ async function main() {
235
317
  if (config.agentToken && !env.CONDUCTOR_AGENT_TOKEN) {
236
318
  env.CONDUCTOR_AGENT_TOKEN = config.agentToken;
237
319
  }
320
+ if (config.daemonName) {
321
+ configuredDaemonName = config.daemonName;
322
+ }
238
323
  } catch (error) {
239
324
  // Ignore config loading errors, rely on env vars or defaults
240
325
  }
@@ -261,6 +346,7 @@ async function main() {
261
346
  providedTaskId: process.env.CONDUCTOR_TASK_ID,
262
347
  requestedTitle: requestedTaskTitle,
263
348
  backend: cliArgs.backend,
349
+ daemonName: configuredDaemonName,
264
350
  });
265
351
 
266
352
  log(
@@ -270,6 +356,48 @@ async function main() {
270
356
  );
271
357
  reconnectTaskId = taskContext.taskId;
272
358
 
359
+ if (typeof conductor.bindTaskSession === "function") {
360
+ try {
361
+ await conductor.bindTaskSession(taskContext.taskId, {
362
+ project_id: process.env.CONDUCTOR_PROJECT_ID,
363
+ project_path: runtimeProjectPath,
364
+ backend_type: cliArgs.backend,
365
+ });
366
+ } catch {
367
+ // best effort only
368
+ }
369
+ }
370
+
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
+ }
391
+
392
+ backendSession = new TuiDriverSession(cliArgs.backend, {
393
+ initialImages: cliArgs.initialImages,
394
+ cwd: runtimeProjectPath,
395
+ resumeSessionId: resolvedResumeSessionId,
396
+ configFile: cliArgs.configFile,
397
+ });
398
+
399
+ log(`Using backend: ${cliArgs.backend}`);
400
+
273
401
  try {
274
402
  await conductor.sendAgentResume({
275
403
  active_tasks: [taskContext.taskId],
@@ -289,7 +417,8 @@ async function main() {
289
417
  includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
290
418
  cliArgs: cliArgs.rawBackendArgs,
291
419
  backendName: cliArgs.backend,
292
- resumeMode: Boolean(cliArgs.resumeSessionId),
420
+ resumeMode: Boolean(resolvedResumeSessionId),
421
+ daemonName: configuredDaemonName,
293
422
  });
294
423
  reconnectRunner = runner;
295
424
  if (pendingRemoteStopEvent) {
@@ -307,7 +436,9 @@ async function main() {
307
436
  backendShutdownRequested = true;
308
437
  void (async () => {
309
438
  try {
310
- await backendSession.close();
439
+ if (backendSession && typeof backendSession.close === "function") {
440
+ await backendSession.close();
441
+ }
311
442
  } catch (error) {
312
443
  log(`Failed to close backend session after ${source}: ${error?.message || error}`);
313
444
  }
@@ -338,6 +469,11 @@ async function main() {
338
469
 
339
470
  let runnerError = null;
340
471
  try {
472
+ if (!resolvedResumeSessionId && String(cliArgs.backend).trim().toLowerCase() === "codex") {
473
+ await withFreshSessionBootstrapLock(cliArgs.backend, runtimeProjectPath, async () => {
474
+ await runner.announceBackendSession();
475
+ });
476
+ }
341
477
  await runner.start(signals.signal);
342
478
  } catch (error) {
343
479
  runnerError = error;
@@ -379,7 +515,7 @@ async function main() {
379
515
  }
380
516
  }
381
517
  } finally {
382
- if (typeof backendSession.close === "function") {
518
+ if (backendSession && typeof backendSession.close === "function") {
383
519
  try {
384
520
  await backendSession.close();
385
521
  } catch (error) {
@@ -667,6 +803,9 @@ async function ensureTaskContext(conductor, opts) {
667
803
  task_title: deriveTaskTitle(opts.initialPrompt, opts.requestedTitle, opts.backend),
668
804
  backend_type: opts.backend,
669
805
  };
806
+ if (opts.daemonName) {
807
+ payload.daemon_name = opts.daemonName;
808
+ }
670
809
  if (opts.initialPrompt) {
671
810
  payload.prefill = opts.initialPrompt;
672
811
  }
@@ -1027,6 +1166,39 @@ function sanitizeForLog(value, maxLen = 180) {
1027
1166
  return truncateText(String(value).replace(/\s+/g, " ").trim(), maxLen);
1028
1167
  }
1029
1168
 
1169
+ function getBoundedEnvInt(envName, fallback, min, max) {
1170
+ const fallbackNumber = Number(fallback);
1171
+ const normalizedFallback = Number.isFinite(fallbackNumber)
1172
+ ? Math.min(Math.max(Math.round(fallbackNumber), min), max)
1173
+ : min;
1174
+ const raw = process.env[envName];
1175
+ const parsed = Number.parseInt(String(raw ?? ""), 10);
1176
+ if (!Number.isFinite(parsed)) {
1177
+ return normalizedFallback;
1178
+ }
1179
+ return Math.min(Math.max(parsed, min), max);
1180
+ }
1181
+
1182
+ function normalizeExecutionErrorKey(errorMessage) {
1183
+ const normalized = sanitizeForLog(errorMessage, 280).toLowerCase();
1184
+ if (!normalized) {
1185
+ return "unknown_error";
1186
+ }
1187
+ if (normalized.includes("pty session already spawned")) {
1188
+ return "pty_session_already_spawned";
1189
+ }
1190
+ if (normalized.includes("tui process has exited")) {
1191
+ return "tui_process_exited";
1192
+ }
1193
+ if (normalized.includes("cannot proceed: tui process has exited")) {
1194
+ return "cannot_proceed_tui_exited";
1195
+ }
1196
+ if (normalized.includes("turn exceeded hard deadline")) {
1197
+ return "turn_timeout";
1198
+ }
1199
+ return normalized;
1200
+ }
1201
+
1030
1202
  function tailLines(value, count = 6) {
1031
1203
  if (!value) return "";
1032
1204
  const lines = String(value).split(/\r?\n/);
@@ -1045,6 +1217,7 @@ class TuiDriverSession {
1045
1217
  this.sessionId = resumeSessionId || `${backend}-${Date.now()}`;
1046
1218
  this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
1047
1219
  this.pendingHistorySeed = this.history.length > 0;
1220
+ this.sessionInfo = null;
1048
1221
 
1049
1222
  const allowCliList = options.configFile ? loadAllowCliList(options.configFile) : DEFAULT_ALLOW_CLI_LIST;
1050
1223
  const cliCommand = CUSTOM_CLI_COMMAND || allowCliList[backend] || backend;
@@ -1066,11 +1239,51 @@ class TuiDriverSession {
1066
1239
  this.closeRequested = false;
1067
1240
  this.closed = false;
1068
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
+ );
1069
1262
 
1070
1263
  const profileName = profileNameForBackend(backend);
1071
1264
  if (!profileName) {
1072
1265
  throw new Error(`Backend "${backend}" is not supported by tui-driver`);
1073
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
+ );
1074
1287
 
1075
1288
  const profileMap = {
1076
1289
  codex: codexProfile,
@@ -1078,6 +1291,7 @@ class TuiDriverSession {
1078
1291
  copilot: copilotProfile,
1079
1292
  };
1080
1293
  const baseProfile = profileMap[profileName];
1294
+ const effectiveDriverArgs = [...(baseProfile.args || []), ...this.args];
1081
1295
  const envConfig = loadEnvConfig(options.configFile);
1082
1296
  const proxyEnv = proxyToEnv(envConfig);
1083
1297
  const cliEnv = envConfig && typeof envConfig === "object" ? { ...envConfig, ...proxyEnv } : proxyEnv;
@@ -1086,10 +1300,10 @@ class TuiDriverSession {
1086
1300
  log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
1087
1301
  }
1088
1302
 
1089
- log(`Using TUI command for ${backend}: ${[this.command, ...this.args].join(" ")} (cwd: ${this.cwd})`);
1303
+ log(`Using TUI command for ${backend}: ${[this.command, ...effectiveDriverArgs].join(" ")} (cwd: ${this.cwd})`);
1090
1304
  if (this.tuiTrace) {
1091
1305
  log(
1092
- `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(this.args)}`,
1306
+ `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(effectiveDriverArgs)}`,
1093
1307
  );
1094
1308
  log(
1095
1309
  `[${this.backend}] [tui-trace] timeouts=${JSON.stringify(baseProfile.timeouts || {})} size=${baseProfile.cols || 120}x${baseProfile.rows || 40}`,
@@ -1100,7 +1314,7 @@ class TuiDriverSession {
1100
1314
  profile: {
1101
1315
  ...baseProfile,
1102
1316
  command: this.command,
1103
- args: this.args,
1317
+ args: effectiveDriverArgs,
1104
1318
  env: {
1105
1319
  ...process.env,
1106
1320
  ...(baseProfile.env || {}),
@@ -1108,6 +1322,7 @@ class TuiDriverSession {
1108
1322
  },
1109
1323
  },
1110
1324
  cwd: this.cwd,
1325
+ expectedSessionId: resumeSessionId || undefined,
1111
1326
  debug: this.tuiDebug,
1112
1327
  onSnapshot: this.tuiTrace
1113
1328
  ? (snapshot, state) => {
@@ -1129,6 +1344,10 @@ class TuiDriverSession {
1129
1344
  }
1130
1345
  log(`[${this.backend}] [WARN] Please run "${this.command} login" or authenticate manually.`);
1131
1346
  });
1347
+
1348
+ this.driver.on("session", (session) => {
1349
+ this.applySessionInfo(session);
1350
+ });
1132
1351
  }
1133
1352
 
1134
1353
  get threadId() {
@@ -1139,12 +1358,316 @@ class TuiDriverSession {
1139
1358
  return { model: this.backend };
1140
1359
  }
1141
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
+
1142
1657
  createSessionClosedError() {
1143
1658
  const error = new Error("TUI session closed");
1144
1659
  error.reason = "session_closed";
1145
1660
  return error;
1146
1661
  }
1147
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
+
1148
1671
  createCloseGuard() {
1149
1672
  if (this.closeRequested) {
1150
1673
  return {
@@ -1169,6 +1692,32 @@ class TuiDriverSession {
1169
1692
  };
1170
1693
  }
1171
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
+
1172
1721
  flushCloseWaiters() {
1173
1722
  if (!this.closeWaiters || this.closeWaiters.size === 0) {
1174
1723
  return;
@@ -1189,6 +1738,8 @@ class TuiDriverSession {
1189
1738
  }
1190
1739
  this.closed = true;
1191
1740
  this.closeRequested = true;
1741
+ this.sessionMonitorStopRequested = true;
1742
+ this.workingStatusMonitorStopRequested = true;
1192
1743
  this.flushCloseWaiters();
1193
1744
  if (this.driver) {
1194
1745
  this.driver.kill();
@@ -1310,9 +1861,13 @@ class TuiDriverSession {
1310
1861
 
1311
1862
  log(`[${this.backend}] Running prompt: ${truncateText(effectivePrompt, 100)}...`);
1312
1863
  this.trace(`runTurn start promptLen=${effectivePrompt.length} useInitialImages=${Boolean(useInitialImages)}`);
1864
+ const useSessionFileReplyStream = this.usesSessionFileReplyStream();
1313
1865
 
1314
1866
  const handleStateChange = (transition) => {
1315
1867
  this.trace(`state ${transition.from} -> ${transition.to}`);
1868
+ if (useSessionFileReplyStream) {
1869
+ return;
1870
+ }
1316
1871
  this.emitProgress(onProgress, {
1317
1872
  state: transition.to,
1318
1873
  phase: "state_change",
@@ -1340,6 +1895,9 @@ class TuiDriverSession {
1340
1895
  );
1341
1896
  }
1342
1897
  }
1898
+ if (useSessionFileReplyStream) {
1899
+ return;
1900
+ }
1343
1901
  this.emitProgress(onProgress, {
1344
1902
  state: this.driver.state,
1345
1903
  phase: "signal_poll",
@@ -1364,28 +1922,38 @@ class TuiDriverSession {
1364
1922
  }
1365
1923
  }
1366
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
+ }
1367
1931
 
1368
1932
  try {
1369
- const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise]);
1933
+ const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise, turnTimeoutGuard.promise]);
1370
1934
  const answer = String(result.answer || result.replyText || "").trim();
1371
1935
  this.trace(
1372
1936
  `runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
1373
1937
  );
1374
1938
 
1375
- this.history.push({ role: "user", content: promptText });
1376
- if (answer) {
1939
+ if (!useSessionFileReplyStream) {
1940
+ this.history.push({ role: "user", content: promptText });
1941
+ }
1942
+ if (answer && !useSessionFileReplyStream) {
1377
1943
  this.history.push({ role: "assistant", content: answer });
1378
1944
  }
1379
1945
 
1380
- this.emitProgress(onProgress, {
1381
- state: result.success ? "DONE" : "ERROR",
1382
- phase: "turn_result",
1383
- source: "tui-driver",
1384
- reply_in_progress: false,
1385
- status_line: result.statusLine || undefined,
1386
- status_done_line: result.statusDoneLine || undefined,
1387
- reply_preview: truncateText(result.replyText || answer, 240) || undefined,
1388
- });
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
+ }
1389
1957
 
1390
1958
  if (!result.success) {
1391
1959
  const error = result.error || new Error("tui-driver failed to complete this turn");
@@ -1415,8 +1983,23 @@ class TuiDriverSession {
1415
1983
  throw error instanceof Error ? error : new Error(errorMessage);
1416
1984
  }
1417
1985
 
1418
- // 特殊处理登录和权限错误
1419
- if (errorReason === "login_required") {
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") {
1420
2003
  this.emitProgress(onProgress, {
1421
2004
  state: "ERROR",
1422
2005
  phase: "login_required",
@@ -1469,6 +2052,7 @@ class TuiDriverSession {
1469
2052
  }
1470
2053
  clearInterval(signalTimer);
1471
2054
  this.driver.off("stateChange", handleStateChange);
2055
+ turnTimeoutGuard.cleanup();
1472
2056
  closeGuard.cleanup();
1473
2057
  this.trace(`runTurn cleanup state=${this.driver.state}`);
1474
2058
  }
@@ -1486,6 +2070,7 @@ export class BridgeRunner {
1486
2070
  cliArgs,
1487
2071
  backendName,
1488
2072
  resumeMode,
2073
+ daemonName,
1489
2074
  }) {
1490
2075
  this.backendSession = backendSession;
1491
2076
  this.conductor = conductor;
@@ -1503,10 +2088,66 @@ export class BridgeRunner {
1503
2088
  this.stopped = false;
1504
2089
  this.runningTurn = false;
1505
2090
  this.processedMessageIds = new Set();
2091
+ this.inFlightMessageIds = new Set();
1506
2092
  this.lastRuntimeStatusSignature = null;
1507
2093
  this.lastRuntimeStatusPayload = null;
2094
+ this.runtimeContextSnapshot = null;
2095
+ this.runtimeContextSnapshotAt = 0;
2096
+ this.runtimeContextInFlight = null;
2097
+ this.runtimeContextRefreshMs = getBoundedEnvInt(
2098
+ "CONDUCTOR_RUNTIME_CONTEXT_REFRESH_MS",
2099
+ 2000,
2100
+ 500,
2101
+ 60 * 1000,
2102
+ );
2103
+ this.useSessionFileReplyStream =
2104
+ Boolean(this.backendSession) &&
2105
+ typeof this.backendSession.usesSessionFileReplyStream === "function" &&
2106
+ this.backendSession.usesSessionFileReplyStream();
2107
+ this.daemonName =
2108
+ (typeof daemonName === "string" && daemonName.trim()) ||
2109
+ (typeof process.env.CONDUCTOR_AGENT_NAME === "string" && process.env.CONDUCTOR_AGENT_NAME.trim()) ||
2110
+ (typeof process.env.CONDUCTOR_DAEMON_NAME === "string" && process.env.CONDUCTOR_DAEMON_NAME.trim()) ||
2111
+ (typeof process.env.HOSTNAME === "string" && process.env.HOSTNAME.trim()) ||
2112
+ os.hostname();
1508
2113
  this.needsReconnectRecovery = false;
1509
2114
  this.remoteStopInfo = null;
2115
+ this.sessionAnnouncementSent = false;
2116
+ this.errorLoop = null;
2117
+ this.errorLoopWindowMs = getBoundedEnvInt(
2118
+ "CONDUCTOR_ERROR_LOOP_WINDOW_MS",
2119
+ DEFAULT_ERROR_LOOP_WINDOW_MS,
2120
+ 15 * 1000,
2121
+ 30 * 60 * 1000,
2122
+ );
2123
+ this.errorLoopBackoffMs = getBoundedEnvInt(
2124
+ "CONDUCTOR_ERROR_LOOP_BACKOFF_MS",
2125
+ DEFAULT_ERROR_LOOP_BACKOFF_MS,
2126
+ 15 * 1000,
2127
+ 60 * 60 * 1000,
2128
+ );
2129
+ this.errorLoopThreshold = getBoundedEnvInt(
2130
+ "CONDUCTOR_ERROR_LOOP_THRESHOLD",
2131
+ DEFAULT_ERROR_LOOP_THRESHOLD,
2132
+ 2,
2133
+ 20,
2134
+ );
2135
+ if (
2136
+ this.useSessionFileReplyStream &&
2137
+ typeof this.backendSession?.setSessionMessageHandler === "function"
2138
+ ) {
2139
+ this.backendSession.setSessionMessageHandler((payload) =>
2140
+ this.handleSessionFileAssistantMessage(payload),
2141
+ );
2142
+ }
2143
+ if (
2144
+ this.useSessionFileReplyStream &&
2145
+ typeof this.backendSession?.setWorkingStatusHandler === "function"
2146
+ ) {
2147
+ this.backendSession.setWorkingStatusHandler((payload) =>
2148
+ this.handleContinuousWorkingStatus(payload),
2149
+ );
2150
+ }
1510
2151
  }
1511
2152
 
1512
2153
  copilotLog(message) {
@@ -1516,6 +2157,71 @@ export class BridgeRunner {
1516
2157
  log(`[copilot-debug] task=${this.taskId} ${message}`);
1517
2158
  }
1518
2159
 
2160
+ async announceBackendSession() {
2161
+ if (this.sessionAnnouncementSent) {
2162
+ return;
2163
+ }
2164
+ if (!this.backendSession || typeof this.backendSession.ensureSessionInfo !== "function") {
2165
+ return;
2166
+ }
2167
+ let sessionInfo = null;
2168
+ try {
2169
+ sessionInfo = await this.backendSession.ensureSessionInfo();
2170
+ } catch (error) {
2171
+ this.copilotLog(`session announce skipped: ${sanitizeForLog(error?.message || error, 160)}`);
2172
+ return;
2173
+ }
2174
+ const discoveredSessionId = String(sessionInfo?.sessionId || "").trim();
2175
+ const fallbackSessionId = this.resumeMode
2176
+ ? String(this.backendSession.threadId || "").trim()
2177
+ : "";
2178
+ const sessionId = discoveredSessionId || fallbackSessionId;
2179
+ const sessionFilePath = sessionInfo?.sessionFilePath ? String(sessionInfo.sessionFilePath).trim() : "";
2180
+ const hasRealSessionId = Boolean(sessionId);
2181
+ const message = hasRealSessionId
2182
+ ? `${this.backendName} session started: ${sessionId}`
2183
+ : `${this.backendName} session started`;
2184
+ if (hasRealSessionId && typeof this.conductor?.bindTaskSession === "function") {
2185
+ try {
2186
+ await this.conductor.bindTaskSession(this.taskId, {
2187
+ session_id: sessionId,
2188
+ session_file_path: sessionFilePath || undefined,
2189
+ backend_type: this.backendName,
2190
+ });
2191
+ } catch (error) {
2192
+ log(`Failed to persist task session binding for ${this.taskId}: ${error?.message || error}`);
2193
+ }
2194
+ }
2195
+ try {
2196
+ await this.conductor.sendMessage(this.taskId, message, {
2197
+ backend: this.backendName,
2198
+ thread_id: hasRealSessionId ? sessionId : undefined,
2199
+ session_id: hasRealSessionId ? sessionId : undefined,
2200
+ session_file_path: sessionFilePath || undefined,
2201
+ cli_args: this.cliArgs,
2202
+ synthetic: true,
2203
+ });
2204
+ this.sessionAnnouncementSent = true;
2205
+ this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
2206
+ await this.reportRuntimeStatus(
2207
+ {
2208
+ state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
2209
+ phase: "session_started",
2210
+ source: "tui-driver",
2211
+ reply_in_progress: false,
2212
+ status_done_line: this.useSessionFileReplyStream
2213
+ ? undefined
2214
+ : `${this.backendName} session started`,
2215
+ backend: this.backendName,
2216
+ thread_id: hasRealSessionId ? sessionId : undefined,
2217
+ },
2218
+ undefined,
2219
+ );
2220
+ } catch (error) {
2221
+ log(`Failed to send session announcement: ${error?.message || error}`);
2222
+ }
2223
+ }
2224
+
1519
2225
  async start(abortSignal) {
1520
2226
  abortSignal?.addEventListener("abort", () => {
1521
2227
  this.stopped = true;
@@ -1524,6 +2230,11 @@ export class BridgeRunner {
1524
2230
  return;
1525
2231
  }
1526
2232
 
2233
+ await this.announceBackendSession();
2234
+ if (this.stopped) {
2235
+ return;
2236
+ }
2237
+
1527
2238
  if (this.initialPrompt) {
1528
2239
  this.copilotLog("processing initial prompt");
1529
2240
  await this.handleSyntheticMessage(this.initialPrompt, {
@@ -1539,11 +2250,6 @@ export class BridgeRunner {
1539
2250
  if (this.stopped) {
1540
2251
  return;
1541
2252
  }
1542
- this.copilotLog("running startup backfill");
1543
- await this.backfillPendingUserMessages();
1544
- if (this.stopped) {
1545
- return;
1546
- }
1547
2253
 
1548
2254
  while (!this.stopped) {
1549
2255
  if (this.needsReconnectRecovery && !this.runningTurn) {
@@ -1611,10 +2317,8 @@ export class BridgeRunner {
1611
2317
  }
1612
2318
  this.needsReconnectRecovery = false;
1613
2319
  log(`Recovering task ${this.taskId} after reconnect`);
1614
- // With durable web outbox enabled, user messages are replayed by the server.
1615
- // Re-running DB-history backfill here can re-drive stale prompts and confuse
1616
- // the local TUI session after reconnect. Keep startup backfill, but disable
1617
- // reconnect backfill by default (opt-in for debugging/legacy fallback).
2320
+ // User-message recovery relies on the durable server outbox. DB-history replay
2321
+ // stays opt-in here only as a legacy/debugging fallback after reconnect.
1618
2322
  if (process.env.CONDUCTOR_FIRE_RECONNECT_BACKFILL === "1") {
1619
2323
  await this.backfillPendingUserMessages();
1620
2324
  }
@@ -1779,7 +2483,80 @@ export class BridgeRunner {
1779
2483
  }
1780
2484
  }
1781
2485
 
1782
- createRuntimeStatus(payload, replyTo) {
2486
+ normalizePercent(value) {
2487
+ if (!Number.isFinite(value)) {
2488
+ return undefined;
2489
+ }
2490
+ if (value < 0) {
2491
+ return 0;
2492
+ }
2493
+ if (value > 100) {
2494
+ return 100;
2495
+ }
2496
+ return value;
2497
+ }
2498
+
2499
+ async resolveRuntimeContext() {
2500
+ const now = Date.now();
2501
+ if (
2502
+ this.runtimeContextSnapshot &&
2503
+ now - this.runtimeContextSnapshotAt < this.runtimeContextRefreshMs
2504
+ ) {
2505
+ return this.runtimeContextSnapshot;
2506
+ }
2507
+ if (this.runtimeContextInFlight) {
2508
+ return this.runtimeContextInFlight;
2509
+ }
2510
+
2511
+ this.runtimeContextInFlight = (async () => {
2512
+ let sessionInfo = null;
2513
+ try {
2514
+ if (typeof this.backendSession?.getSessionInfo === "function") {
2515
+ sessionInfo = this.backendSession.getSessionInfo();
2516
+ }
2517
+ } catch {
2518
+ sessionInfo = null;
2519
+ }
2520
+
2521
+ let usage = null;
2522
+ try {
2523
+ if (typeof this.backendSession?.getSessionUsageSummary === "function") {
2524
+ usage = await this.backendSession.getSessionUsageSummary();
2525
+ }
2526
+ } catch {
2527
+ usage = null;
2528
+ }
2529
+
2530
+ const resolvedSessionId = String(
2531
+ usage?.sessionId || sessionInfo?.sessionId || "",
2532
+ ).trim();
2533
+ const resolvedSessionFilePath = String(
2534
+ usage?.sessionFilePath || sessionInfo?.sessionFilePath || "",
2535
+ ).trim();
2536
+ const tokenUsagePercent = this.normalizePercent(Number(usage?.tokenUsagePercent));
2537
+ const contextUsagePercent = this.normalizePercent(Number(usage?.contextUsagePercent));
2538
+
2539
+ const snapshot = {
2540
+ daemon: this.daemonName || undefined,
2541
+ pid: process.pid,
2542
+ session_id: resolvedSessionId || undefined,
2543
+ session_file_path: resolvedSessionFilePath || undefined,
2544
+ token_usage_percent: tokenUsagePercent,
2545
+ context_usage_percent: contextUsagePercent,
2546
+ };
2547
+ this.runtimeContextSnapshot = snapshot;
2548
+ this.runtimeContextSnapshotAt = Date.now();
2549
+ return snapshot;
2550
+ })();
2551
+
2552
+ try {
2553
+ return await this.runtimeContextInFlight;
2554
+ } finally {
2555
+ this.runtimeContextInFlight = null;
2556
+ }
2557
+ }
2558
+
2559
+ createRuntimeStatus(payload, replyTo, runtimeContext = null) {
1783
2560
  if (!payload || typeof payload !== "object") {
1784
2561
  return null;
1785
2562
  }
@@ -1804,12 +2581,22 @@ export class BridgeRunner {
1804
2581
  reply_preview: truncateText(replyPreview, 240) || undefined,
1805
2582
  reply_to: replyTo,
1806
2583
  backend: this.backendName,
1807
- thread_id: this.backendSession.threadId,
2584
+ thread_id:
2585
+ String(
2586
+ payload.thread_id || runtimeContext?.session_id || "",
2587
+ ).trim() || undefined,
2588
+ daemon: runtimeContext?.daemon,
2589
+ pid: runtimeContext?.pid,
2590
+ session_id: runtimeContext?.session_id,
2591
+ session_file_path: runtimeContext?.session_file_path,
2592
+ token_usage_percent: runtimeContext?.token_usage_percent,
2593
+ context_usage_percent: runtimeContext?.context_usage_percent,
1808
2594
  };
1809
2595
  }
1810
2596
 
1811
2597
  async reportRuntimeStatus(payload, replyTo) {
1812
- const runtime = this.createRuntimeStatus(payload, replyTo);
2598
+ const runtimeContext = await this.resolveRuntimeContext();
2599
+ const runtime = this.createRuntimeStatus(payload, replyTo, runtimeContext);
1813
2600
  if (!runtime) {
1814
2601
  return;
1815
2602
  }
@@ -1852,6 +2639,105 @@ export class BridgeRunner {
1852
2639
  }
1853
2640
  }
1854
2641
 
2642
+ async handleSessionFileAssistantMessage(payload) {
2643
+ if (this.stopped) {
2644
+ return;
2645
+ }
2646
+ const text = typeof payload?.text === "string" ? payload.text.trim() : "";
2647
+ if (!text) {
2648
+ return;
2649
+ }
2650
+
2651
+ const replyTo = typeof payload?.replyTo === "string" ? payload.replyTo.trim() : "";
2652
+ const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId.trim() : "";
2653
+ const sessionFilePath =
2654
+ typeof payload?.sessionFilePath === "string" ? payload.sessionFilePath.trim() : "";
2655
+
2656
+ logBackendReply(this.backendName, text, {
2657
+ usage: null,
2658
+ replyTo: replyTo || "latest",
2659
+ });
2660
+ await this.conductor.sendMessage(this.taskId, text, {
2661
+ model: this.backendSession.threadOptions?.model || this.backendName,
2662
+ backend: this.backendName,
2663
+ thread_id: sessionId || this.backendSession.threadId,
2664
+ session_id: sessionId || undefined,
2665
+ session_file_path: sessionFilePath || undefined,
2666
+ reply_to: replyTo || undefined,
2667
+ cli_args: this.cliArgs,
2668
+ session_stream: true,
2669
+ timestamp: payload?.timestamp || undefined,
2670
+ });
2671
+ this.copilotLog(
2672
+ `session_file sdk_message sent replyTo=${replyTo || "latest"} responseLen=${text.length}`,
2673
+ );
2674
+ }
2675
+
2676
+ async handleContinuousWorkingStatus(payload) {
2677
+ await this.reportRuntimeStatus(payload, payload?.replyTo);
2678
+ }
2679
+
2680
+ resetErrorLoop() {
2681
+ this.errorLoop = null;
2682
+ }
2683
+
2684
+ evaluateErrorLoop(errorMessage) {
2685
+ const normalizedKey = normalizeExecutionErrorKey(errorMessage);
2686
+ const now = Date.now();
2687
+ const current = this.errorLoop;
2688
+
2689
+ if (
2690
+ !current ||
2691
+ current.key !== normalizedKey ||
2692
+ now - current.lastAt > this.errorLoopWindowMs
2693
+ ) {
2694
+ this.errorLoop = {
2695
+ key: normalizedKey,
2696
+ count: 1,
2697
+ lastAt: now,
2698
+ cooldownUntil: 0,
2699
+ };
2700
+ return {
2701
+ key: normalizedKey,
2702
+ count: 1,
2703
+ open: false,
2704
+ suppressReport: false,
2705
+ cooldownMs: 0,
2706
+ };
2707
+ }
2708
+
2709
+ current.count += 1;
2710
+ current.lastAt = now;
2711
+ const open = current.count >= this.errorLoopThreshold;
2712
+ let suppressReport = false;
2713
+
2714
+ if (open) {
2715
+ if (current.cooldownUntil > now) {
2716
+ suppressReport = true;
2717
+ } else {
2718
+ current.cooldownUntil = now + this.errorLoopBackoffMs;
2719
+ }
2720
+ }
2721
+
2722
+ this.errorLoop = current;
2723
+ return {
2724
+ key: normalizedKey,
2725
+ count: current.count,
2726
+ open,
2727
+ suppressReport,
2728
+ cooldownMs: suppressReport ? current.cooldownUntil - now : 0,
2729
+ };
2730
+ }
2731
+
2732
+ isExecutionFailureLoopError(errorMessage) {
2733
+ const normalized = String(errorMessage || "").toLowerCase();
2734
+ return (
2735
+ normalized.includes("pty session already spawned") ||
2736
+ normalized.includes("tui process has exited") ||
2737
+ normalized.includes("cannot proceed: tui process has exited")
2738
+ );
2739
+ }
2740
+
1855
2741
  async respondToMessage(message) {
1856
2742
  const content = String(message.content || "").trim();
1857
2743
  if (!content) {
@@ -1863,6 +2749,19 @@ export class BridgeRunner {
1863
2749
  this.copilotLog(`skip duplicated message replyTo=${replyTo}`);
1864
2750
  return;
1865
2751
  }
2752
+ if (replyTo && this.inFlightMessageIds.has(replyTo)) {
2753
+ this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
2754
+ return;
2755
+ }
2756
+ if (replyTo) {
2757
+ this.inFlightMessageIds.add(replyTo);
2758
+ }
2759
+ if (
2760
+ this.useSessionFileReplyStream &&
2761
+ typeof this.backendSession?.setSessionReplyTarget === "function"
2762
+ ) {
2763
+ this.backendSession.setSessionReplyTarget(replyTo);
2764
+ }
1866
2765
  this.lastRuntimeStatusSignature = null;
1867
2766
  this.runningTurn = true;
1868
2767
  const turnStartedAt = Date.now();
@@ -1883,15 +2782,25 @@ export class BridgeRunner {
1883
2782
  );
1884
2783
  log(`Processing message ${replyTo} (${message.role})`);
1885
2784
  try {
1886
- await this.reportRuntimeStatus(
1887
- {
1888
- state: "PREPARE_TURN",
1889
- phase: "start_turn",
1890
- reply_in_progress: true,
1891
- status_line: `${this.backendName} is preparing the response`,
1892
- },
1893
- replyTo,
1894
- );
2785
+ if (this.useSessionFileReplyStream) {
2786
+ await this.reportRuntimeStatus(
2787
+ {
2788
+ phase: "start_turn",
2789
+ reply_in_progress: true,
2790
+ },
2791
+ replyTo,
2792
+ );
2793
+ } else {
2794
+ await this.reportRuntimeStatus(
2795
+ {
2796
+ state: "PREPARE_TURN",
2797
+ phase: "start_turn",
2798
+ reply_in_progress: true,
2799
+ status_line: `${this.backendName} is preparing the response`,
2800
+ },
2801
+ replyTo,
2802
+ );
2803
+ }
1895
2804
 
1896
2805
  const result = await this.backendSession.runTurn(content, {
1897
2806
  onProgress: (payload) => {
@@ -1904,35 +2813,49 @@ export class BridgeRunner {
1904
2813
  ).trim().length} items=${Array.isArray(result.items) ? result.items.length : 0}`,
1905
2814
  );
1906
2815
 
1907
- await this.reportRuntimeStatus(
1908
- {
1909
- state: "DONE",
1910
- phase: "reply_ready",
1911
- reply_in_progress: false,
1912
- status_done_line: `${this.backendName} finished`,
1913
- },
1914
- replyTo,
1915
- );
2816
+ if (!this.useSessionFileReplyStream) {
2817
+ await this.reportRuntimeStatus(
2818
+ {
2819
+ state: "DONE",
2820
+ phase: "reply_ready",
2821
+ reply_in_progress: false,
2822
+ status_done_line: `${this.backendName} finished`,
2823
+ },
2824
+ replyTo,
2825
+ );
2826
+ }
1916
2827
 
1917
- const responseText =
1918
- result.text ||
1919
- extractAgentTextFromItems(result.items) ||
1920
- extractAgentTextFromMetadata(result.metadata) ||
1921
- `(${this.backendName} 未返回任何文本)`;
1922
- logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
1923
- await this.conductor.sendMessage(this.taskId, responseText, {
1924
- model: this.backendSession.threadOptions?.model || this.backendName,
1925
- backend: this.backendName,
1926
- usage: result.usage || null,
1927
- thread_id: this.backendSession.threadId,
1928
- items: result.items,
1929
- reply_to: replyTo,
1930
- cli_args: this.cliArgs,
1931
- });
2828
+ if (!this.useSessionFileReplyStream) {
2829
+ const responseText =
2830
+ result.text ||
2831
+ extractAgentTextFromItems(result.items) ||
2832
+ extractAgentTextFromMetadata(result.metadata) ||
2833
+ `(${this.backendName} 未返回任何文本)`;
2834
+ logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
2835
+ await this.conductor.sendMessage(this.taskId, responseText, {
2836
+ model: this.backendSession.threadOptions?.model || this.backendName,
2837
+ backend: this.backendName,
2838
+ usage: result.usage || null,
2839
+ thread_id: this.backendSession.threadId,
2840
+ items: result.items,
2841
+ reply_to: replyTo,
2842
+ cli_args: this.cliArgs,
2843
+ });
2844
+ }
1932
2845
  if (replyTo) {
1933
2846
  this.processedMessageIds.add(replyTo);
1934
2847
  }
1935
- this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
2848
+ this.resetErrorLoop();
2849
+ if (this.useSessionFileReplyStream) {
2850
+ this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
2851
+ } else {
2852
+ const responseText =
2853
+ result.text ||
2854
+ extractAgentTextFromItems(result.items) ||
2855
+ extractAgentTextFromMetadata(result.metadata) ||
2856
+ `(${this.backendName} 未返回任何文本)`;
2857
+ this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
2858
+ }
1936
2859
  } catch (error) {
1937
2860
  const errorMessage = error instanceof Error ? error.message : String(error);
1938
2861
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
@@ -1953,11 +2876,48 @@ export class BridgeRunner {
1953
2876
  },
1954
2877
  replyTo,
1955
2878
  );
1956
- await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
2879
+ const isExecutionFailure = this.isExecutionFailureLoopError(errorMessage);
2880
+ const loopState = isExecutionFailure
2881
+ ? this.evaluateErrorLoop(errorMessage)
2882
+ : {
2883
+ key: "non_execution_error",
2884
+ count: 1,
2885
+ open: false,
2886
+ suppressReport: false,
2887
+ cooldownMs: 0,
2888
+ };
2889
+ if (!isExecutionFailure) {
2890
+ this.resetErrorLoop();
2891
+ }
2892
+ const executionFailureLoop = isExecutionFailure && loopState.open;
2893
+ if (executionFailureLoop) {
2894
+ await this.reportRuntimeStatus(
2895
+ {
2896
+ state: "ERROR",
2897
+ phase: "execution_failure_loop",
2898
+ reply_in_progress: false,
2899
+ status_done_line: `${this.backendName} execution_failure_loop`,
2900
+ },
2901
+ replyTo,
2902
+ );
2903
+ }
2904
+ if (isExecutionFailure && loopState.suppressReport) {
2905
+ this.copilotLog(
2906
+ `suppress repeated error report key=${loopState.key} count=${loopState.count} cooldownMs=${loopState.cooldownMs}`,
2907
+ );
2908
+ return;
2909
+ }
2910
+ const reportMessage = executionFailureLoop
2911
+ ? `${this.backendName} 执行层失败循环(${loopState.key}, 连续${loopState.count}次): ${errorMessage}`
2912
+ : `${this.backendName} 处理失败: ${errorMessage}`;
2913
+ await this.reportError(reportMessage, replyTo);
1957
2914
  } finally {
1958
2915
  if (turnWatchdog) {
1959
2916
  clearInterval(turnWatchdog);
1960
2917
  }
2918
+ if (replyTo) {
2919
+ this.inFlightMessageIds.delete(replyTo);
2920
+ }
1961
2921
  this.copilotLog(
1962
2922
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
1963
2923
  );
@@ -1969,6 +2929,12 @@ export class BridgeRunner {
1969
2929
  this.lastRuntimeStatusSignature = null;
1970
2930
  this.runningTurn = true;
1971
2931
  const startedAt = Date.now();
2932
+ if (
2933
+ this.useSessionFileReplyStream &&
2934
+ typeof this.backendSession?.setSessionReplyTarget === "function"
2935
+ ) {
2936
+ this.backendSession.setSessionReplyTarget("initial");
2937
+ }
1972
2938
  this.copilotLog(`synthetic turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`);
1973
2939
  try {
1974
2940
  const result = await this.backendSession.runTurn(content, {
@@ -1980,24 +2946,28 @@ export class BridgeRunner {
1980
2946
  this.copilotLog(
1981
2947
  `synthetic runTurn completed elapsedMs=${Date.now() - startedAt} answerLen=${String(result.text || "").trim().length}`,
1982
2948
  );
1983
- const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
1984
- const intro = `${backendLabel} 已根据初始提示给出回复:`;
1985
- const replyText =
1986
- result.text || extractAgentTextFromItems(result.items) || extractAgentTextFromMetadata(result.metadata);
1987
- const text = replyText ? `${intro}\n\n${replyText}` : intro;
1988
- logBackendReply(this.backendName, replyText || "(无文本输出)", {
1989
- usage: result.usage,
1990
- replyTo: "initial",
1991
- });
1992
- await this.conductor.sendMessage(this.taskId, text, {
1993
- model: this.backendSession.threadOptions?.model || this.backendName,
1994
- backend: this.backendName,
1995
- usage: result.usage || null,
1996
- thread_id: this.backendSession.threadId,
1997
- cli_args: this.cliArgs,
1998
- synthetic: true,
1999
- });
2000
- this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
2949
+ if (!this.useSessionFileReplyStream) {
2950
+ const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
2951
+ const intro = `${backendLabel} 已根据初始提示给出回复:`;
2952
+ const replyText =
2953
+ result.text || extractAgentTextFromItems(result.items) || extractAgentTextFromMetadata(result.metadata);
2954
+ const text = replyText ? `${intro}\n\n${replyText}` : intro;
2955
+ logBackendReply(this.backendName, replyText || "(无文本输出)", {
2956
+ usage: result.usage,
2957
+ replyTo: "initial",
2958
+ });
2959
+ await this.conductor.sendMessage(this.taskId, text, {
2960
+ model: this.backendSession.threadOptions?.model || this.backendName,
2961
+ backend: this.backendName,
2962
+ usage: result.usage || null,
2963
+ thread_id: this.backendSession.threadId,
2964
+ cli_args: this.cliArgs,
2965
+ synthetic: true,
2966
+ });
2967
+ this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
2968
+ } else {
2969
+ this.copilotLog("synthetic session_file turn settled");
2970
+ }
2001
2971
  } catch (error) {
2002
2972
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2003
2973
  this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);