@love-moon/conductor-cli 0.2.11 → 0.2.12

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"));
@@ -121,6 +117,7 @@ function appendFireLocalLog(line) {
121
117
 
122
118
  async function main() {
123
119
  const cliArgs = parseCliArgs();
120
+ let runtimeProjectPath = process.cwd();
124
121
 
125
122
  if (cliArgs.showHelp) {
126
123
  return;
@@ -147,48 +144,22 @@ async function main() {
147
144
  return;
148
145
  }
149
146
 
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
- }
147
+ let resumeContext = null;
148
+ if (cliArgs.resumeSessionId) {
149
+ resumeContext = await resolveResumeContext(cliArgs.backend, cliArgs.resumeSessionId);
150
+ log(
151
+ `Validated --resume ${resumeContext.sessionId} (${resumeContext.provider}) at ${resumeContext.sessionPath}`,
152
+ );
153
+ log(`Resume will run backend from ${resumeContext.cwd}`);
154
+ runtimeProjectPath = await applyWorkingDirectory(resumeContext.cwd);
155
+ log(`Switched working directory to ${runtimeProjectPath} before Conductor connect`);
184
156
  }
185
157
 
186
158
  // Create backend session using tui-driver
187
159
  const backendSession = new TuiDriverSession(cliArgs.backend, {
188
160
  initialImages: cliArgs.initialImages,
189
- cwd: CLI_PROJECT_PATH,
190
- initialHistory: fromHistory.history,
191
- resumeSessionId: fromHistory.sessionId,
161
+ cwd: runtimeProjectPath,
162
+ resumeSessionId: cliArgs.resumeSessionId,
192
163
  configFile: cliArgs.configFile,
193
164
  });
194
165
 
@@ -198,6 +169,7 @@ async function main() {
198
169
  const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
199
170
  let reconnectRunner = null;
200
171
  let reconnectTaskId = null;
172
+ let pendingRemoteStopEvent = null;
201
173
  let conductor = null;
202
174
  let reconnectResumeInFlight = false;
203
175
 
@@ -234,6 +206,21 @@ async function main() {
234
206
  })();
235
207
  };
236
208
 
209
+ const handleStopTaskCommand = async (event) => {
210
+ if (!event || typeof event !== "object") {
211
+ return;
212
+ }
213
+ const taskId = typeof event.taskId === "string" ? event.taskId : "";
214
+ if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
215
+ return;
216
+ }
217
+ if (reconnectRunner && typeof reconnectRunner.requestStopFromRemote === "function") {
218
+ await reconnectRunner.requestStopFromRemote(event);
219
+ return;
220
+ }
221
+ pendingRemoteStopEvent = event;
222
+ };
223
+
237
224
  if (cliArgs.configFile) {
238
225
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
239
226
  }
@@ -252,111 +239,159 @@ async function main() {
252
239
  // Ignore config loading errors, rely on env vars or defaults
253
240
  }
254
241
 
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
242
  try {
278
- await conductor.sendAgentResume({
279
- active_tasks: [taskContext.taskId],
280
- source: "conductor-fire",
281
- metadata: { reconnect: false },
243
+ const requestedTaskTitle = resolveRequestedTaskTitle({
244
+ cliTaskTitle: cliArgs.taskTitle,
245
+ hasExplicitTaskTitle: cliArgs.hasExplicitTaskTitle,
246
+ envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
247
+ runtimeProjectPath,
282
248
  });
283
- } catch (error) {
284
- log(`Failed to report agent resume: ${error?.message || error}`);
285
- }
286
249
 
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;
250
+ conductor = await ConductorClient.connect({
251
+ projectPath: runtimeProjectPath,
252
+ extraEnv: env,
253
+ configFile: cliArgs.configFile,
254
+ onConnected: scheduleReconnectRecovery,
255
+ onStopTask: handleStopTaskCommand,
256
+ });
298
257
 
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);
258
+ const taskContext = await ensureTaskContext(conductor, {
259
+ initialPrompt: cliArgs.initialPrompt,
260
+ requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
261
+ providedTaskId: process.env.CONDUCTOR_TASK_ID,
262
+ requestedTitle: requestedTaskTitle,
263
+ backend: cliArgs.backend,
264
+ });
265
+
266
+ log(
267
+ `Attached to Conductor task ${taskContext.taskId}${
268
+ taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
269
+ }`,
270
+ );
271
+ reconnectTaskId = taskContext.taskId;
311
272
 
312
- if (!launchedByDaemon) {
313
273
  try {
314
- await conductor.sendTaskStatus(taskContext.taskId, {
315
- status: "RUNNING",
274
+ await conductor.sendAgentResume({
275
+ active_tasks: [taskContext.taskId],
276
+ source: "conductor-fire",
277
+ metadata: { reconnect: false },
316
278
  });
317
279
  } catch (error) {
318
- log(`Failed to report task status (RUNNING): ${error?.message || error}`);
280
+ log(`Failed to report agent resume: ${error?.message || error}`);
281
+ }
282
+
283
+ const runner = new BridgeRunner({
284
+ backendSession,
285
+ conductor,
286
+ taskId: taskContext.taskId,
287
+ pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
288
+ initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
289
+ includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
290
+ cliArgs: cliArgs.rawBackendArgs,
291
+ backendName: cliArgs.backend,
292
+ resumeMode: Boolean(cliArgs.resumeSessionId),
293
+ });
294
+ reconnectRunner = runner;
295
+ if (pendingRemoteStopEvent) {
296
+ await runner.requestStopFromRemote(pendingRemoteStopEvent);
297
+ pendingRemoteStopEvent = null;
319
298
  }
320
- }
321
299
 
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);
300
+ const signals = new AbortController();
301
+ let shutdownSignal = null;
302
+ let backendShutdownRequested = false;
303
+ const requestBackendShutdown = (source) => {
304
+ if (backendShutdownRequested) {
305
+ return;
306
+ }
307
+ backendShutdownRequested = true;
308
+ void (async () => {
309
+ try {
310
+ await backendSession.close();
311
+ } catch (error) {
312
+ log(`Failed to close backend session after ${source}: ${error?.message || error}`);
313
+ }
314
+ })();
315
+ };
316
+ const onSigint = () => {
317
+ shutdownSignal = shutdownSignal || "SIGINT";
318
+ signals.abort();
319
+ requestBackendShutdown("SIGINT");
320
+ };
321
+ const onSigterm = () => {
322
+ shutdownSignal = shutdownSignal || "SIGTERM";
323
+ signals.abort();
324
+ requestBackendShutdown("SIGTERM");
325
+ };
326
+ process.on("SIGINT", onSigint);
327
+ process.on("SIGTERM", onSigterm);
328
+
331
329
  if (!launchedByDaemon) {
332
- const finalStatus = shutdownSignal
333
- ? {
334
- status: "KILLED",
335
- summary: `terminated by ${shutdownSignal}`,
336
- }
337
- : runnerError
330
+ try {
331
+ await conductor.sendTaskStatus(taskContext.taskId, {
332
+ status: "RUNNING",
333
+ });
334
+ } catch (error) {
335
+ log(`Failed to report task status (RUNNING): ${error?.message || error}`);
336
+ }
337
+ }
338
+
339
+ let runnerError = null;
340
+ try {
341
+ await runner.start(signals.signal);
342
+ } catch (error) {
343
+ runnerError = error;
344
+ throw error;
345
+ } finally {
346
+ process.off("SIGINT", onSigint);
347
+ process.off("SIGTERM", onSigterm);
348
+ if (!launchedByDaemon) {
349
+ const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
350
+ const finalStatus = shutdownSignal
338
351
  ? {
339
352
  status: "KILLED",
340
- summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
353
+ summary: `terminated by ${shutdownSignal}`,
341
354
  }
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}`);
355
+ : runnerError
356
+ ? {
357
+ status: "KILLED",
358
+ summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
359
+ }
360
+ : remoteStopSummary
361
+ ? {
362
+ status: "KILLED",
363
+ summary: remoteStopSummary,
364
+ }
365
+ : {
366
+ status: "COMPLETED",
367
+ summary: "conductor fire exited",
368
+ };
369
+ try {
370
+ await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
371
+ } catch (error) {
372
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
373
+ }
374
+ }
375
+ if (shutdownSignal === "SIGINT") {
376
+ process.exitCode = 130;
377
+ } else if (shutdownSignal === "SIGTERM") {
378
+ process.exitCode = 143;
350
379
  }
351
380
  }
381
+ } finally {
352
382
  if (typeof backendSession.close === "function") {
353
- await backendSession.close();
383
+ try {
384
+ await backendSession.close();
385
+ } catch (error) {
386
+ log(`Failed to close backend session: ${error?.message || error}`);
387
+ }
354
388
  }
355
- await conductor.close();
356
- if (shutdownSignal === "SIGINT") {
357
- process.exitCode = 130;
358
- } else if (shutdownSignal === "SIGTERM") {
359
- process.exitCode = 143;
389
+ if (conductor && typeof conductor.close === "function") {
390
+ try {
391
+ await conductor.close();
392
+ } catch (error) {
393
+ log(`Failed to close conductor connection: ${error?.message || error}`);
394
+ }
360
395
  }
361
396
  }
362
397
  }
@@ -374,6 +409,65 @@ function extractConfigFileFromArgv(argv) {
374
409
  return undefined;
375
410
  }
376
411
 
412
+ function hasLegacyFromFlags(argv = []) {
413
+ return argv.some(
414
+ (arg) =>
415
+ arg === "--from" ||
416
+ arg.startsWith("--from=") ||
417
+ arg === "--from-provider" ||
418
+ arg.startsWith("--from-provider="),
419
+ );
420
+ }
421
+
422
+ const CONDUCTOR_BOOLEAN_FLAGS = new Set([
423
+ "--list-backends",
424
+ "--version",
425
+ "-v",
426
+ "--help",
427
+ "-h",
428
+ ]);
429
+
430
+ const CONDUCTOR_VALUE_FLAGS = new Set([
431
+ "--backend",
432
+ "-b",
433
+ "--config-file",
434
+ "--poll-interval",
435
+ "--title",
436
+ "-t",
437
+ "--resume",
438
+ "--prefill",
439
+ ]);
440
+
441
+ function stripConductorArgsFromArgv(argv = []) {
442
+ const backendArgs = [];
443
+ for (let i = 0; i < argv.length; i += 1) {
444
+ const raw = String(argv[i] ?? "");
445
+ if (!raw) {
446
+ continue;
447
+ }
448
+ if (raw === "--") {
449
+ backendArgs.push(...argv.slice(i + 1));
450
+ break;
451
+ }
452
+
453
+ const eqIndex = raw.indexOf("=");
454
+ const flag = eqIndex > 0 ? raw.slice(0, eqIndex) : raw;
455
+
456
+ if (CONDUCTOR_BOOLEAN_FLAGS.has(flag)) {
457
+ continue;
458
+ }
459
+ if (CONDUCTOR_VALUE_FLAGS.has(flag)) {
460
+ if (eqIndex < 0) {
461
+ i += 1;
462
+ }
463
+ continue;
464
+ }
465
+
466
+ backendArgs.push(raw);
467
+ }
468
+ return backendArgs;
469
+ }
470
+
377
471
  export function parseCliArgs(argvInput = process.argv) {
378
472
  const rawArgv = Array.isArray(argvInput) ? argvInput : process.argv;
379
473
  const argv = hideBin(rawArgv);
@@ -381,12 +475,15 @@ export function parseCliArgs(argvInput = process.argv) {
381
475
 
382
476
  // When no separator, parse all args first to check for conductor-specific options
383
477
  const conductorArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
384
- const backendArgv = separatorIndex === -1 ? argv : argv.slice(separatorIndex + 1);
478
+ const backendArgv = separatorIndex === -1 ? stripConductorArgsFromArgv(argv) : argv.slice(separatorIndex + 1);
385
479
 
386
480
  // Check for version/list-backends/help without separator
387
481
  const versionWithoutSeparator = separatorIndex === -1 && (argv.includes("--version") || argv.includes("-v"));
388
482
  const listBackendsWithoutSeparator = separatorIndex === -1 && argv.includes("--list-backends");
389
483
  const helpWithoutSeparator = separatorIndex === -1 && (argv.includes("--help") || argv.includes("-h"));
484
+ if (hasLegacyFromFlags(conductorArgv)) {
485
+ throw new Error("--from and --from-provider were removed. Use --resume <session-id>.");
486
+ }
390
487
 
391
488
  const configFileFromArgs = extractConfigFileFromArgv(argv);
392
489
  const allowCliList = loadAllowCliList(configFileFromArgs);
@@ -419,15 +516,9 @@ export function parseCliArgs(argvInput = process.argv) {
419
516
  type: "string",
420
517
  describe: "Optional task title shown in the app task list",
421
518
  })
422
- .option("from", {
423
- alias: "f",
519
+ .option("resume", {
424
520
  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,
521
+ describe: "Resume from a backend session ID",
431
522
  })
432
523
  .option("prefill", {
433
524
  type: "string",
@@ -460,8 +551,7 @@ Options:
460
551
  --config-file <path> Path to Conductor config file
461
552
  --poll-interval <ms> Polling interval when waiting for Conductor messages
462
553
  -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)
554
+ --resume <id> Resume from an existing backend session
465
555
  -v, --version Show Conductor CLI version and exit
466
556
  -h, --help Show this help message
467
557
 
@@ -475,6 +565,7 @@ Examples:
475
565
  ${CLI_NAME} -- "fix the bug" # Use default backend
476
566
  ${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
477
567
  ${CLI_NAME} --backend copilot -- "fix the bug" # Use GitHub Copilot CLI backend
568
+ ${CLI_NAME} --backend codex --resume <id> # Resume Codex session
478
569
  ${CLI_NAME} --list-backends # Show configured backends
479
570
  ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
480
571
 
@@ -504,11 +595,13 @@ Environment:
504
595
  ? Number(conductorArgs.pollInterval)
505
596
  : DEFAULT_POLL_INTERVAL_MS;
506
597
 
507
- let fromSpec = null;
508
- try {
509
- fromSpec = parseFromSpec(conductorArgs.from, conductorArgs.fromProvider, backend);
510
- } catch (error) {
511
- throw new Error(error.message);
598
+ const resumeRaw = conductorArgs.resume;
599
+ if (resumeRaw === true) {
600
+ throw new Error("--resume requires a session id");
601
+ }
602
+ const resumeSessionId = typeof resumeRaw === "string" ? resumeRaw.trim() : "";
603
+ if (resumeRaw !== undefined && !resumeSessionId) {
604
+ throw new Error("--resume requires a session id");
512
605
  }
513
606
 
514
607
  return {
@@ -517,22 +610,34 @@ Environment:
517
610
  initialImages,
518
611
  pollIntervalMs,
519
612
  rawBackendArgs: backendArgv,
520
- taskTitle: resolveTaskTitle(conductorArgs.title),
613
+ taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
614
+ hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
521
615
  configFile: conductorArgs.configFile,
522
- fromSpec,
616
+ resumeSessionId,
523
617
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
524
618
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
525
619
  };
526
620
  }
527
621
 
528
- function resolveTaskTitle(titleFlag) {
622
+ export function resolveTaskTitle(titleFlag, defaultPath = process.cwd()) {
529
623
  if (typeof titleFlag === "string" && titleFlag.trim()) {
530
624
  return titleFlag.trim();
531
625
  }
532
- const cwdName = path.basename(CLI_PROJECT_PATH || process.cwd());
626
+ const targetPath = typeof defaultPath === "string" ? defaultPath.trim() : "";
627
+ const cwdName = path.basename(targetPath || process.cwd());
533
628
  return cwdName || "";
534
629
  }
535
630
 
631
+ export function resolveRequestedTaskTitle({
632
+ cliTaskTitle,
633
+ hasExplicitTaskTitle,
634
+ envTaskTitle,
635
+ runtimeProjectPath,
636
+ }) {
637
+ const explicit = hasExplicitTaskTitle ? cliTaskTitle : envTaskTitle;
638
+ return resolveTaskTitle(explicit, runtimeProjectPath);
639
+ }
640
+
536
641
  function normalizeArray(value) {
537
642
  if (!value) {
538
643
  return [];
@@ -655,6 +760,7 @@ function deriveTaskTitle(prompt, explicit, backend = "codex") {
655
760
 
656
761
  const BACKEND_PROFILE_MAP = {
657
762
  codex: "codex",
763
+ code: "codex",
658
764
  claude: "claude-code",
659
765
  "claude-code": "claude-code",
660
766
  copilot: "copilot",
@@ -677,6 +783,228 @@ function parseCommandParts(commandLine) {
677
783
  };
678
784
  }
679
785
 
786
+ export function buildResumeArgsForBackend(backend, sessionId) {
787
+ const resumeSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
788
+ if (!resumeSessionId) {
789
+ return [];
790
+ }
791
+ const normalizedBackend = String(backend || "").trim().toLowerCase();
792
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
793
+ return ["resume", resumeSessionId];
794
+ }
795
+ if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
796
+ return ["--resume", resumeSessionId];
797
+ }
798
+ if (normalizedBackend === "copilot") {
799
+ return [`--resume=${resumeSessionId}`];
800
+ }
801
+ throw new Error(`--resume is not supported for backend "${backend}"`);
802
+ }
803
+
804
+ export function resumeProviderForBackend(backend) {
805
+ const normalizedBackend = String(backend || "").trim().toLowerCase();
806
+ if (normalizedBackend === "codex" || normalizedBackend === "code") {
807
+ return "codex";
808
+ }
809
+ if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
810
+ return "claude";
811
+ }
812
+ if (normalizedBackend === "copilot") {
813
+ return "copilot";
814
+ }
815
+ return null;
816
+ }
817
+
818
+ export async function resolveSessionRunDirectory(sessionPath) {
819
+ const normalizedPath = typeof sessionPath === "string" ? sessionPath.trim() : "";
820
+ if (!normalizedPath) {
821
+ throw new Error("Invalid session path");
822
+ }
823
+ let stats;
824
+ try {
825
+ stats = await fs.promises.stat(normalizedPath);
826
+ } catch {
827
+ throw new Error(`Session path does not exist: ${normalizedPath}`);
828
+ }
829
+ return stats.isDirectory() ? normalizedPath : path.dirname(normalizedPath);
830
+ }
831
+
832
+ async function isExistingDirectory(targetPath) {
833
+ const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
834
+ if (!normalizedPath) {
835
+ return false;
836
+ }
837
+ try {
838
+ const stats = await fs.promises.stat(normalizedPath);
839
+ return stats.isDirectory();
840
+ } catch {
841
+ return false;
842
+ }
843
+ }
844
+
845
+ async function extractCodexResumeCwd(sessionPath) {
846
+ if (!sessionPath.endsWith(".jsonl")) {
847
+ return null;
848
+ }
849
+ const rl = readline.createInterface({
850
+ input: fs.createReadStream(sessionPath),
851
+ crlfDelay: Infinity,
852
+ });
853
+ for await (const line of rl) {
854
+ const trimmed = line.trim();
855
+ if (!trimmed) {
856
+ continue;
857
+ }
858
+ let entry;
859
+ try {
860
+ entry = JSON.parse(trimmed);
861
+ } catch {
862
+ continue;
863
+ }
864
+ const maybeCwd = entry?.type === "session_meta" ? entry?.payload?.cwd : null;
865
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
866
+ return maybeCwd.trim();
867
+ }
868
+ }
869
+ return null;
870
+ }
871
+
872
+ async function extractClaudeResumeCwd(sessionPath, sessionId) {
873
+ if (!sessionPath.endsWith(".jsonl")) {
874
+ return null;
875
+ }
876
+ const rl = readline.createInterface({
877
+ input: fs.createReadStream(sessionPath),
878
+ crlfDelay: Infinity,
879
+ });
880
+ for await (const line of rl) {
881
+ const trimmed = line.trim();
882
+ if (!trimmed) {
883
+ continue;
884
+ }
885
+ let entry;
886
+ try {
887
+ entry = JSON.parse(trimmed);
888
+ } catch {
889
+ continue;
890
+ }
891
+ const idMatches = String(entry?.sessionId || "").trim() === sessionId;
892
+ const maybeCwd = entry?.cwd;
893
+ if (idMatches && typeof maybeCwd === "string" && maybeCwd.trim()) {
894
+ return maybeCwd.trim();
895
+ }
896
+ }
897
+ return null;
898
+ }
899
+
900
+ async function extractCopilotResumeCwd(sessionPath) {
901
+ let stats;
902
+ try {
903
+ stats = await fs.promises.stat(sessionPath);
904
+ } catch {
905
+ return null;
906
+ }
907
+
908
+ if (stats.isDirectory()) {
909
+ const workspaceYamlPath = path.join(sessionPath, "workspace.yaml");
910
+ try {
911
+ const yamlContent = await fs.promises.readFile(workspaceYamlPath, "utf8");
912
+ const parsed = yaml.load(yamlContent);
913
+ const maybeCwd = parsed && typeof parsed === "object" ? parsed.cwd : null;
914
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
915
+ return maybeCwd.trim();
916
+ }
917
+ } catch {
918
+ return null;
919
+ }
920
+ return null;
921
+ }
922
+
923
+ if (!sessionPath.endsWith(".jsonl")) {
924
+ return null;
925
+ }
926
+
927
+ const rl = readline.createInterface({
928
+ input: fs.createReadStream(sessionPath),
929
+ crlfDelay: Infinity,
930
+ });
931
+ for await (const line of rl) {
932
+ const trimmed = line.trim();
933
+ if (!trimmed) {
934
+ continue;
935
+ }
936
+ let entry;
937
+ try {
938
+ entry = JSON.parse(trimmed);
939
+ } catch {
940
+ continue;
941
+ }
942
+ const maybeCwd = entry?.data?.context?.cwd || entry?.data?.cwd;
943
+ if (typeof maybeCwd === "string" && maybeCwd.trim()) {
944
+ return maybeCwd.trim();
945
+ }
946
+ }
947
+ return null;
948
+ }
949
+
950
+ async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
951
+ if (provider === "codex") {
952
+ return extractCodexResumeCwd(sessionPath);
953
+ }
954
+ if (provider === "claude") {
955
+ return extractClaudeResumeCwd(sessionPath, sessionId);
956
+ }
957
+ if (provider === "copilot") {
958
+ return extractCopilotResumeCwd(sessionPath);
959
+ }
960
+ return null;
961
+ }
962
+
963
+ export async function resolveResumeContext(backend, sessionId, options = {}) {
964
+ const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
965
+ if (!normalizedSessionId) {
966
+ throw new Error("--resume requires a session id");
967
+ }
968
+ const provider = resumeProviderForBackend(backend);
969
+ if (!provider) {
970
+ throw new Error(`--resume is not supported for backend "${backend}"`);
971
+ }
972
+
973
+ const sessionPath = await findSessionPath(provider, normalizedSessionId, options);
974
+ if (!sessionPath) {
975
+ throw new Error(`Invalid --resume session id for ${provider}: ${normalizedSessionId}`);
976
+ }
977
+
978
+ const cwdFromSession = await extractResumeCwdFromSession(provider, sessionPath, normalizedSessionId);
979
+ const fallbackCwd = await resolveSessionRunDirectory(sessionPath);
980
+ const cwd = cwdFromSession || fallbackCwd;
981
+ if (!(await isExistingDirectory(cwd))) {
982
+ throw new Error(`Resume workspace path does not exist: ${cwd}`);
983
+ }
984
+ return {
985
+ provider,
986
+ sessionId: normalizedSessionId,
987
+ sessionPath,
988
+ cwd,
989
+ };
990
+ }
991
+
992
+ export async function applyWorkingDirectory(targetPath) {
993
+ const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
994
+ if (!normalizedPath) {
995
+ throw new Error("Cannot switch working directory: empty path");
996
+ }
997
+ if (!(await isExistingDirectory(normalizedPath))) {
998
+ throw new Error(`Cannot switch working directory: ${normalizedPath}`);
999
+ }
1000
+ try {
1001
+ process.chdir(normalizedPath);
1002
+ } catch (error) {
1003
+ throw new Error(`Cannot switch working directory to ${normalizedPath}: ${error?.message || error}`);
1004
+ }
1005
+ return process.cwd();
1006
+ }
1007
+
680
1008
  function truncateText(value, maxLen = 240) {
681
1009
  if (!value) return "";
682
1010
  const text = String(value).trim();
@@ -690,6 +1018,10 @@ function isTruthyEnv(value) {
690
1018
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
691
1019
  }
692
1020
 
1021
+ function isSessionClosedError(error) {
1022
+ return Boolean(error && typeof error === "object" && error.reason === "session_closed");
1023
+ }
1024
+
693
1025
  function sanitizeForLog(value, maxLen = 180) {
694
1026
  if (!value) return "";
695
1027
  return truncateText(String(value).replace(/\s+/g, " ").trim(), maxLen);
@@ -705,7 +1037,12 @@ class TuiDriverSession {
705
1037
  constructor(backend, options = {}) {
706
1038
  this.backend = backend;
707
1039
  this.options = options;
708
- this.sessionId = options.resumeSessionId || `${backend}-${Date.now()}`;
1040
+ this.cwd =
1041
+ typeof options.cwd === "string" && options.cwd.trim()
1042
+ ? options.cwd.trim()
1043
+ : INITIAL_CLI_PROJECT_PATH;
1044
+ const resumeSessionId = typeof options.resumeSessionId === "string" ? options.resumeSessionId.trim() : "";
1045
+ this.sessionId = resumeSessionId || `${backend}-${Date.now()}`;
709
1046
  this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
710
1047
  this.pendingHistorySeed = this.history.length > 0;
711
1048
 
@@ -715,8 +1052,9 @@ class TuiDriverSession {
715
1052
  if (!command) {
716
1053
  throw new Error(`Invalid command for backend "${backend}"`);
717
1054
  }
1055
+ const resumeArgs = buildResumeArgsForBackend(backend, resumeSessionId);
718
1056
  this.command = command;
719
- this.args = args;
1057
+ this.args = [...args, ...resumeArgs];
720
1058
  this.tuiDebug = isTruthyEnv(process.env.CONDUCTOR_TUI_DEBUG);
721
1059
  this.tuiTrace = this.tuiDebug || isTruthyEnv(process.env.CONDUCTOR_TUI_TRACE);
722
1060
  this.tuiTraceLines = Number.isFinite(Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES || "", 10))
@@ -725,6 +1063,9 @@ class TuiDriverSession {
725
1063
  this.lastSignalSignature = "";
726
1064
  this.lastPollSignature = "";
727
1065
  this.lastSnapshotHash = "";
1066
+ this.closeRequested = false;
1067
+ this.closed = false;
1068
+ this.closeWaiters = new Set();
728
1069
 
729
1070
  const profileName = profileNameForBackend(backend);
730
1071
  if (!profileName) {
@@ -745,7 +1086,7 @@ class TuiDriverSession {
745
1086
  log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
746
1087
  }
747
1088
 
748
- log(`Using TUI command for ${backend}: ${cliCommand}`);
1089
+ log(`Using TUI command for ${backend}: ${[this.command, ...this.args].join(" ")} (cwd: ${this.cwd})`);
749
1090
  if (this.tuiTrace) {
750
1091
  log(
751
1092
  `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(this.args)}`,
@@ -766,6 +1107,7 @@ class TuiDriverSession {
766
1107
  ...cliEnv,
767
1108
  },
768
1109
  },
1110
+ cwd: this.cwd,
769
1111
  debug: this.tuiDebug,
770
1112
  onSnapshot: this.tuiTrace
771
1113
  ? (snapshot, state) => {
@@ -797,7 +1139,57 @@ class TuiDriverSession {
797
1139
  return { model: this.backend };
798
1140
  }
799
1141
 
1142
+ createSessionClosedError() {
1143
+ const error = new Error("TUI session closed");
1144
+ error.reason = "session_closed";
1145
+ return error;
1146
+ }
1147
+
1148
+ createCloseGuard() {
1149
+ if (this.closeRequested) {
1150
+ return {
1151
+ promise: Promise.reject(this.createSessionClosedError()),
1152
+ cleanup: () => {},
1153
+ };
1154
+ }
1155
+ let waiter = null;
1156
+ const promise = new Promise((_, reject) => {
1157
+ waiter = () => {
1158
+ reject(this.createSessionClosedError());
1159
+ };
1160
+ this.closeWaiters.add(waiter);
1161
+ });
1162
+ return {
1163
+ promise,
1164
+ cleanup: () => {
1165
+ if (waiter) {
1166
+ this.closeWaiters.delete(waiter);
1167
+ }
1168
+ },
1169
+ };
1170
+ }
1171
+
1172
+ flushCloseWaiters() {
1173
+ if (!this.closeWaiters || this.closeWaiters.size === 0) {
1174
+ return;
1175
+ }
1176
+ for (const waiter of this.closeWaiters) {
1177
+ try {
1178
+ waiter();
1179
+ } catch {
1180
+ // best effort
1181
+ }
1182
+ }
1183
+ this.closeWaiters.clear();
1184
+ }
1185
+
800
1186
  async close() {
1187
+ if (this.closed) {
1188
+ return;
1189
+ }
1190
+ this.closed = true;
1191
+ this.closeRequested = true;
1192
+ this.flushCloseWaiters();
801
1193
  if (this.driver) {
802
1194
  this.driver.kill();
803
1195
  }
@@ -902,6 +1294,10 @@ class TuiDriverSession {
902
1294
  }
903
1295
 
904
1296
  async runTurn(promptText, { useInitialImages = false, onProgress } = {}) {
1297
+ if (this.closeRequested) {
1298
+ throw this.createSessionClosedError();
1299
+ }
1300
+
905
1301
  const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
906
1302
  if (!effectivePrompt) {
907
1303
  return {
@@ -958,8 +1354,19 @@ class TuiDriverSession {
958
1354
  signalTimer.unref();
959
1355
  }
960
1356
 
1357
+ const previousCwd = process.cwd();
1358
+ const shouldSwitchCwd = this.cwd && this.cwd !== previousCwd;
1359
+ if (shouldSwitchCwd) {
1360
+ try {
1361
+ process.chdir(this.cwd);
1362
+ } catch (error) {
1363
+ throw new Error(`Failed to switch backend cwd to ${this.cwd}: ${error?.message || error}`);
1364
+ }
1365
+ }
1366
+ const closeGuard = this.createCloseGuard();
1367
+
961
1368
  try {
962
- const result = await this.driver.ask(effectivePrompt);
1369
+ const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise]);
963
1370
  const answer = String(result.answer || result.replyText || "").trim();
964
1371
  this.trace(
965
1372
  `runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
@@ -1003,6 +1410,10 @@ class TuiDriverSession {
1003
1410
  } catch (error) {
1004
1411
  const errorMessage = error instanceof Error ? error.message : String(error);
1005
1412
  const errorReason = error?.reason || "unknown";
1413
+ if (errorReason === "session_closed") {
1414
+ this.trace("runTurn interrupted because backend session is closing");
1415
+ throw error instanceof Error ? error : new Error(errorMessage);
1416
+ }
1006
1417
 
1007
1418
  // 特殊处理登录和权限错误
1008
1419
  if (errorReason === "login_required") {
@@ -1037,21 +1448,34 @@ class TuiDriverSession {
1037
1448
  log(`[${this.backend}] Error: ${errorMessage}`);
1038
1449
  }
1039
1450
 
1040
- const latestSignals = this.driver.getSignals();
1451
+ let latestSignals = {};
1452
+ try {
1453
+ latestSignals = this.driver.getSignals();
1454
+ } catch {
1455
+ // driver may already be disposed while closing
1456
+ }
1041
1457
  const summary = this.formatSignalSummary(latestSignals);
1042
1458
  this.trace(
1043
1459
  `runTurn exception state=${this.driver.state} error="${sanitizeForLog(errorMessage, 220)}" status="${summary.status || ""}" done="${summary.done || ""}" preview="${summary.replyPreview || ""}"`,
1044
1460
  );
1045
1461
  throw error instanceof Error ? error : new Error(errorMessage);
1046
1462
  } finally {
1463
+ if (shouldSwitchCwd) {
1464
+ try {
1465
+ process.chdir(previousCwd);
1466
+ } catch (error) {
1467
+ log(`Failed to restore cwd to ${previousCwd}: ${error?.message || error}`);
1468
+ }
1469
+ }
1047
1470
  clearInterval(signalTimer);
1048
1471
  this.driver.off("stateChange", handleStateChange);
1472
+ closeGuard.cleanup();
1049
1473
  this.trace(`runTurn cleanup state=${this.driver.state}`);
1050
1474
  }
1051
1475
  }
1052
1476
  }
1053
1477
 
1054
- class BridgeRunner {
1478
+ export class BridgeRunner {
1055
1479
  constructor({
1056
1480
  backendSession,
1057
1481
  conductor,
@@ -1061,6 +1485,7 @@ class BridgeRunner {
1061
1485
  includeInitialImages,
1062
1486
  cliArgs,
1063
1487
  backendName,
1488
+ resumeMode,
1064
1489
  }) {
1065
1490
  this.backendSession = backendSession;
1066
1491
  this.conductor = conductor;
@@ -1070,17 +1495,22 @@ class BridgeRunner {
1070
1495
  this.includeInitialImages = includeInitialImages;
1071
1496
  this.cliArgs = cliArgs;
1072
1497
  this.backendName = backendName || "codex";
1498
+ this.resumeMode = Boolean(resumeMode);
1073
1499
  this.isCopilotBackend = String(this.backendName).toLowerCase() === "copilot";
1500
+ this.copilotDebug =
1501
+ this.isCopilotBackend &&
1502
+ (isTruthyEnv(process.env.CONDUCTOR_COPILOT_DEBUG) || isTruthyEnv(process.env.CONDUCTOR_DEBUG));
1074
1503
  this.stopped = false;
1075
1504
  this.runningTurn = false;
1076
1505
  this.processedMessageIds = new Set();
1077
1506
  this.lastRuntimeStatusSignature = null;
1078
1507
  this.lastRuntimeStatusPayload = null;
1079
1508
  this.needsReconnectRecovery = false;
1509
+ this.remoteStopInfo = null;
1080
1510
  }
1081
1511
 
1082
1512
  copilotLog(message) {
1083
- if (!this.isCopilotBackend) {
1513
+ if (!this.copilotDebug) {
1084
1514
  return;
1085
1515
  }
1086
1516
  log(`[copilot-debug] task=${this.taskId} ${message}`);
@@ -1090,6 +1520,9 @@ class BridgeRunner {
1090
1520
  abortSignal?.addEventListener("abort", () => {
1091
1521
  this.stopped = true;
1092
1522
  });
1523
+ if (this.stopped) {
1524
+ return;
1525
+ }
1093
1526
 
1094
1527
  if (this.initialPrompt) {
1095
1528
  this.copilotLog("processing initial prompt");
@@ -1097,8 +1530,20 @@ class BridgeRunner {
1097
1530
  includeImages: this.includeInitialImages,
1098
1531
  });
1099
1532
  }
1533
+ if (this.stopped) {
1534
+ return;
1535
+ }
1536
+ if (this.resumeMode) {
1537
+ await this.drainBufferedMessagesForResume();
1538
+ }
1539
+ if (this.stopped) {
1540
+ return;
1541
+ }
1100
1542
  this.copilotLog("running startup backfill");
1101
1543
  await this.backfillPendingUserMessages();
1544
+ if (this.stopped) {
1545
+ return;
1546
+ }
1102
1547
 
1103
1548
  while (!this.stopped) {
1104
1549
  if (this.needsReconnectRecovery && !this.runningTurn) {
@@ -1108,10 +1553,13 @@ class BridgeRunner {
1108
1553
  try {
1109
1554
  processed = await this.processIncomingBatch();
1110
1555
  } catch (error) {
1556
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
1557
+ break;
1558
+ }
1111
1559
  log(`Error while processing messages: ${error.message}`);
1112
1560
  await this.reportError(`处理任务失败: ${error.message}`);
1113
1561
  }
1114
- if (!processed) {
1562
+ if (!processed && !this.stopped) {
1115
1563
  await delay(this.pollIntervalMs);
1116
1564
  }
1117
1565
  }
@@ -1121,6 +1569,42 @@ class BridgeRunner {
1121
1569
  this.needsReconnectRecovery = true;
1122
1570
  }
1123
1571
 
1572
+ getRemoteStopSummary() {
1573
+ if (!this.remoteStopInfo) {
1574
+ return null;
1575
+ }
1576
+ const reason = this.remoteStopInfo.reason || "killed by app";
1577
+ return `task stopped by app: ${reason}`;
1578
+ }
1579
+
1580
+ async requestStopFromRemote(event = {}) {
1581
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
1582
+ if (taskId && taskId !== this.taskId) {
1583
+ return;
1584
+ }
1585
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
1586
+ const reason = typeof event.reason === "string" ? event.reason.trim() : "";
1587
+ if (!this.remoteStopInfo) {
1588
+ this.remoteStopInfo = {
1589
+ requestId: requestId || null,
1590
+ reason: reason || null,
1591
+ };
1592
+ log(
1593
+ `Received stop_task for ${this.taskId}${
1594
+ reason ? ` (${reason})` : ""
1595
+ }; stopping conductor fire`,
1596
+ );
1597
+ }
1598
+ this.stopped = true;
1599
+ if (typeof this.backendSession?.close === "function") {
1600
+ try {
1601
+ await this.backendSession.close();
1602
+ } catch (error) {
1603
+ log(`Failed to stop backend session for ${this.taskId}: ${error?.message || error}`);
1604
+ }
1605
+ }
1606
+ }
1607
+
1124
1608
  async recoverAfterReconnect() {
1125
1609
  if (!this.needsReconnectRecovery) {
1126
1610
  return;
@@ -1137,6 +1621,41 @@ class BridgeRunner {
1137
1621
  await this.replayLastRuntimeStatus();
1138
1622
  }
1139
1623
 
1624
+ async drainBufferedMessagesForResume() {
1625
+ let drainedCount = 0;
1626
+ let drainedBatches = 0;
1627
+
1628
+ while (!this.stopped) {
1629
+ const result = await this.conductor.receiveMessages(this.taskId, 50);
1630
+ const messages = Array.isArray(result?.messages) ? result.messages : [];
1631
+ if (messages.length === 0) {
1632
+ break;
1633
+ }
1634
+
1635
+ drainedBatches += 1;
1636
+ for (const message of messages) {
1637
+ const replyTo = message?.message_id ? String(message.message_id) : "";
1638
+ if (replyTo) {
1639
+ this.processedMessageIds.add(replyTo);
1640
+ }
1641
+ drainedCount += 1;
1642
+ }
1643
+
1644
+ const ackToken = result.next_ack_token || result.nextAckToken;
1645
+ if (ackToken) {
1646
+ await this.conductor.ackMessages(this.taskId, ackToken);
1647
+ }
1648
+ }
1649
+
1650
+ if (drainedCount > 0) {
1651
+ log(
1652
+ `Resume startup skipped ${drainedCount} buffered message(s) in ${drainedBatches} batch(es) for task ${this.taskId}`,
1653
+ );
1654
+ } else {
1655
+ this.copilotLog("resume startup found no buffered messages to skip");
1656
+ }
1657
+ }
1658
+
1140
1659
  async processIncomingBatch() {
1141
1660
  const result = await this.conductor.receiveMessages(this.taskId, 20);
1142
1661
  const messages = Array.isArray(result?.messages) ? result.messages : [];
@@ -1151,6 +1670,9 @@ class BridgeRunner {
1151
1670
  );
1152
1671
 
1153
1672
  for (const message of messages) {
1673
+ if (this.stopped) {
1674
+ break;
1675
+ }
1154
1676
  if (!this.shouldRespond(message)) {
1155
1677
  this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
1156
1678
  continue;
@@ -1211,12 +1733,37 @@ class BridgeRunner {
1211
1733
  }
1212
1734
  }
1213
1735
 
1736
+ const historyUserIds = history
1737
+ .filter((item) => String(item?.role || "").toLowerCase() === "user")
1738
+ .map((item) => (item?.id ? String(item.id) : ""))
1739
+ .filter(Boolean);
1740
+
1741
+ const handledUserIds = history
1742
+ .slice(0, lastSdkIndex + 1)
1743
+ .filter((item) => String(item?.role || "").toLowerCase() === "user")
1744
+ .map((item) => (item?.id ? String(item.id) : ""))
1745
+ .filter(Boolean);
1746
+
1747
+ for (const handledId of handledUserIds) {
1748
+ this.processedMessageIds.add(handledId);
1749
+ }
1750
+
1214
1751
  const pendingUserMessages = history
1215
1752
  .slice(lastSdkIndex + 1)
1216
1753
  .filter((item) => String(item?.role || "").toLowerCase() === "user")
1217
1754
  .filter((item) => typeof item?.content === "string" && item.content.trim());
1755
+
1756
+ if (this.resumeMode) {
1757
+ for (const historyId of historyUserIds) {
1758
+ this.processedMessageIds.add(historyId);
1759
+ }
1760
+ this.copilotLog(
1761
+ `resume mode: seeded processed ids=${historyUserIds.length}, skip startup backfill replay`,
1762
+ );
1763
+ return;
1764
+ }
1218
1765
  this.copilotLog(
1219
- `backfill loaded history=${history.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
1766
+ `backfill loaded history=${history.length} handledUsers=${handledUserIds.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
1220
1767
  );
1221
1768
 
1222
1769
  for (const pending of pendingUserMessages) {
@@ -1388,6 +1935,12 @@ class BridgeRunner {
1388
1935
  this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
1389
1936
  } catch (error) {
1390
1937
  const errorMessage = error instanceof Error ? error.message : String(error);
1938
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
1939
+ this.copilotLog(
1940
+ `turn interrupted by stop_task replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt}`,
1941
+ );
1942
+ return;
1943
+ }
1391
1944
  this.copilotLog(
1392
1945
  `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
1393
1946
  );
@@ -1446,6 +1999,10 @@ class BridgeRunner {
1446
1999
  });
1447
2000
  this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
1448
2001
  } catch (error) {
2002
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2003
+ this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
2004
+ return;
2005
+ }
1449
2006
  this.copilotLog(
1450
2007
  `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(error?.message || error, 200)}"`,
1451
2008
  );
@@ -1519,6 +2076,22 @@ function logBackendReply(backend, text, { usage, replyTo }) {
1519
2076
  log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
1520
2077
  }
1521
2078
 
2079
+ export function formatFatalError(error, opts = {}) {
2080
+ const showStack =
2081
+ typeof opts.showStack === "boolean"
2082
+ ? opts.showStack
2083
+ : isTruthyEnv(process.env.CONDUCTOR_CLI_SHOW_STACK) ||
2084
+ isTruthyEnv(process.env.CONDUCTOR_DEBUG);
2085
+ const message = error instanceof Error ? error.message : String(error);
2086
+ if (!showStack) {
2087
+ return message;
2088
+ }
2089
+ if (error instanceof Error && typeof error.stack === "string" && error.stack.trim()) {
2090
+ return error.stack;
2091
+ }
2092
+ return message;
2093
+ }
2094
+
1522
2095
  function isDirectRun() {
1523
2096
  try {
1524
2097
  if (!process.argv[1]) {
@@ -1535,7 +2108,7 @@ function isDirectRun() {
1535
2108
  if (isDirectRun()) {
1536
2109
  main().catch((error) => {
1537
2110
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1538
- const line = `[${CLI_NAME} ${ts}] failed: ${error.stack || error.message}\n`;
2111
+ const line = `[${CLI_NAME} ${ts}] failed: ${formatFatalError(error)}\n`;
1539
2112
  process.stderr.write(line);
1540
2113
  appendFireLocalLog(line);
1541
2114
  process.exitCode = 1;