@love-moon/conductor-cli 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import process from "node:process";
14
+ import readline from "node:readline";
14
15
  import { setTimeout as delay } from "node:timers/promises";
15
16
  import { fileURLToPath } from "node:url";
16
17
 
@@ -19,18 +20,13 @@ import { hideBin } from "yargs/helpers";
19
20
  import yaml from "js-yaml";
20
21
  import { TuiDriver, claudeCodeProfile, codexProfile, copilotProfile } from "@love-moon/tui-driver";
21
22
  import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
22
- import {
23
- loadHistoryFromSpec,
24
- parseFromSpec,
25
- selectHistorySession,
26
- SUPPORTED_FROM_PROVIDERS,
27
- } from "../src/fire/history.js";
23
+ import { findSessionPath } from "../src/fire/history.js";
28
24
 
29
25
  const __filename = fileURLToPath(import.meta.url);
30
26
  const __dirname = path.dirname(__filename);
31
27
  const PKG_ROOT = path.join(__dirname, "..");
32
- const CLI_PROJECT_PATH = process.cwd();
33
- const FIRE_LOG_PATH = path.join(CLI_PROJECT_PATH, "conductor.log");
28
+ const INITIAL_CLI_PROJECT_PATH = process.cwd();
29
+ const FIRE_LOG_PATH = path.join(INITIAL_CLI_PROJECT_PATH, "conductor.log");
34
30
  const ENABLE_FIRE_LOCAL_LOG = !process.env.CONDUCTOR_CLI_COMMAND;
35
31
 
36
32
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
@@ -107,6 +103,12 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
107
103
  process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
108
104
  10,
109
105
  );
106
+ const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
107
+ const MIN_TURN_DEADLINE_MS = 30 * 1000;
108
+ const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
109
+ const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
110
+ const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
111
+ const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
110
112
 
111
113
  function appendFireLocalLog(line) {
112
114
  if (!ENABLE_FIRE_LOCAL_LOG) {
@@ -121,6 +123,7 @@ function appendFireLocalLog(line) {
121
123
 
122
124
  async function main() {
123
125
  const cliArgs = parseCliArgs();
126
+ let runtimeProjectPath = process.cwd();
124
127
 
125
128
  if (cliArgs.showHelp) {
126
129
  return;
@@ -147,48 +150,22 @@ async function main() {
147
150
  return;
148
151
  }
149
152
 
150
- let fromHistory = { history: [], provider: null, sessionId: null };
151
- if (cliArgs.fromSpec) {
152
- const provider = cliArgs.fromSpec.provider;
153
- if (!SUPPORTED_FROM_PROVIDERS.includes(provider)) {
154
- throw new Error(`--from only supports: ${SUPPORTED_FROM_PROVIDERS.join(", ")}`);
155
- }
156
-
157
- let resolvedSpec = cliArgs.fromSpec;
158
- if (!resolvedSpec.sessionId) {
159
- const selected = await selectHistorySession(provider);
160
- if (!selected) {
161
- log("No session selected. Starting a new conversation.");
162
- resolvedSpec = null;
163
- } else {
164
- resolvedSpec = parseFromSpec(`${provider}:${selected.sessionId}`);
165
- }
166
- }
167
-
168
- if (resolvedSpec) {
169
- if (cliArgs.backend !== resolvedSpec.provider) {
170
- log(
171
- `Ignoring --from ${resolvedSpec.provider}:${resolvedSpec.sessionId} because backend is ${cliArgs.backend}`,
172
- );
173
- } else {
174
- fromHistory = await loadHistoryFromSpec(resolvedSpec);
175
- if (fromHistory.warning) {
176
- log(fromHistory.warning);
177
- } else {
178
- log(
179
- `Loaded ${fromHistory.history.length} history messages from ${fromHistory.provider} session ${fromHistory.sessionId}`,
180
- );
181
- }
182
- }
183
- }
153
+ let resumeContext = null;
154
+ if (cliArgs.resumeSessionId) {
155
+ resumeContext = await resolveResumeContext(cliArgs.backend, cliArgs.resumeSessionId);
156
+ log(
157
+ `Validated --resume ${resumeContext.sessionId} (${resumeContext.provider}) at ${resumeContext.sessionPath}`,
158
+ );
159
+ log(`Resume will run backend from ${resumeContext.cwd}`);
160
+ runtimeProjectPath = await applyWorkingDirectory(resumeContext.cwd);
161
+ log(`Switched working directory to ${runtimeProjectPath} before Conductor connect`);
184
162
  }
185
163
 
186
164
  // Create backend session using tui-driver
187
165
  const backendSession = new TuiDriverSession(cliArgs.backend, {
188
166
  initialImages: cliArgs.initialImages,
189
- cwd: CLI_PROJECT_PATH,
190
- initialHistory: fromHistory.history,
191
- resumeSessionId: fromHistory.sessionId,
167
+ cwd: runtimeProjectPath,
168
+ resumeSessionId: cliArgs.resumeSessionId,
192
169
  configFile: cliArgs.configFile,
193
170
  });
194
171
 
@@ -198,6 +175,7 @@ async function main() {
198
175
  const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
199
176
  let reconnectRunner = null;
200
177
  let reconnectTaskId = null;
178
+ let pendingRemoteStopEvent = null;
201
179
  let conductor = null;
202
180
  let reconnectResumeInFlight = false;
203
181
 
@@ -234,6 +212,21 @@ async function main() {
234
212
  })();
235
213
  };
236
214
 
215
+ const handleStopTaskCommand = async (event) => {
216
+ if (!event || typeof event !== "object") {
217
+ return;
218
+ }
219
+ const taskId = typeof event.taskId === "string" ? event.taskId : "";
220
+ if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
221
+ return;
222
+ }
223
+ if (reconnectRunner && typeof reconnectRunner.requestStopFromRemote === "function") {
224
+ await reconnectRunner.requestStopFromRemote(event);
225
+ return;
226
+ }
227
+ pendingRemoteStopEvent = event;
228
+ };
229
+
237
230
  if (cliArgs.configFile) {
238
231
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
239
232
  }
@@ -252,111 +245,159 @@ async function main() {
252
245
  // Ignore config loading errors, rely on env vars or defaults
253
246
  }
254
247
 
255
- conductor = await ConductorClient.connect({
256
- projectPath: CLI_PROJECT_PATH,
257
- extraEnv: env,
258
- configFile: cliArgs.configFile,
259
- onConnected: scheduleReconnectRecovery,
260
- });
261
-
262
- const taskContext = await ensureTaskContext(conductor, {
263
- initialPrompt: cliArgs.initialPrompt,
264
- requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
265
- providedTaskId: process.env.CONDUCTOR_TASK_ID,
266
- requestedTitle: cliArgs.taskTitle || process.env.CONDUCTOR_TASK_TITLE,
267
- backend: cliArgs.backend,
268
- });
269
-
270
- log(
271
- `Attached to Conductor task ${taskContext.taskId}${
272
- taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
273
- }`,
274
- );
275
- reconnectTaskId = taskContext.taskId;
276
-
277
248
  try {
278
- await conductor.sendAgentResume({
279
- active_tasks: [taskContext.taskId],
280
- source: "conductor-fire",
281
- metadata: { reconnect: false },
249
+ const requestedTaskTitle = resolveRequestedTaskTitle({
250
+ cliTaskTitle: cliArgs.taskTitle,
251
+ hasExplicitTaskTitle: cliArgs.hasExplicitTaskTitle,
252
+ envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
253
+ runtimeProjectPath,
282
254
  });
283
- } catch (error) {
284
- log(`Failed to report agent resume: ${error?.message || error}`);
285
- }
286
255
 
287
- const runner = new BridgeRunner({
288
- backendSession,
289
- conductor,
290
- taskId: taskContext.taskId,
291
- pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
292
- initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
293
- includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
294
- cliArgs: cliArgs.rawBackendArgs,
295
- backendName: cliArgs.backend,
296
- });
297
- reconnectRunner = runner;
256
+ conductor = await ConductorClient.connect({
257
+ projectPath: runtimeProjectPath,
258
+ extraEnv: env,
259
+ configFile: cliArgs.configFile,
260
+ onConnected: scheduleReconnectRecovery,
261
+ onStopTask: handleStopTaskCommand,
262
+ });
298
263
 
299
- const signals = new AbortController();
300
- let shutdownSignal = null;
301
- const onSigint = () => {
302
- shutdownSignal = shutdownSignal || "SIGINT";
303
- signals.abort();
304
- };
305
- const onSigterm = () => {
306
- shutdownSignal = shutdownSignal || "SIGTERM";
307
- signals.abort();
308
- };
309
- process.on("SIGINT", onSigint);
310
- process.on("SIGTERM", onSigterm);
264
+ const taskContext = await ensureTaskContext(conductor, {
265
+ initialPrompt: cliArgs.initialPrompt,
266
+ requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
267
+ providedTaskId: process.env.CONDUCTOR_TASK_ID,
268
+ requestedTitle: requestedTaskTitle,
269
+ backend: cliArgs.backend,
270
+ });
271
+
272
+ log(
273
+ `Attached to Conductor task ${taskContext.taskId}${
274
+ taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
275
+ }`,
276
+ );
277
+ reconnectTaskId = taskContext.taskId;
311
278
 
312
- if (!launchedByDaemon) {
313
279
  try {
314
- await conductor.sendTaskStatus(taskContext.taskId, {
315
- status: "RUNNING",
280
+ await conductor.sendAgentResume({
281
+ active_tasks: [taskContext.taskId],
282
+ source: "conductor-fire",
283
+ metadata: { reconnect: false },
316
284
  });
317
285
  } catch (error) {
318
- log(`Failed to report task status (RUNNING): ${error?.message || error}`);
286
+ log(`Failed to report agent resume: ${error?.message || error}`);
287
+ }
288
+
289
+ const runner = new BridgeRunner({
290
+ backendSession,
291
+ conductor,
292
+ taskId: taskContext.taskId,
293
+ pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
294
+ initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
295
+ includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
296
+ cliArgs: cliArgs.rawBackendArgs,
297
+ backendName: cliArgs.backend,
298
+ resumeMode: Boolean(cliArgs.resumeSessionId),
299
+ });
300
+ reconnectRunner = runner;
301
+ if (pendingRemoteStopEvent) {
302
+ await runner.requestStopFromRemote(pendingRemoteStopEvent);
303
+ pendingRemoteStopEvent = null;
319
304
  }
320
- }
321
305
 
322
- let runnerError = null;
323
- try {
324
- await runner.start(signals.signal);
325
- } catch (error) {
326
- runnerError = error;
327
- throw error;
328
- } finally {
329
- process.off("SIGINT", onSigint);
330
- process.off("SIGTERM", onSigterm);
306
+ const signals = new AbortController();
307
+ let shutdownSignal = null;
308
+ let backendShutdownRequested = false;
309
+ const requestBackendShutdown = (source) => {
310
+ if (backendShutdownRequested) {
311
+ return;
312
+ }
313
+ backendShutdownRequested = true;
314
+ void (async () => {
315
+ try {
316
+ await backendSession.close();
317
+ } catch (error) {
318
+ log(`Failed to close backend session after ${source}: ${error?.message || error}`);
319
+ }
320
+ })();
321
+ };
322
+ const onSigint = () => {
323
+ shutdownSignal = shutdownSignal || "SIGINT";
324
+ signals.abort();
325
+ requestBackendShutdown("SIGINT");
326
+ };
327
+ const onSigterm = () => {
328
+ shutdownSignal = shutdownSignal || "SIGTERM";
329
+ signals.abort();
330
+ requestBackendShutdown("SIGTERM");
331
+ };
332
+ process.on("SIGINT", onSigint);
333
+ process.on("SIGTERM", onSigterm);
334
+
331
335
  if (!launchedByDaemon) {
332
- const finalStatus = shutdownSignal
333
- ? {
334
- status: "KILLED",
335
- summary: `terminated by ${shutdownSignal}`,
336
- }
337
- : runnerError
336
+ try {
337
+ await conductor.sendTaskStatus(taskContext.taskId, {
338
+ status: "RUNNING",
339
+ });
340
+ } catch (error) {
341
+ log(`Failed to report task status (RUNNING): ${error?.message || error}`);
342
+ }
343
+ }
344
+
345
+ let runnerError = null;
346
+ try {
347
+ await runner.start(signals.signal);
348
+ } catch (error) {
349
+ runnerError = error;
350
+ throw error;
351
+ } finally {
352
+ process.off("SIGINT", onSigint);
353
+ process.off("SIGTERM", onSigterm);
354
+ if (!launchedByDaemon) {
355
+ const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
356
+ const finalStatus = shutdownSignal
338
357
  ? {
339
358
  status: "KILLED",
340
- summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
359
+ summary: `terminated by ${shutdownSignal}`,
341
360
  }
342
- : {
343
- status: "COMPLETED",
344
- summary: "conductor fire exited",
345
- };
346
- try {
347
- await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
348
- } catch (error) {
349
- log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
361
+ : runnerError
362
+ ? {
363
+ status: "KILLED",
364
+ summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
365
+ }
366
+ : remoteStopSummary
367
+ ? {
368
+ status: "KILLED",
369
+ summary: remoteStopSummary,
370
+ }
371
+ : {
372
+ status: "COMPLETED",
373
+ summary: "conductor fire exited",
374
+ };
375
+ try {
376
+ await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
377
+ } catch (error) {
378
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
379
+ }
380
+ }
381
+ if (shutdownSignal === "SIGINT") {
382
+ process.exitCode = 130;
383
+ } else if (shutdownSignal === "SIGTERM") {
384
+ process.exitCode = 143;
350
385
  }
351
386
  }
387
+ } finally {
352
388
  if (typeof backendSession.close === "function") {
353
- await backendSession.close();
389
+ try {
390
+ await backendSession.close();
391
+ } catch (error) {
392
+ log(`Failed to close backend session: ${error?.message || error}`);
393
+ }
354
394
  }
355
- await conductor.close();
356
- if (shutdownSignal === "SIGINT") {
357
- process.exitCode = 130;
358
- } else if (shutdownSignal === "SIGTERM") {
359
- process.exitCode = 143;
395
+ if (conductor && typeof conductor.close === "function") {
396
+ try {
397
+ await conductor.close();
398
+ } catch (error) {
399
+ log(`Failed to close conductor connection: ${error?.message || error}`);
400
+ }
360
401
  }
361
402
  }
362
403
  }
@@ -374,6 +415,65 @@ function extractConfigFileFromArgv(argv) {
374
415
  return undefined;
375
416
  }
376
417
 
418
+ function hasLegacyFromFlags(argv = []) {
419
+ return argv.some(
420
+ (arg) =>
421
+ arg === "--from" ||
422
+ arg.startsWith("--from=") ||
423
+ arg === "--from-provider" ||
424
+ arg.startsWith("--from-provider="),
425
+ );
426
+ }
427
+
428
+ const CONDUCTOR_BOOLEAN_FLAGS = new Set([
429
+ "--list-backends",
430
+ "--version",
431
+ "-v",
432
+ "--help",
433
+ "-h",
434
+ ]);
435
+
436
+ const CONDUCTOR_VALUE_FLAGS = new Set([
437
+ "--backend",
438
+ "-b",
439
+ "--config-file",
440
+ "--poll-interval",
441
+ "--title",
442
+ "-t",
443
+ "--resume",
444
+ "--prefill",
445
+ ]);
446
+
447
+ function stripConductorArgsFromArgv(argv = []) {
448
+ const backendArgs = [];
449
+ for (let i = 0; i < argv.length; i += 1) {
450
+ const raw = String(argv[i] ?? "");
451
+ if (!raw) {
452
+ continue;
453
+ }
454
+ if (raw === "--") {
455
+ backendArgs.push(...argv.slice(i + 1));
456
+ break;
457
+ }
458
+
459
+ const eqIndex = raw.indexOf("=");
460
+ const flag = eqIndex > 0 ? raw.slice(0, eqIndex) : raw;
461
+
462
+ if (CONDUCTOR_BOOLEAN_FLAGS.has(flag)) {
463
+ continue;
464
+ }
465
+ if (CONDUCTOR_VALUE_FLAGS.has(flag)) {
466
+ if (eqIndex < 0) {
467
+ i += 1;
468
+ }
469
+ continue;
470
+ }
471
+
472
+ backendArgs.push(raw);
473
+ }
474
+ return backendArgs;
475
+ }
476
+
377
477
  export function parseCliArgs(argvInput = process.argv) {
378
478
  const rawArgv = Array.isArray(argvInput) ? argvInput : process.argv;
379
479
  const argv = hideBin(rawArgv);
@@ -381,12 +481,15 @@ export function parseCliArgs(argvInput = process.argv) {
381
481
 
382
482
  // When no separator, parse all args first to check for conductor-specific options
383
483
  const conductorArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
384
- const backendArgv = separatorIndex === -1 ? argv : argv.slice(separatorIndex + 1);
484
+ const backendArgv = separatorIndex === -1 ? stripConductorArgsFromArgv(argv) : argv.slice(separatorIndex + 1);
385
485
 
386
486
  // Check for version/list-backends/help without separator
387
487
  const versionWithoutSeparator = separatorIndex === -1 && (argv.includes("--version") || argv.includes("-v"));
388
488
  const listBackendsWithoutSeparator = separatorIndex === -1 && argv.includes("--list-backends");
389
489
  const helpWithoutSeparator = separatorIndex === -1 && (argv.includes("--help") || argv.includes("-h"));
490
+ if (hasLegacyFromFlags(conductorArgv)) {
491
+ throw new Error("--from and --from-provider were removed. Use --resume <session-id>.");
492
+ }
390
493
 
391
494
  const configFileFromArgs = extractConfigFileFromArgv(argv);
392
495
  const allowCliList = loadAllowCliList(configFileFromArgs);
@@ -419,15 +522,9 @@ export function parseCliArgs(argvInput = process.argv) {
419
522
  type: "string",
420
523
  describe: "Optional task title shown in the app task list",
421
524
  })
422
- .option("from", {
423
- alias: "f",
525
+ .option("resume", {
424
526
  type: "string",
425
- describe: "Resume from local history (optional session id; otherwise pick interactively)",
426
- })
427
- .option("from-provider", {
428
- type: "string",
429
- describe: "Provider for --from picker (defaults to --backend)",
430
- choices: SUPPORTED_FROM_PROVIDERS,
527
+ describe: "Resume from a backend session ID",
431
528
  })
432
529
  .option("prefill", {
433
530
  type: "string",
@@ -460,8 +557,7 @@ Options:
460
557
  --config-file <path> Path to Conductor config file
461
558
  --poll-interval <ms> Polling interval when waiting for Conductor messages
462
559
  -t, --title <text> Optional task title shown in the app task list
463
- -f, --from [id] Resume from local history (pick if id omitted)
464
- --from-provider <p> Provider for --from picker (codex or claude)
560
+ --resume <id> Resume from an existing backend session
465
561
  -v, --version Show Conductor CLI version and exit
466
562
  -h, --help Show this help message
467
563
 
@@ -475,6 +571,7 @@ Examples:
475
571
  ${CLI_NAME} -- "fix the bug" # Use default backend
476
572
  ${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
477
573
  ${CLI_NAME} --backend copilot -- "fix the bug" # Use GitHub Copilot CLI backend
574
+ ${CLI_NAME} --backend codex --resume <id> # Resume Codex session
478
575
  ${CLI_NAME} --list-backends # Show configured backends
479
576
  ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
480
577
 
@@ -504,11 +601,13 @@ Environment:
504
601
  ? Number(conductorArgs.pollInterval)
505
602
  : DEFAULT_POLL_INTERVAL_MS;
506
603
 
507
- let fromSpec = null;
508
- try {
509
- fromSpec = parseFromSpec(conductorArgs.from, conductorArgs.fromProvider, backend);
510
- } catch (error) {
511
- throw new Error(error.message);
604
+ const resumeRaw = conductorArgs.resume;
605
+ if (resumeRaw === true) {
606
+ throw new Error("--resume requires a session id");
607
+ }
608
+ const resumeSessionId = typeof resumeRaw === "string" ? resumeRaw.trim() : "";
609
+ if (resumeRaw !== undefined && !resumeSessionId) {
610
+ throw new Error("--resume requires a session id");
512
611
  }
513
612
 
514
613
  return {
@@ -517,22 +616,34 @@ Environment:
517
616
  initialImages,
518
617
  pollIntervalMs,
519
618
  rawBackendArgs: backendArgv,
520
- taskTitle: resolveTaskTitle(conductorArgs.title),
619
+ taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
620
+ hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
521
621
  configFile: conductorArgs.configFile,
522
- fromSpec,
622
+ resumeSessionId,
523
623
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
524
624
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
525
625
  };
526
626
  }
527
627
 
528
- function resolveTaskTitle(titleFlag) {
628
+ export function resolveTaskTitle(titleFlag, defaultPath = process.cwd()) {
529
629
  if (typeof titleFlag === "string" && titleFlag.trim()) {
530
630
  return titleFlag.trim();
531
631
  }
532
- const cwdName = path.basename(CLI_PROJECT_PATH || process.cwd());
632
+ const targetPath = typeof defaultPath === "string" ? defaultPath.trim() : "";
633
+ const cwdName = path.basename(targetPath || process.cwd());
533
634
  return cwdName || "";
534
635
  }
535
636
 
637
+ export function resolveRequestedTaskTitle({
638
+ cliTaskTitle,
639
+ hasExplicitTaskTitle,
640
+ envTaskTitle,
641
+ runtimeProjectPath,
642
+ }) {
643
+ const explicit = hasExplicitTaskTitle ? cliTaskTitle : envTaskTitle;
644
+ return resolveTaskTitle(explicit, runtimeProjectPath);
645
+ }
646
+
536
647
  function normalizeArray(value) {
537
648
  if (!value) {
538
649
  return [];
@@ -655,6 +766,7 @@ function deriveTaskTitle(prompt, explicit, backend = "codex") {
655
766
 
656
767
  const BACKEND_PROFILE_MAP = {
657
768
  codex: "codex",
769
+ code: "codex",
658
770
  claude: "claude-code",
659
771
  "claude-code": "claude-code",
660
772
  copilot: "copilot",
@@ -677,6 +789,228 @@ function parseCommandParts(commandLine) {
677
789
  };
678
790
  }
679
791
 
792
+ export function buildResumeArgsForBackend(backend, sessionId) {
793
+ const resumeSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
794
+ if (!resumeSessionId) {
795
+ return [];
796
+ }
797
+ const normalizedBackend = String(backend || "").trim().toLowerCase();
798
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
799
+ return ["resume", resumeSessionId];
800
+ }
801
+ if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
802
+ return ["--resume", resumeSessionId];
803
+ }
804
+ if (normalizedBackend === "copilot") {
805
+ return [`--resume=${resumeSessionId}`];
806
+ }
807
+ throw new Error(`--resume is not supported for backend "${backend}"`);
808
+ }
809
+
810
+ export function resumeProviderForBackend(backend) {
811
+ const normalizedBackend = String(backend || "").trim().toLowerCase();
812
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
813
+ return "codex";
814
+ }
815
+ if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
816
+ return "claude";
817
+ }
818
+ if (normalizedBackend === "copilot") {
819
+ return "copilot";
820
+ }
821
+ return null;
822
+ }
823
+
824
+ export async function resolveSessionRunDirectory(sessionPath) {
825
+ const normalizedPath = typeof sessionPath === "string" ? sessionPath.trim() : "";
826
+ if (!normalizedPath) {
827
+ throw new Error("Invalid session path");
828
+ }
829
+ let stats;
830
+ try {
831
+ stats = await fs.promises.stat(normalizedPath);
832
+ } catch {
833
+ throw new Error(`Session path does not exist: ${normalizedPath}`);
834
+ }
835
+ return stats.isDirectory() ? normalizedPath : path.dirname(normalizedPath);
836
+ }
837
+
838
+ async function isExistingDirectory(targetPath) {
839
+ const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
840
+ if (!normalizedPath) {
841
+ return false;
842
+ }
843
+ try {
844
+ const stats = await fs.promises.stat(normalizedPath);
845
+ return stats.isDirectory();
846
+ } catch {
847
+ return false;
848
+ }
849
+ }
850
+
851
+ async function extractCodexResumeCwd(sessionPath) {
852
+ if (!sessionPath.endsWith(".jsonl")) {
853
+ return null;
854
+ }
855
+ const rl = readline.createInterface({
856
+ input: fs.createReadStream(sessionPath),
857
+ crlfDelay: Infinity,
858
+ });
859
+ for await (const line of rl) {
860
+ const trimmed = line.trim();
861
+ if (!trimmed) {
862
+ continue;
863
+ }
864
+ let entry;
865
+ try {
866
+ entry = JSON.parse(trimmed);
867
+ } catch {
868
+ continue;
869
+ }
870
+ const maybeCwd = entry?.type === "session_meta" ? entry?.payload?.cwd : null;
871
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
872
+ return maybeCwd.trim();
873
+ }
874
+ }
875
+ return null;
876
+ }
877
+
878
+ async function extractClaudeResumeCwd(sessionPath, sessionId) {
879
+ if (!sessionPath.endsWith(".jsonl")) {
880
+ return null;
881
+ }
882
+ const rl = readline.createInterface({
883
+ input: fs.createReadStream(sessionPath),
884
+ crlfDelay: Infinity,
885
+ });
886
+ for await (const line of rl) {
887
+ const trimmed = line.trim();
888
+ if (!trimmed) {
889
+ continue;
890
+ }
891
+ let entry;
892
+ try {
893
+ entry = JSON.parse(trimmed);
894
+ } catch {
895
+ continue;
896
+ }
897
+ const idMatches = String(entry?.sessionId || "").trim() === sessionId;
898
+ const maybeCwd = entry?.cwd;
899
+ if (idMatches && typeof maybeCwd === "string" && maybeCwd.trim()) {
900
+ return maybeCwd.trim();
901
+ }
902
+ }
903
+ return null;
904
+ }
905
+
906
+ async function extractCopilotResumeCwd(sessionPath) {
907
+ let stats;
908
+ try {
909
+ stats = await fs.promises.stat(sessionPath);
910
+ } catch {
911
+ return null;
912
+ }
913
+
914
+ if (stats.isDirectory()) {
915
+ const workspaceYamlPath = path.join(sessionPath, "workspace.yaml");
916
+ try {
917
+ const yamlContent = await fs.promises.readFile(workspaceYamlPath, "utf8");
918
+ const parsed = yaml.load(yamlContent);
919
+ const maybeCwd = parsed && typeof parsed === "object" ? parsed.cwd : null;
920
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
921
+ return maybeCwd.trim();
922
+ }
923
+ } catch {
924
+ return null;
925
+ }
926
+ return null;
927
+ }
928
+
929
+ if (!sessionPath.endsWith(".jsonl")) {
930
+ return null;
931
+ }
932
+
933
+ const rl = readline.createInterface({
934
+ input: fs.createReadStream(sessionPath),
935
+ crlfDelay: Infinity,
936
+ });
937
+ for await (const line of rl) {
938
+ const trimmed = line.trim();
939
+ if (!trimmed) {
940
+ continue;
941
+ }
942
+ let entry;
943
+ try {
944
+ entry = JSON.parse(trimmed);
945
+ } catch {
946
+ continue;
947
+ }
948
+ const maybeCwd = entry?.data?.context?.cwd || entry?.data?.cwd;
949
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
950
+ return maybeCwd.trim();
951
+ }
952
+ }
953
+ return null;
954
+ }
955
+
956
+ async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
957
+ if (provider === "codex") {
958
+ return extractCodexResumeCwd(sessionPath);
959
+ }
960
+ if (provider === "claude") {
961
+ return extractClaudeResumeCwd(sessionPath, sessionId);
962
+ }
963
+ if (provider === "copilot") {
964
+ return extractCopilotResumeCwd(sessionPath);
965
+ }
966
+ return null;
967
+ }
968
+
969
+ export async function resolveResumeContext(backend, sessionId, options = {}) {
970
+ const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
971
+ if (!normalizedSessionId) {
972
+ throw new Error("--resume requires a session id");
973
+ }
974
+ const provider = resumeProviderForBackend(backend);
975
+ if (!provider) {
976
+ throw new Error(`--resume is not supported for backend "${backend}"`);
977
+ }
978
+
979
+ const sessionPath = await findSessionPath(provider, normalizedSessionId, options);
980
+ if (!sessionPath) {
981
+ throw new Error(`Invalid --resume session id for ${provider}: ${normalizedSessionId}`);
982
+ }
983
+
984
+ const cwdFromSession = await extractResumeCwdFromSession(provider, sessionPath, normalizedSessionId);
985
+ const fallbackCwd = await resolveSessionRunDirectory(sessionPath);
986
+ const cwd = cwdFromSession || fallbackCwd;
987
+ if (!(await isExistingDirectory(cwd))) {
988
+ throw new Error(`Resume workspace path does not exist: ${cwd}`);
989
+ }
990
+ return {
991
+ provider,
992
+ sessionId: normalizedSessionId,
993
+ sessionPath,
994
+ cwd,
995
+ };
996
+ }
997
+
998
+ export async function applyWorkingDirectory(targetPath) {
999
+ const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
1000
+ if (!normalizedPath) {
1001
+ throw new Error("Cannot switch working directory: empty path");
1002
+ }
1003
+ if (!(await isExistingDirectory(normalizedPath))) {
1004
+ throw new Error(`Cannot switch working directory: ${normalizedPath}`);
1005
+ }
1006
+ try {
1007
+ process.chdir(normalizedPath);
1008
+ } catch (error) {
1009
+ throw new Error(`Cannot switch working directory to ${normalizedPath}: ${error?.message || error}`);
1010
+ }
1011
+ return process.cwd();
1012
+ }
1013
+
680
1014
  function truncateText(value, maxLen = 240) {
681
1015
  if (!value) return "";
682
1016
  const text = String(value).trim();
@@ -690,11 +1024,48 @@ function isTruthyEnv(value) {
690
1024
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
691
1025
  }
692
1026
 
1027
+ function isSessionClosedError(error) {
1028
+ return Boolean(error && typeof error === "object" && error.reason === "session_closed");
1029
+ }
1030
+
693
1031
  function sanitizeForLog(value, maxLen = 180) {
694
1032
  if (!value) return "";
695
1033
  return truncateText(String(value).replace(/\s+/g, " ").trim(), maxLen);
696
1034
  }
697
1035
 
1036
+ function getBoundedEnvInt(envName, fallback, min, max) {
1037
+ const fallbackNumber = Number(fallback);
1038
+ const normalizedFallback = Number.isFinite(fallbackNumber)
1039
+ ? Math.min(Math.max(Math.round(fallbackNumber), min), max)
1040
+ : min;
1041
+ const raw = process.env[envName];
1042
+ const parsed = Number.parseInt(String(raw ?? ""), 10);
1043
+ if (!Number.isFinite(parsed)) {
1044
+ return normalizedFallback;
1045
+ }
1046
+ return Math.min(Math.max(parsed, min), max);
1047
+ }
1048
+
1049
+ function normalizeExecutionErrorKey(errorMessage) {
1050
+ const normalized = sanitizeForLog(errorMessage, 280).toLowerCase();
1051
+ if (!normalized) {
1052
+ return "unknown_error";
1053
+ }
1054
+ if (normalized.includes("pty session already spawned")) {
1055
+ return "pty_session_already_spawned";
1056
+ }
1057
+ if (normalized.includes("tui process has exited")) {
1058
+ return "tui_process_exited";
1059
+ }
1060
+ if (normalized.includes("cannot proceed: tui process has exited")) {
1061
+ return "cannot_proceed_tui_exited";
1062
+ }
1063
+ if (normalized.includes("turn exceeded hard deadline")) {
1064
+ return "turn_timeout";
1065
+ }
1066
+ return normalized;
1067
+ }
1068
+
698
1069
  function tailLines(value, count = 6) {
699
1070
  if (!value) return "";
700
1071
  const lines = String(value).split(/\r?\n/);
@@ -705,9 +1076,15 @@ class TuiDriverSession {
705
1076
  constructor(backend, options = {}) {
706
1077
  this.backend = backend;
707
1078
  this.options = options;
708
- this.sessionId = options.resumeSessionId || `${backend}-${Date.now()}`;
1079
+ this.cwd =
1080
+ typeof options.cwd === "string" && options.cwd.trim()
1081
+ ? options.cwd.trim()
1082
+ : INITIAL_CLI_PROJECT_PATH;
1083
+ const resumeSessionId = typeof options.resumeSessionId === "string" ? options.resumeSessionId.trim() : "";
1084
+ this.sessionId = resumeSessionId || `${backend}-${Date.now()}`;
709
1085
  this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
710
1086
  this.pendingHistorySeed = this.history.length > 0;
1087
+ this.sessionInfo = null;
711
1088
 
712
1089
  const allowCliList = options.configFile ? loadAllowCliList(options.configFile) : DEFAULT_ALLOW_CLI_LIST;
713
1090
  const cliCommand = CUSTOM_CLI_COMMAND || allowCliList[backend] || backend;
@@ -715,8 +1092,9 @@ class TuiDriverSession {
715
1092
  if (!command) {
716
1093
  throw new Error(`Invalid command for backend "${backend}"`);
717
1094
  }
1095
+ const resumeArgs = buildResumeArgsForBackend(backend, resumeSessionId);
718
1096
  this.command = command;
719
- this.args = args;
1097
+ this.args = [...args, ...resumeArgs];
720
1098
  this.tuiDebug = isTruthyEnv(process.env.CONDUCTOR_TUI_DEBUG);
721
1099
  this.tuiTrace = this.tuiDebug || isTruthyEnv(process.env.CONDUCTOR_TUI_TRACE);
722
1100
  this.tuiTraceLines = Number.isFinite(Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES || "", 10))
@@ -725,6 +1103,15 @@ class TuiDriverSession {
725
1103
  this.lastSignalSignature = "";
726
1104
  this.lastPollSignature = "";
727
1105
  this.lastSnapshotHash = "";
1106
+ this.closeRequested = false;
1107
+ this.closed = false;
1108
+ this.closeWaiters = new Set();
1109
+ this.turnDeadlineMs = getBoundedEnvInt(
1110
+ "CONDUCTOR_TURN_DEADLINE_MS",
1111
+ DEFAULT_TURN_DEADLINE_MS,
1112
+ MIN_TURN_DEADLINE_MS,
1113
+ MAX_TURN_DEADLINE_MS,
1114
+ );
728
1115
 
729
1116
  const profileName = profileNameForBackend(backend);
730
1117
  if (!profileName) {
@@ -745,7 +1132,7 @@ class TuiDriverSession {
745
1132
  log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
746
1133
  }
747
1134
 
748
- log(`Using TUI command for ${backend}: ${cliCommand}`);
1135
+ log(`Using TUI command for ${backend}: ${[this.command, ...this.args].join(" ")} (cwd: ${this.cwd})`);
749
1136
  if (this.tuiTrace) {
750
1137
  log(
751
1138
  `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(this.args)}`,
@@ -766,6 +1153,7 @@ class TuiDriverSession {
766
1153
  ...cliEnv,
767
1154
  },
768
1155
  },
1156
+ cwd: this.cwd,
769
1157
  debug: this.tuiDebug,
770
1158
  onSnapshot: this.tuiTrace
771
1159
  ? (snapshot, state) => {
@@ -787,6 +1175,10 @@ class TuiDriverSession {
787
1175
  }
788
1176
  log(`[${this.backend}] [WARN] Please run "${this.command} login" or authenticate manually.`);
789
1177
  });
1178
+
1179
+ this.driver.on("session", (session) => {
1180
+ this.applySessionInfo(session);
1181
+ });
790
1182
  }
791
1183
 
792
1184
  get threadId() {
@@ -797,7 +1189,157 @@ class TuiDriverSession {
797
1189
  return { model: this.backend };
798
1190
  }
799
1191
 
1192
+ applySessionInfo(session) {
1193
+ if (!session || typeof session !== "object") {
1194
+ return;
1195
+ }
1196
+ const sessionId = typeof session.sessionId === "string" ? session.sessionId.trim() : "";
1197
+ const sessionFilePath =
1198
+ typeof session.sessionFilePath === "string" ? session.sessionFilePath.trim() : "";
1199
+ if (!sessionId) {
1200
+ return;
1201
+ }
1202
+ this.sessionId = sessionId;
1203
+ this.sessionInfo = {
1204
+ backend: this.backend,
1205
+ sessionId,
1206
+ sessionFilePath: sessionFilePath || undefined,
1207
+ };
1208
+ this.trace(
1209
+ `session id=${sessionId} file="${sanitizeForLog(sessionFilePath, 180)}"`,
1210
+ );
1211
+ }
1212
+
1213
+ getSessionInfo() {
1214
+ if (this.sessionInfo) {
1215
+ return { ...this.sessionInfo };
1216
+ }
1217
+ return null;
1218
+ }
1219
+
1220
+ async ensureSessionInfo() {
1221
+ if (!this.driver) {
1222
+ return null;
1223
+ }
1224
+ try {
1225
+ await this.driver.boot();
1226
+ } catch (error) {
1227
+ this.trace(`session boot failed: ${sanitizeForLog(error?.message || error, 180)}`);
1228
+ return this.getSessionInfo();
1229
+ }
1230
+
1231
+ try {
1232
+ if (typeof this.driver.ensureSessionInfo === "function") {
1233
+ const detected = await this.driver.ensureSessionInfo();
1234
+ this.applySessionInfo(detected);
1235
+ } else if (typeof this.driver.getSessionInfo === "function") {
1236
+ this.applySessionInfo(this.driver.getSessionInfo());
1237
+ }
1238
+ } catch (error) {
1239
+ this.trace(`session detect failed: ${sanitizeForLog(error?.message || error, 180)}`);
1240
+ }
1241
+
1242
+ return this.getSessionInfo();
1243
+ }
1244
+
1245
+ async getSessionUsageSummary() {
1246
+ if (!this.driver || typeof this.driver.getSessionUsageSummary !== "function") {
1247
+ return null;
1248
+ }
1249
+ try {
1250
+ const summary = await this.driver.getSessionUsageSummary();
1251
+ return summary && typeof summary === "object" ? summary : null;
1252
+ } catch (error) {
1253
+ this.trace(`session usage detect failed: ${sanitizeForLog(error?.message || error, 180)}`);
1254
+ return null;
1255
+ }
1256
+ }
1257
+
1258
+ createSessionClosedError() {
1259
+ const error = new Error("TUI session closed");
1260
+ error.reason = "session_closed";
1261
+ return error;
1262
+ }
1263
+
1264
+ createTurnTimeoutError(timeoutMs) {
1265
+ const seconds = Math.max(1, Math.round(timeoutMs / 1000));
1266
+ const error = new Error(`Turn exceeded hard deadline (${seconds}s)`);
1267
+ error.reason = "turn_timeout";
1268
+ error.timeoutMs = timeoutMs;
1269
+ return error;
1270
+ }
1271
+
1272
+ createCloseGuard() {
1273
+ if (this.closeRequested) {
1274
+ return {
1275
+ promise: Promise.reject(this.createSessionClosedError()),
1276
+ cleanup: () => {},
1277
+ };
1278
+ }
1279
+ let waiter = null;
1280
+ const promise = new Promise((_, reject) => {
1281
+ waiter = () => {
1282
+ reject(this.createSessionClosedError());
1283
+ };
1284
+ this.closeWaiters.add(waiter);
1285
+ });
1286
+ return {
1287
+ promise,
1288
+ cleanup: () => {
1289
+ if (waiter) {
1290
+ this.closeWaiters.delete(waiter);
1291
+ }
1292
+ },
1293
+ };
1294
+ }
1295
+
1296
+ createTurnTimeoutGuard() {
1297
+ if (!Number.isFinite(this.turnDeadlineMs) || this.turnDeadlineMs <= 0) {
1298
+ return {
1299
+ promise: new Promise(() => {}),
1300
+ cleanup: () => {},
1301
+ };
1302
+ }
1303
+ let timer = null;
1304
+ const promise = new Promise((_, reject) => {
1305
+ timer = setTimeout(() => {
1306
+ reject(this.createTurnTimeoutError(this.turnDeadlineMs));
1307
+ }, this.turnDeadlineMs);
1308
+ if (typeof timer.unref === "function") {
1309
+ timer.unref();
1310
+ }
1311
+ });
1312
+ return {
1313
+ promise,
1314
+ cleanup: () => {
1315
+ if (timer) {
1316
+ clearTimeout(timer);
1317
+ }
1318
+ },
1319
+ };
1320
+ }
1321
+
1322
+ flushCloseWaiters() {
1323
+ if (!this.closeWaiters || this.closeWaiters.size === 0) {
1324
+ return;
1325
+ }
1326
+ for (const waiter of this.closeWaiters) {
1327
+ try {
1328
+ waiter();
1329
+ } catch {
1330
+ // best effort
1331
+ }
1332
+ }
1333
+ this.closeWaiters.clear();
1334
+ }
1335
+
800
1336
  async close() {
1337
+ if (this.closed) {
1338
+ return;
1339
+ }
1340
+ this.closed = true;
1341
+ this.closeRequested = true;
1342
+ this.flushCloseWaiters();
801
1343
  if (this.driver) {
802
1344
  this.driver.kill();
803
1345
  }
@@ -902,6 +1444,10 @@ class TuiDriverSession {
902
1444
  }
903
1445
 
904
1446
  async runTurn(promptText, { useInitialImages = false, onProgress } = {}) {
1447
+ if (this.closeRequested) {
1448
+ throw this.createSessionClosedError();
1449
+ }
1450
+
905
1451
  const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
906
1452
  if (!effectivePrompt) {
907
1453
  return {
@@ -958,8 +1504,20 @@ class TuiDriverSession {
958
1504
  signalTimer.unref();
959
1505
  }
960
1506
 
1507
+ const previousCwd = process.cwd();
1508
+ const shouldSwitchCwd = this.cwd && this.cwd !== previousCwd;
1509
+ if (shouldSwitchCwd) {
1510
+ try {
1511
+ process.chdir(this.cwd);
1512
+ } catch (error) {
1513
+ throw new Error(`Failed to switch backend cwd to ${this.cwd}: ${error?.message || error}`);
1514
+ }
1515
+ }
1516
+ const closeGuard = this.createCloseGuard();
1517
+ const turnTimeoutGuard = this.createTurnTimeoutGuard();
1518
+
961
1519
  try {
962
- const result = await this.driver.ask(effectivePrompt);
1520
+ const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise, turnTimeoutGuard.promise]);
963
1521
  const answer = String(result.answer || result.replyText || "").trim();
964
1522
  this.trace(
965
1523
  `runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
@@ -1003,9 +1561,28 @@ class TuiDriverSession {
1003
1561
  } catch (error) {
1004
1562
  const errorMessage = error instanceof Error ? error.message : String(error);
1005
1563
  const errorReason = error?.reason || "unknown";
1564
+ if (errorReason === "session_closed") {
1565
+ this.trace("runTurn interrupted because backend session is closing");
1566
+ throw error instanceof Error ? error : new Error(errorMessage);
1567
+ }
1006
1568
 
1007
- // 特殊处理登录和权限错误
1008
- if (errorReason === "login_required") {
1569
+ if (errorReason === "turn_timeout") {
1570
+ this.emitProgress(onProgress, {
1571
+ state: "ERROR",
1572
+ phase: "timeout_recovered",
1573
+ source: "tui-driver",
1574
+ error: errorMessage,
1575
+ reason: errorReason,
1576
+ timeout_ms: error?.timeoutMs,
1577
+ });
1578
+ log(`[${this.backend}] Turn timed out (${error?.timeoutMs || this.turnDeadlineMs}ms), restarting TUI session`);
1579
+ try {
1580
+ await this.driver.forceRestart();
1581
+ } catch (restartError) {
1582
+ log(`[${this.backend}] Failed to restart TUI after timeout: ${restartError?.message || restartError}`);
1583
+ }
1584
+ log(`[${this.backend}] Error: ${errorMessage}`);
1585
+ } else if (errorReason === "login_required") {
1009
1586
  this.emitProgress(onProgress, {
1010
1587
  state: "ERROR",
1011
1588
  phase: "login_required",
@@ -1037,21 +1614,35 @@ class TuiDriverSession {
1037
1614
  log(`[${this.backend}] Error: ${errorMessage}`);
1038
1615
  }
1039
1616
 
1040
- const latestSignals = this.driver.getSignals();
1617
+ let latestSignals = {};
1618
+ try {
1619
+ latestSignals = this.driver.getSignals();
1620
+ } catch {
1621
+ // driver may already be disposed while closing
1622
+ }
1041
1623
  const summary = this.formatSignalSummary(latestSignals);
1042
1624
  this.trace(
1043
1625
  `runTurn exception state=${this.driver.state} error="${sanitizeForLog(errorMessage, 220)}" status="${summary.status || ""}" done="${summary.done || ""}" preview="${summary.replyPreview || ""}"`,
1044
1626
  );
1045
1627
  throw error instanceof Error ? error : new Error(errorMessage);
1046
1628
  } finally {
1629
+ if (shouldSwitchCwd) {
1630
+ try {
1631
+ process.chdir(previousCwd);
1632
+ } catch (error) {
1633
+ log(`Failed to restore cwd to ${previousCwd}: ${error?.message || error}`);
1634
+ }
1635
+ }
1047
1636
  clearInterval(signalTimer);
1048
1637
  this.driver.off("stateChange", handleStateChange);
1638
+ turnTimeoutGuard.cleanup();
1639
+ closeGuard.cleanup();
1049
1640
  this.trace(`runTurn cleanup state=${this.driver.state}`);
1050
1641
  }
1051
1642
  }
1052
1643
  }
1053
1644
 
1054
- class BridgeRunner {
1645
+ export class BridgeRunner {
1055
1646
  constructor({
1056
1647
  backendSession,
1057
1648
  conductor,
@@ -1061,6 +1652,7 @@ class BridgeRunner {
1061
1652
  includeInitialImages,
1062
1653
  cliArgs,
1063
1654
  backendName,
1655
+ resumeMode,
1064
1656
  }) {
1065
1657
  this.backendSession = backendSession;
1066
1658
  this.conductor = conductor;
@@ -1070,26 +1662,122 @@ class BridgeRunner {
1070
1662
  this.includeInitialImages = includeInitialImages;
1071
1663
  this.cliArgs = cliArgs;
1072
1664
  this.backendName = backendName || "codex";
1665
+ this.resumeMode = Boolean(resumeMode);
1073
1666
  this.isCopilotBackend = String(this.backendName).toLowerCase() === "copilot";
1667
+ this.copilotDebug =
1668
+ this.isCopilotBackend &&
1669
+ (isTruthyEnv(process.env.CONDUCTOR_COPILOT_DEBUG) || isTruthyEnv(process.env.CONDUCTOR_DEBUG));
1074
1670
  this.stopped = false;
1075
1671
  this.runningTurn = false;
1076
1672
  this.processedMessageIds = new Set();
1673
+ this.inFlightMessageIds = new Set();
1077
1674
  this.lastRuntimeStatusSignature = null;
1078
1675
  this.lastRuntimeStatusPayload = null;
1676
+ this.runtimeContextSnapshot = null;
1677
+ this.runtimeContextSnapshotAt = 0;
1678
+ this.runtimeContextInFlight = null;
1679
+ this.runtimeContextRefreshMs = getBoundedEnvInt(
1680
+ "CONDUCTOR_RUNTIME_CONTEXT_REFRESH_MS",
1681
+ 2000,
1682
+ 500,
1683
+ 60 * 1000,
1684
+ );
1685
+ this.daemonName =
1686
+ (typeof process.env.CONDUCTOR_AGENT_NAME === "string" && process.env.CONDUCTOR_AGENT_NAME.trim()) ||
1687
+ (typeof process.env.CONDUCTOR_DAEMON_NAME === "string" && process.env.CONDUCTOR_DAEMON_NAME.trim()) ||
1688
+ (typeof process.env.HOSTNAME === "string" && process.env.HOSTNAME.trim()) ||
1689
+ os.hostname();
1079
1690
  this.needsReconnectRecovery = false;
1691
+ this.remoteStopInfo = null;
1692
+ this.sessionAnnouncementSent = false;
1693
+ this.errorLoop = null;
1694
+ this.errorLoopWindowMs = getBoundedEnvInt(
1695
+ "CONDUCTOR_ERROR_LOOP_WINDOW_MS",
1696
+ DEFAULT_ERROR_LOOP_WINDOW_MS,
1697
+ 15 * 1000,
1698
+ 30 * 60 * 1000,
1699
+ );
1700
+ this.errorLoopBackoffMs = getBoundedEnvInt(
1701
+ "CONDUCTOR_ERROR_LOOP_BACKOFF_MS",
1702
+ DEFAULT_ERROR_LOOP_BACKOFF_MS,
1703
+ 15 * 1000,
1704
+ 60 * 60 * 1000,
1705
+ );
1706
+ this.errorLoopThreshold = getBoundedEnvInt(
1707
+ "CONDUCTOR_ERROR_LOOP_THRESHOLD",
1708
+ DEFAULT_ERROR_LOOP_THRESHOLD,
1709
+ 2,
1710
+ 20,
1711
+ );
1080
1712
  }
1081
1713
 
1082
1714
  copilotLog(message) {
1083
- if (!this.isCopilotBackend) {
1715
+ if (!this.copilotDebug) {
1084
1716
  return;
1085
1717
  }
1086
1718
  log(`[copilot-debug] task=${this.taskId} ${message}`);
1087
1719
  }
1088
1720
 
1721
+ async announceBackendSession() {
1722
+ if (this.sessionAnnouncementSent) {
1723
+ return;
1724
+ }
1725
+ if (!this.backendSession || typeof this.backendSession.ensureSessionInfo !== "function") {
1726
+ return;
1727
+ }
1728
+ let sessionInfo = null;
1729
+ try {
1730
+ sessionInfo = await this.backendSession.ensureSessionInfo();
1731
+ } catch (error) {
1732
+ this.copilotLog(`session announce skipped: ${sanitizeForLog(error?.message || error, 160)}`);
1733
+ return;
1734
+ }
1735
+ const sessionId = String(sessionInfo?.sessionId || "").trim();
1736
+ const sessionFilePath = sessionInfo?.sessionFilePath ? String(sessionInfo.sessionFilePath).trim() : "";
1737
+ const hasRealSessionId = Boolean(sessionId);
1738
+ const message = hasRealSessionId
1739
+ ? `${this.backendName} session started: ${sessionId}`
1740
+ : `${this.backendName} session started`;
1741
+ try {
1742
+ await this.conductor.sendMessage(this.taskId, message, {
1743
+ backend: this.backendName,
1744
+ thread_id: hasRealSessionId ? sessionId : undefined,
1745
+ session_id: hasRealSessionId ? sessionId : undefined,
1746
+ session_file_path: sessionFilePath || undefined,
1747
+ cli_args: this.cliArgs,
1748
+ synthetic: true,
1749
+ });
1750
+ this.sessionAnnouncementSent = true;
1751
+ this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
1752
+ await this.reportRuntimeStatus(
1753
+ {
1754
+ state: "WAIT_READY",
1755
+ phase: "session_started",
1756
+ source: "tui-driver",
1757
+ reply_in_progress: false,
1758
+ status_done_line: `${this.backendName} session started`,
1759
+ backend: this.backendName,
1760
+ thread_id: hasRealSessionId ? sessionId : undefined,
1761
+ },
1762
+ undefined,
1763
+ );
1764
+ } catch (error) {
1765
+ log(`Failed to send session announcement: ${error?.message || error}`);
1766
+ }
1767
+ }
1768
+
1089
1769
  async start(abortSignal) {
1090
1770
  abortSignal?.addEventListener("abort", () => {
1091
1771
  this.stopped = true;
1092
1772
  });
1773
+ if (this.stopped) {
1774
+ return;
1775
+ }
1776
+
1777
+ await this.announceBackendSession();
1778
+ if (this.stopped) {
1779
+ return;
1780
+ }
1093
1781
 
1094
1782
  if (this.initialPrompt) {
1095
1783
  this.copilotLog("processing initial prompt");
@@ -1097,8 +1785,20 @@ class BridgeRunner {
1097
1785
  includeImages: this.includeInitialImages,
1098
1786
  });
1099
1787
  }
1788
+ if (this.stopped) {
1789
+ return;
1790
+ }
1791
+ if (this.resumeMode) {
1792
+ await this.drainBufferedMessagesForResume();
1793
+ }
1794
+ if (this.stopped) {
1795
+ return;
1796
+ }
1100
1797
  this.copilotLog("running startup backfill");
1101
1798
  await this.backfillPendingUserMessages();
1799
+ if (this.stopped) {
1800
+ return;
1801
+ }
1102
1802
 
1103
1803
  while (!this.stopped) {
1104
1804
  if (this.needsReconnectRecovery && !this.runningTurn) {
@@ -1108,10 +1808,13 @@ class BridgeRunner {
1108
1808
  try {
1109
1809
  processed = await this.processIncomingBatch();
1110
1810
  } catch (error) {
1811
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
1812
+ break;
1813
+ }
1111
1814
  log(`Error while processing messages: ${error.message}`);
1112
1815
  await this.reportError(`处理任务失败: ${error.message}`);
1113
1816
  }
1114
- if (!processed) {
1817
+ if (!processed && !this.stopped) {
1115
1818
  await delay(this.pollIntervalMs);
1116
1819
  }
1117
1820
  }
@@ -1121,6 +1824,42 @@ class BridgeRunner {
1121
1824
  this.needsReconnectRecovery = true;
1122
1825
  }
1123
1826
 
1827
+ getRemoteStopSummary() {
1828
+ if (!this.remoteStopInfo) {
1829
+ return null;
1830
+ }
1831
+ const reason = this.remoteStopInfo.reason || "killed by app";
1832
+ return `task stopped by app: ${reason}`;
1833
+ }
1834
+
1835
+ async requestStopFromRemote(event = {}) {
1836
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
1837
+ if (taskId && taskId !== this.taskId) {
1838
+ return;
1839
+ }
1840
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
1841
+ const reason = typeof event.reason === "string" ? event.reason.trim() : "";
1842
+ if (!this.remoteStopInfo) {
1843
+ this.remoteStopInfo = {
1844
+ requestId: requestId || null,
1845
+ reason: reason || null,
1846
+ };
1847
+ log(
1848
+ `Received stop_task for ${this.taskId}${
1849
+ reason ? ` (${reason})` : ""
1850
+ }; stopping conductor fire`,
1851
+ );
1852
+ }
1853
+ this.stopped = true;
1854
+ if (typeof this.backendSession?.close === "function") {
1855
+ try {
1856
+ await this.backendSession.close();
1857
+ } catch (error) {
1858
+ log(`Failed to stop backend session for ${this.taskId}: ${error?.message || error}`);
1859
+ }
1860
+ }
1861
+ }
1862
+
1124
1863
  async recoverAfterReconnect() {
1125
1864
  if (!this.needsReconnectRecovery) {
1126
1865
  return;
@@ -1137,6 +1876,41 @@ class BridgeRunner {
1137
1876
  await this.replayLastRuntimeStatus();
1138
1877
  }
1139
1878
 
1879
+ async drainBufferedMessagesForResume() {
1880
+ let drainedCount = 0;
1881
+ let drainedBatches = 0;
1882
+
1883
+ while (!this.stopped) {
1884
+ const result = await this.conductor.receiveMessages(this.taskId, 50);
1885
+ const messages = Array.isArray(result?.messages) ? result.messages : [];
1886
+ if (messages.length === 0) {
1887
+ break;
1888
+ }
1889
+
1890
+ drainedBatches += 1;
1891
+ for (const message of messages) {
1892
+ const replyTo = message?.message_id ? String(message.message_id) : "";
1893
+ if (replyTo) {
1894
+ this.processedMessageIds.add(replyTo);
1895
+ }
1896
+ drainedCount += 1;
1897
+ }
1898
+
1899
+ const ackToken = result.next_ack_token || result.nextAckToken;
1900
+ if (ackToken) {
1901
+ await this.conductor.ackMessages(this.taskId, ackToken);
1902
+ }
1903
+ }
1904
+
1905
+ if (drainedCount > 0) {
1906
+ log(
1907
+ `Resume startup skipped ${drainedCount} buffered message(s) in ${drainedBatches} batch(es) for task ${this.taskId}`,
1908
+ );
1909
+ } else {
1910
+ this.copilotLog("resume startup found no buffered messages to skip");
1911
+ }
1912
+ }
1913
+
1140
1914
  async processIncomingBatch() {
1141
1915
  const result = await this.conductor.receiveMessages(this.taskId, 20);
1142
1916
  const messages = Array.isArray(result?.messages) ? result.messages : [];
@@ -1151,6 +1925,9 @@ class BridgeRunner {
1151
1925
  );
1152
1926
 
1153
1927
  for (const message of messages) {
1928
+ if (this.stopped) {
1929
+ break;
1930
+ }
1154
1931
  if (!this.shouldRespond(message)) {
1155
1932
  this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
1156
1933
  continue;
@@ -1211,12 +1988,37 @@ class BridgeRunner {
1211
1988
  }
1212
1989
  }
1213
1990
 
1991
+ const historyUserIds = history
1992
+ .filter((item) => String(item?.role || "").toLowerCase() === "user")
1993
+ .map((item) => (item?.id ? String(item.id) : ""))
1994
+ .filter(Boolean);
1995
+
1996
+ const handledUserIds = history
1997
+ .slice(0, lastSdkIndex + 1)
1998
+ .filter((item) => String(item?.role || "").toLowerCase() === "user")
1999
+ .map((item) => (item?.id ? String(item.id) : ""))
2000
+ .filter(Boolean);
2001
+
2002
+ for (const handledId of handledUserIds) {
2003
+ this.processedMessageIds.add(handledId);
2004
+ }
2005
+
1214
2006
  const pendingUserMessages = history
1215
2007
  .slice(lastSdkIndex + 1)
1216
2008
  .filter((item) => String(item?.role || "").toLowerCase() === "user")
1217
2009
  .filter((item) => typeof item?.content === "string" && item.content.trim());
2010
+
2011
+ if (this.resumeMode) {
2012
+ for (const historyId of historyUserIds) {
2013
+ this.processedMessageIds.add(historyId);
2014
+ }
2015
+ this.copilotLog(
2016
+ `resume mode: seeded processed ids=${historyUserIds.length}, skip startup backfill replay`,
2017
+ );
2018
+ return;
2019
+ }
1218
2020
  this.copilotLog(
1219
- `backfill loaded history=${history.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
2021
+ `backfill loaded history=${history.length} handledUsers=${handledUserIds.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
1220
2022
  );
1221
2023
 
1222
2024
  for (const pending of pendingUserMessages) {
@@ -1232,7 +2034,80 @@ class BridgeRunner {
1232
2034
  }
1233
2035
  }
1234
2036
 
1235
- createRuntimeStatus(payload, replyTo) {
2037
+ normalizePercent(value) {
2038
+ if (!Number.isFinite(value)) {
2039
+ return undefined;
2040
+ }
2041
+ if (value < 0) {
2042
+ return 0;
2043
+ }
2044
+ if (value > 100) {
2045
+ return 100;
2046
+ }
2047
+ return value;
2048
+ }
2049
+
2050
+ async resolveRuntimeContext() {
2051
+ const now = Date.now();
2052
+ if (
2053
+ this.runtimeContextSnapshot &&
2054
+ now - this.runtimeContextSnapshotAt < this.runtimeContextRefreshMs
2055
+ ) {
2056
+ return this.runtimeContextSnapshot;
2057
+ }
2058
+ if (this.runtimeContextInFlight) {
2059
+ return this.runtimeContextInFlight;
2060
+ }
2061
+
2062
+ this.runtimeContextInFlight = (async () => {
2063
+ let sessionInfo = null;
2064
+ try {
2065
+ if (typeof this.backendSession?.getSessionInfo === "function") {
2066
+ sessionInfo = this.backendSession.getSessionInfo();
2067
+ }
2068
+ } catch {
2069
+ sessionInfo = null;
2070
+ }
2071
+
2072
+ let usage = null;
2073
+ try {
2074
+ if (typeof this.backendSession?.getSessionUsageSummary === "function") {
2075
+ usage = await this.backendSession.getSessionUsageSummary();
2076
+ }
2077
+ } catch {
2078
+ usage = null;
2079
+ }
2080
+
2081
+ const resolvedSessionId = String(
2082
+ usage?.sessionId || sessionInfo?.sessionId || "",
2083
+ ).trim();
2084
+ const resolvedSessionFilePath = String(
2085
+ usage?.sessionFilePath || sessionInfo?.sessionFilePath || "",
2086
+ ).trim();
2087
+ const tokenUsagePercent = this.normalizePercent(Number(usage?.tokenUsagePercent));
2088
+ const contextUsagePercent = this.normalizePercent(Number(usage?.contextUsagePercent));
2089
+
2090
+ const snapshot = {
2091
+ daemon: this.daemonName || undefined,
2092
+ pid: process.pid,
2093
+ session_id: resolvedSessionId || undefined,
2094
+ session_file_path: resolvedSessionFilePath || undefined,
2095
+ token_usage_percent: tokenUsagePercent,
2096
+ context_usage_percent: contextUsagePercent,
2097
+ };
2098
+ this.runtimeContextSnapshot = snapshot;
2099
+ this.runtimeContextSnapshotAt = Date.now();
2100
+ return snapshot;
2101
+ })();
2102
+
2103
+ try {
2104
+ return await this.runtimeContextInFlight;
2105
+ } finally {
2106
+ this.runtimeContextInFlight = null;
2107
+ }
2108
+ }
2109
+
2110
+ createRuntimeStatus(payload, replyTo, runtimeContext = null) {
1236
2111
  if (!payload || typeof payload !== "object") {
1237
2112
  return null;
1238
2113
  }
@@ -1257,12 +2132,22 @@ class BridgeRunner {
1257
2132
  reply_preview: truncateText(replyPreview, 240) || undefined,
1258
2133
  reply_to: replyTo,
1259
2134
  backend: this.backendName,
1260
- thread_id: this.backendSession.threadId,
2135
+ thread_id:
2136
+ String(
2137
+ payload.thread_id || runtimeContext?.session_id || "",
2138
+ ).trim() || undefined,
2139
+ daemon: runtimeContext?.daemon,
2140
+ pid: runtimeContext?.pid,
2141
+ session_id: runtimeContext?.session_id,
2142
+ session_file_path: runtimeContext?.session_file_path,
2143
+ token_usage_percent: runtimeContext?.token_usage_percent,
2144
+ context_usage_percent: runtimeContext?.context_usage_percent,
1261
2145
  };
1262
2146
  }
1263
2147
 
1264
2148
  async reportRuntimeStatus(payload, replyTo) {
1265
- const runtime = this.createRuntimeStatus(payload, replyTo);
2149
+ const runtimeContext = await this.resolveRuntimeContext();
2150
+ const runtime = this.createRuntimeStatus(payload, replyTo, runtimeContext);
1266
2151
  if (!runtime) {
1267
2152
  return;
1268
2153
  }
@@ -1305,6 +2190,67 @@ class BridgeRunner {
1305
2190
  }
1306
2191
  }
1307
2192
 
2193
+ resetErrorLoop() {
2194
+ this.errorLoop = null;
2195
+ }
2196
+
2197
+ evaluateErrorLoop(errorMessage) {
2198
+ const normalizedKey = normalizeExecutionErrorKey(errorMessage);
2199
+ const now = Date.now();
2200
+ const current = this.errorLoop;
2201
+
2202
+ if (
2203
+ !current ||
2204
+ current.key !== normalizedKey ||
2205
+ now - current.lastAt > this.errorLoopWindowMs
2206
+ ) {
2207
+ this.errorLoop = {
2208
+ key: normalizedKey,
2209
+ count: 1,
2210
+ lastAt: now,
2211
+ cooldownUntil: 0,
2212
+ };
2213
+ return {
2214
+ key: normalizedKey,
2215
+ count: 1,
2216
+ open: false,
2217
+ suppressReport: false,
2218
+ cooldownMs: 0,
2219
+ };
2220
+ }
2221
+
2222
+ current.count += 1;
2223
+ current.lastAt = now;
2224
+ const open = current.count >= this.errorLoopThreshold;
2225
+ let suppressReport = false;
2226
+
2227
+ if (open) {
2228
+ if (current.cooldownUntil > now) {
2229
+ suppressReport = true;
2230
+ } else {
2231
+ current.cooldownUntil = now + this.errorLoopBackoffMs;
2232
+ }
2233
+ }
2234
+
2235
+ this.errorLoop = current;
2236
+ return {
2237
+ key: normalizedKey,
2238
+ count: current.count,
2239
+ open,
2240
+ suppressReport,
2241
+ cooldownMs: suppressReport ? current.cooldownUntil - now : 0,
2242
+ };
2243
+ }
2244
+
2245
+ isExecutionFailureLoopError(errorMessage) {
2246
+ const normalized = String(errorMessage || "").toLowerCase();
2247
+ return (
2248
+ normalized.includes("pty session already spawned") ||
2249
+ normalized.includes("tui process has exited") ||
2250
+ normalized.includes("cannot proceed: tui process has exited")
2251
+ );
2252
+ }
2253
+
1308
2254
  async respondToMessage(message) {
1309
2255
  const content = String(message.content || "").trim();
1310
2256
  if (!content) {
@@ -1316,6 +2262,13 @@ class BridgeRunner {
1316
2262
  this.copilotLog(`skip duplicated message replyTo=${replyTo}`);
1317
2263
  return;
1318
2264
  }
2265
+ if (replyTo && this.inFlightMessageIds.has(replyTo)) {
2266
+ this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
2267
+ return;
2268
+ }
2269
+ if (replyTo) {
2270
+ this.inFlightMessageIds.add(replyTo);
2271
+ }
1319
2272
  this.lastRuntimeStatusSignature = null;
1320
2273
  this.runningTurn = true;
1321
2274
  const turnStartedAt = Date.now();
@@ -1385,9 +2338,16 @@ class BridgeRunner {
1385
2338
  if (replyTo) {
1386
2339
  this.processedMessageIds.add(replyTo);
1387
2340
  }
2341
+ this.resetErrorLoop();
1388
2342
  this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
1389
2343
  } catch (error) {
1390
2344
  const errorMessage = error instanceof Error ? error.message : String(error);
2345
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2346
+ this.copilotLog(
2347
+ `turn interrupted by stop_task replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt}`,
2348
+ );
2349
+ return;
2350
+ }
1391
2351
  this.copilotLog(
1392
2352
  `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
1393
2353
  );
@@ -1400,11 +2360,48 @@ class BridgeRunner {
1400
2360
  },
1401
2361
  replyTo,
1402
2362
  );
1403
- await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
2363
+ const isExecutionFailure = this.isExecutionFailureLoopError(errorMessage);
2364
+ const loopState = isExecutionFailure
2365
+ ? this.evaluateErrorLoop(errorMessage)
2366
+ : {
2367
+ key: "non_execution_error",
2368
+ count: 1,
2369
+ open: false,
2370
+ suppressReport: false,
2371
+ cooldownMs: 0,
2372
+ };
2373
+ if (!isExecutionFailure) {
2374
+ this.resetErrorLoop();
2375
+ }
2376
+ const executionFailureLoop = isExecutionFailure && loopState.open;
2377
+ if (executionFailureLoop) {
2378
+ await this.reportRuntimeStatus(
2379
+ {
2380
+ state: "ERROR",
2381
+ phase: "execution_failure_loop",
2382
+ reply_in_progress: false,
2383
+ status_done_line: `${this.backendName} execution_failure_loop`,
2384
+ },
2385
+ replyTo,
2386
+ );
2387
+ }
2388
+ if (isExecutionFailure && loopState.suppressReport) {
2389
+ this.copilotLog(
2390
+ `suppress repeated error report key=${loopState.key} count=${loopState.count} cooldownMs=${loopState.cooldownMs}`,
2391
+ );
2392
+ return;
2393
+ }
2394
+ const reportMessage = executionFailureLoop
2395
+ ? `${this.backendName} 执行层失败循环(${loopState.key}, 连续${loopState.count}次): ${errorMessage}`
2396
+ : `${this.backendName} 处理失败: ${errorMessage}`;
2397
+ await this.reportError(reportMessage, replyTo);
1404
2398
  } finally {
1405
2399
  if (turnWatchdog) {
1406
2400
  clearInterval(turnWatchdog);
1407
2401
  }
2402
+ if (replyTo) {
2403
+ this.inFlightMessageIds.delete(replyTo);
2404
+ }
1408
2405
  this.copilotLog(
1409
2406
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
1410
2407
  );
@@ -1446,6 +2443,10 @@ class BridgeRunner {
1446
2443
  });
1447
2444
  this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
1448
2445
  } catch (error) {
2446
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2447
+ this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
2448
+ return;
2449
+ }
1449
2450
  this.copilotLog(
1450
2451
  `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(error?.message || error, 200)}"`,
1451
2452
  );
@@ -1519,6 +2520,22 @@ function logBackendReply(backend, text, { usage, replyTo }) {
1519
2520
  log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
1520
2521
  }
1521
2522
 
2523
+ export function formatFatalError(error, opts = {}) {
2524
+ const showStack =
2525
+ typeof opts.showStack === "boolean"
2526
+ ? opts.showStack
2527
+ : isTruthyEnv(process.env.CONDUCTOR_CLI_SHOW_STACK) ||
2528
+ isTruthyEnv(process.env.CONDUCTOR_DEBUG);
2529
+ const message = error instanceof Error ? error.message : String(error);
2530
+ if (!showStack) {
2531
+ return message;
2532
+ }
2533
+ if (error instanceof Error && typeof error.stack === "string" && error.stack.trim()) {
2534
+ return error.stack;
2535
+ }
2536
+ return message;
2537
+ }
2538
+
1522
2539
  function isDirectRun() {
1523
2540
  try {
1524
2541
  if (!process.argv[1]) {
@@ -1535,7 +2552,7 @@ function isDirectRun() {
1535
2552
  if (isDirectRun()) {
1536
2553
  main().catch((error) => {
1537
2554
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1538
- const line = `[${CLI_NAME} ${ts}] failed: ${error.stack || error.message}\n`;
2555
+ const line = `[${CLI_NAME} ${ts}] failed: ${formatFatalError(error)}\n`;
1539
2556
  process.stderr.write(line);
1540
2557
  appendFireLocalLog(line);
1541
2558
  process.exitCode = 1;