@love-moon/conductor-cli 0.2.10 → 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,17 +20,14 @@ 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();
28
+ const INITIAL_CLI_PROJECT_PATH = process.cwd();
29
+ const FIRE_LOG_PATH = path.join(INITIAL_CLI_PROJECT_PATH, "conductor.log");
30
+ const ENABLE_FIRE_LOCAL_LOG = !process.env.CONDUCTOR_CLI_COMMAND;
33
31
 
34
32
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
35
33
  const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1] || "conductor-fire")).replace(
@@ -106,8 +104,20 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
106
104
  10,
107
105
  );
108
106
 
107
+ function appendFireLocalLog(line) {
108
+ if (!ENABLE_FIRE_LOCAL_LOG) {
109
+ return;
110
+ }
111
+ try {
112
+ fs.appendFileSync(FIRE_LOG_PATH, line);
113
+ } catch {
114
+ // ignore file log errors
115
+ }
116
+ }
117
+
109
118
  async function main() {
110
119
  const cliArgs = parseCliArgs();
120
+ let runtimeProjectPath = process.cwd();
111
121
 
112
122
  if (cliArgs.showHelp) {
113
123
  return;
@@ -134,48 +144,22 @@ async function main() {
134
144
  return;
135
145
  }
136
146
 
137
- let fromHistory = { history: [], provider: null, sessionId: null };
138
- if (cliArgs.fromSpec) {
139
- const provider = cliArgs.fromSpec.provider;
140
- if (!SUPPORTED_FROM_PROVIDERS.includes(provider)) {
141
- throw new Error(`--from only supports: ${SUPPORTED_FROM_PROVIDERS.join(", ")}`);
142
- }
143
-
144
- let resolvedSpec = cliArgs.fromSpec;
145
- if (!resolvedSpec.sessionId) {
146
- const selected = await selectHistorySession(provider);
147
- if (!selected) {
148
- log("No session selected. Starting a new conversation.");
149
- resolvedSpec = null;
150
- } else {
151
- resolvedSpec = parseFromSpec(`${provider}:${selected.sessionId}`);
152
- }
153
- }
154
-
155
- if (resolvedSpec) {
156
- if (cliArgs.backend !== resolvedSpec.provider) {
157
- log(
158
- `Ignoring --from ${resolvedSpec.provider}:${resolvedSpec.sessionId} because backend is ${cliArgs.backend}`,
159
- );
160
- } else {
161
- fromHistory = await loadHistoryFromSpec(resolvedSpec);
162
- if (fromHistory.warning) {
163
- log(fromHistory.warning);
164
- } else {
165
- log(
166
- `Loaded ${fromHistory.history.length} history messages from ${fromHistory.provider} session ${fromHistory.sessionId}`,
167
- );
168
- }
169
- }
170
- }
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`);
171
156
  }
172
157
 
173
158
  // Create backend session using tui-driver
174
159
  const backendSession = new TuiDriverSession(cliArgs.backend, {
175
160
  initialImages: cliArgs.initialImages,
176
- cwd: CLI_PROJECT_PATH,
177
- initialHistory: fromHistory.history,
178
- resumeSessionId: fromHistory.sessionId,
161
+ cwd: runtimeProjectPath,
162
+ resumeSessionId: cliArgs.resumeSessionId,
179
163
  configFile: cliArgs.configFile,
180
164
  });
181
165
 
@@ -185,6 +169,7 @@ async function main() {
185
169
  const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
186
170
  let reconnectRunner = null;
187
171
  let reconnectTaskId = null;
172
+ let pendingRemoteStopEvent = null;
188
173
  let conductor = null;
189
174
  let reconnectResumeInFlight = false;
190
175
 
@@ -221,6 +206,21 @@ async function main() {
221
206
  })();
222
207
  };
223
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
+
224
224
  if (cliArgs.configFile) {
225
225
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
226
226
  }
@@ -239,111 +239,159 @@ async function main() {
239
239
  // Ignore config loading errors, rely on env vars or defaults
240
240
  }
241
241
 
242
- conductor = await ConductorClient.connect({
243
- projectPath: CLI_PROJECT_PATH,
244
- extraEnv: env,
245
- configFile: cliArgs.configFile,
246
- onConnected: scheduleReconnectRecovery,
247
- });
248
-
249
- const taskContext = await ensureTaskContext(conductor, {
250
- initialPrompt: cliArgs.initialPrompt,
251
- requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
252
- providedTaskId: process.env.CONDUCTOR_TASK_ID,
253
- requestedTitle: cliArgs.taskTitle || process.env.CONDUCTOR_TASK_TITLE,
254
- backend: cliArgs.backend,
255
- });
256
-
257
- log(
258
- `Attached to Conductor task ${taskContext.taskId}${
259
- taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
260
- }`,
261
- );
262
- reconnectTaskId = taskContext.taskId;
263
-
264
242
  try {
265
- await conductor.sendAgentResume({
266
- active_tasks: [taskContext.taskId],
267
- source: "conductor-fire",
268
- metadata: { reconnect: false },
243
+ const requestedTaskTitle = resolveRequestedTaskTitle({
244
+ cliTaskTitle: cliArgs.taskTitle,
245
+ hasExplicitTaskTitle: cliArgs.hasExplicitTaskTitle,
246
+ envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
247
+ runtimeProjectPath,
269
248
  });
270
- } catch (error) {
271
- log(`Failed to report agent resume: ${error?.message || error}`);
272
- }
273
249
 
274
- const runner = new BridgeRunner({
275
- backendSession,
276
- conductor,
277
- taskId: taskContext.taskId,
278
- pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
279
- initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
280
- includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
281
- cliArgs: cliArgs.rawBackendArgs,
282
- backendName: cliArgs.backend,
283
- });
284
- reconnectRunner = runner;
250
+ conductor = await ConductorClient.connect({
251
+ projectPath: runtimeProjectPath,
252
+ extraEnv: env,
253
+ configFile: cliArgs.configFile,
254
+ onConnected: scheduleReconnectRecovery,
255
+ onStopTask: handleStopTaskCommand,
256
+ });
285
257
 
286
- const signals = new AbortController();
287
- let shutdownSignal = null;
288
- const onSigint = () => {
289
- shutdownSignal = shutdownSignal || "SIGINT";
290
- signals.abort();
291
- };
292
- const onSigterm = () => {
293
- shutdownSignal = shutdownSignal || "SIGTERM";
294
- signals.abort();
295
- };
296
- process.on("SIGINT", onSigint);
297
- 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;
298
272
 
299
- if (!launchedByDaemon) {
300
273
  try {
301
- await conductor.sendTaskStatus(taskContext.taskId, {
302
- status: "RUNNING",
274
+ await conductor.sendAgentResume({
275
+ active_tasks: [taskContext.taskId],
276
+ source: "conductor-fire",
277
+ metadata: { reconnect: false },
303
278
  });
304
279
  } catch (error) {
305
- 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;
306
298
  }
307
- }
308
299
 
309
- let runnerError = null;
310
- try {
311
- await runner.start(signals.signal);
312
- } catch (error) {
313
- runnerError = error;
314
- throw error;
315
- } finally {
316
- process.off("SIGINT", onSigint);
317
- 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
+
318
329
  if (!launchedByDaemon) {
319
- const finalStatus = shutdownSignal
320
- ? {
321
- status: "KILLED",
322
- summary: `terminated by ${shutdownSignal}`,
323
- }
324
- : 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
325
351
  ? {
326
352
  status: "KILLED",
327
- summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
353
+ summary: `terminated by ${shutdownSignal}`,
328
354
  }
329
- : {
330
- status: "COMPLETED",
331
- summary: "conductor fire exited",
332
- };
333
- try {
334
- await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
335
- } catch (error) {
336
- 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;
337
379
  }
338
380
  }
381
+ } finally {
339
382
  if (typeof backendSession.close === "function") {
340
- await backendSession.close();
383
+ try {
384
+ await backendSession.close();
385
+ } catch (error) {
386
+ log(`Failed to close backend session: ${error?.message || error}`);
387
+ }
341
388
  }
342
- await conductor.close();
343
- if (shutdownSignal === "SIGINT") {
344
- process.exitCode = 130;
345
- } else if (shutdownSignal === "SIGTERM") {
346
- 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
+ }
347
395
  }
348
396
  }
349
397
  }
@@ -361,6 +409,65 @@ function extractConfigFileFromArgv(argv) {
361
409
  return undefined;
362
410
  }
363
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
+
364
471
  export function parseCliArgs(argvInput = process.argv) {
365
472
  const rawArgv = Array.isArray(argvInput) ? argvInput : process.argv;
366
473
  const argv = hideBin(rawArgv);
@@ -368,12 +475,15 @@ export function parseCliArgs(argvInput = process.argv) {
368
475
 
369
476
  // When no separator, parse all args first to check for conductor-specific options
370
477
  const conductorArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
371
- const backendArgv = separatorIndex === -1 ? argv : argv.slice(separatorIndex + 1);
478
+ const backendArgv = separatorIndex === -1 ? stripConductorArgsFromArgv(argv) : argv.slice(separatorIndex + 1);
372
479
 
373
480
  // Check for version/list-backends/help without separator
374
481
  const versionWithoutSeparator = separatorIndex === -1 && (argv.includes("--version") || argv.includes("-v"));
375
482
  const listBackendsWithoutSeparator = separatorIndex === -1 && argv.includes("--list-backends");
376
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
+ }
377
487
 
378
488
  const configFileFromArgs = extractConfigFileFromArgv(argv);
379
489
  const allowCliList = loadAllowCliList(configFileFromArgs);
@@ -406,15 +516,9 @@ export function parseCliArgs(argvInput = process.argv) {
406
516
  type: "string",
407
517
  describe: "Optional task title shown in the app task list",
408
518
  })
409
- .option("from", {
410
- alias: "f",
411
- type: "string",
412
- describe: "Resume from local history (optional session id; otherwise pick interactively)",
413
- })
414
- .option("from-provider", {
519
+ .option("resume", {
415
520
  type: "string",
416
- describe: "Provider for --from picker (defaults to --backend)",
417
- choices: SUPPORTED_FROM_PROVIDERS,
521
+ describe: "Resume from a backend session ID",
418
522
  })
419
523
  .option("prefill", {
420
524
  type: "string",
@@ -447,8 +551,7 @@ Options:
447
551
  --config-file <path> Path to Conductor config file
448
552
  --poll-interval <ms> Polling interval when waiting for Conductor messages
449
553
  -t, --title <text> Optional task title shown in the app task list
450
- -f, --from [id] Resume from local history (pick if id omitted)
451
- --from-provider <p> Provider for --from picker (codex or claude)
554
+ --resume <id> Resume from an existing backend session
452
555
  -v, --version Show Conductor CLI version and exit
453
556
  -h, --help Show this help message
454
557
 
@@ -462,6 +565,7 @@ Examples:
462
565
  ${CLI_NAME} -- "fix the bug" # Use default backend
463
566
  ${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
464
567
  ${CLI_NAME} --backend copilot -- "fix the bug" # Use GitHub Copilot CLI backend
568
+ ${CLI_NAME} --backend codex --resume <id> # Resume Codex session
465
569
  ${CLI_NAME} --list-backends # Show configured backends
466
570
  ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
467
571
 
@@ -491,11 +595,13 @@ Environment:
491
595
  ? Number(conductorArgs.pollInterval)
492
596
  : DEFAULT_POLL_INTERVAL_MS;
493
597
 
494
- let fromSpec = null;
495
- try {
496
- fromSpec = parseFromSpec(conductorArgs.from, conductorArgs.fromProvider, backend);
497
- } catch (error) {
498
- 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");
499
605
  }
500
606
 
501
607
  return {
@@ -504,22 +610,34 @@ Environment:
504
610
  initialImages,
505
611
  pollIntervalMs,
506
612
  rawBackendArgs: backendArgv,
507
- taskTitle: resolveTaskTitle(conductorArgs.title),
613
+ taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
614
+ hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
508
615
  configFile: conductorArgs.configFile,
509
- fromSpec,
616
+ resumeSessionId,
510
617
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
511
618
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
512
619
  };
513
620
  }
514
621
 
515
- function resolveTaskTitle(titleFlag) {
622
+ export function resolveTaskTitle(titleFlag, defaultPath = process.cwd()) {
516
623
  if (typeof titleFlag === "string" && titleFlag.trim()) {
517
624
  return titleFlag.trim();
518
625
  }
519
- 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());
520
628
  return cwdName || "";
521
629
  }
522
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
+
523
641
  function normalizeArray(value) {
524
642
  if (!value) {
525
643
  return [];
@@ -642,6 +760,7 @@ function deriveTaskTitle(prompt, explicit, backend = "codex") {
642
760
 
643
761
  const BACKEND_PROFILE_MAP = {
644
762
  codex: "codex",
763
+ code: "codex",
645
764
  claude: "claude-code",
646
765
  "claude-code": "claude-code",
647
766
  copilot: "copilot",
@@ -664,6 +783,228 @@ function parseCommandParts(commandLine) {
664
783
  };
665
784
  }
666
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
+
667
1008
  function truncateText(value, maxLen = 240) {
668
1009
  if (!value) return "";
669
1010
  const text = String(value).trim();
@@ -677,6 +1018,10 @@ function isTruthyEnv(value) {
677
1018
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
678
1019
  }
679
1020
 
1021
+ function isSessionClosedError(error) {
1022
+ return Boolean(error && typeof error === "object" && error.reason === "session_closed");
1023
+ }
1024
+
680
1025
  function sanitizeForLog(value, maxLen = 180) {
681
1026
  if (!value) return "";
682
1027
  return truncateText(String(value).replace(/\s+/g, " ").trim(), maxLen);
@@ -692,7 +1037,12 @@ class TuiDriverSession {
692
1037
  constructor(backend, options = {}) {
693
1038
  this.backend = backend;
694
1039
  this.options = options;
695
- 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()}`;
696
1046
  this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
697
1047
  this.pendingHistorySeed = this.history.length > 0;
698
1048
 
@@ -702,8 +1052,9 @@ class TuiDriverSession {
702
1052
  if (!command) {
703
1053
  throw new Error(`Invalid command for backend "${backend}"`);
704
1054
  }
1055
+ const resumeArgs = buildResumeArgsForBackend(backend, resumeSessionId);
705
1056
  this.command = command;
706
- this.args = args;
1057
+ this.args = [...args, ...resumeArgs];
707
1058
  this.tuiDebug = isTruthyEnv(process.env.CONDUCTOR_TUI_DEBUG);
708
1059
  this.tuiTrace = this.tuiDebug || isTruthyEnv(process.env.CONDUCTOR_TUI_TRACE);
709
1060
  this.tuiTraceLines = Number.isFinite(Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES || "", 10))
@@ -712,6 +1063,9 @@ class TuiDriverSession {
712
1063
  this.lastSignalSignature = "";
713
1064
  this.lastPollSignature = "";
714
1065
  this.lastSnapshotHash = "";
1066
+ this.closeRequested = false;
1067
+ this.closed = false;
1068
+ this.closeWaiters = new Set();
715
1069
 
716
1070
  const profileName = profileNameForBackend(backend);
717
1071
  if (!profileName) {
@@ -732,7 +1086,7 @@ class TuiDriverSession {
732
1086
  log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
733
1087
  }
734
1088
 
735
- log(`Using TUI command for ${backend}: ${cliCommand}`);
1089
+ log(`Using TUI command for ${backend}: ${[this.command, ...this.args].join(" ")} (cwd: ${this.cwd})`);
736
1090
  if (this.tuiTrace) {
737
1091
  log(
738
1092
  `[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(this.args)}`,
@@ -753,6 +1107,7 @@ class TuiDriverSession {
753
1107
  ...cliEnv,
754
1108
  },
755
1109
  },
1110
+ cwd: this.cwd,
756
1111
  debug: this.tuiDebug,
757
1112
  onSnapshot: this.tuiTrace
758
1113
  ? (snapshot, state) => {
@@ -784,7 +1139,57 @@ class TuiDriverSession {
784
1139
  return { model: this.backend };
785
1140
  }
786
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
+
787
1186
  async close() {
1187
+ if (this.closed) {
1188
+ return;
1189
+ }
1190
+ this.closed = true;
1191
+ this.closeRequested = true;
1192
+ this.flushCloseWaiters();
788
1193
  if (this.driver) {
789
1194
  this.driver.kill();
790
1195
  }
@@ -889,6 +1294,10 @@ class TuiDriverSession {
889
1294
  }
890
1295
 
891
1296
  async runTurn(promptText, { useInitialImages = false, onProgress } = {}) {
1297
+ if (this.closeRequested) {
1298
+ throw this.createSessionClosedError();
1299
+ }
1300
+
892
1301
  const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
893
1302
  if (!effectivePrompt) {
894
1303
  return {
@@ -945,8 +1354,19 @@ class TuiDriverSession {
945
1354
  signalTimer.unref();
946
1355
  }
947
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
+
948
1368
  try {
949
- const result = await this.driver.ask(effectivePrompt);
1369
+ const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise]);
950
1370
  const answer = String(result.answer || result.replyText || "").trim();
951
1371
  this.trace(
952
1372
  `runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
@@ -990,6 +1410,10 @@ class TuiDriverSession {
990
1410
  } catch (error) {
991
1411
  const errorMessage = error instanceof Error ? error.message : String(error);
992
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
+ }
993
1417
 
994
1418
  // 特殊处理登录和权限错误
995
1419
  if (errorReason === "login_required") {
@@ -1024,21 +1448,34 @@ class TuiDriverSession {
1024
1448
  log(`[${this.backend}] Error: ${errorMessage}`);
1025
1449
  }
1026
1450
 
1027
- 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
+ }
1028
1457
  const summary = this.formatSignalSummary(latestSignals);
1029
1458
  this.trace(
1030
1459
  `runTurn exception state=${this.driver.state} error="${sanitizeForLog(errorMessage, 220)}" status="${summary.status || ""}" done="${summary.done || ""}" preview="${summary.replyPreview || ""}"`,
1031
1460
  );
1032
1461
  throw error instanceof Error ? error : new Error(errorMessage);
1033
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
+ }
1034
1470
  clearInterval(signalTimer);
1035
1471
  this.driver.off("stateChange", handleStateChange);
1472
+ closeGuard.cleanup();
1036
1473
  this.trace(`runTurn cleanup state=${this.driver.state}`);
1037
1474
  }
1038
1475
  }
1039
1476
  }
1040
1477
 
1041
- class BridgeRunner {
1478
+ export class BridgeRunner {
1042
1479
  constructor({
1043
1480
  backendSession,
1044
1481
  conductor,
@@ -1048,6 +1485,7 @@ class BridgeRunner {
1048
1485
  includeInitialImages,
1049
1486
  cliArgs,
1050
1487
  backendName,
1488
+ resumeMode,
1051
1489
  }) {
1052
1490
  this.backendSession = backendSession;
1053
1491
  this.conductor = conductor;
@@ -1057,25 +1495,55 @@ class BridgeRunner {
1057
1495
  this.includeInitialImages = includeInitialImages;
1058
1496
  this.cliArgs = cliArgs;
1059
1497
  this.backendName = backendName || "codex";
1498
+ this.resumeMode = Boolean(resumeMode);
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));
1060
1503
  this.stopped = false;
1061
1504
  this.runningTurn = false;
1062
1505
  this.processedMessageIds = new Set();
1063
1506
  this.lastRuntimeStatusSignature = null;
1064
1507
  this.lastRuntimeStatusPayload = null;
1065
1508
  this.needsReconnectRecovery = false;
1509
+ this.remoteStopInfo = null;
1510
+ }
1511
+
1512
+ copilotLog(message) {
1513
+ if (!this.copilotDebug) {
1514
+ return;
1515
+ }
1516
+ log(`[copilot-debug] task=${this.taskId} ${message}`);
1066
1517
  }
1067
1518
 
1068
1519
  async start(abortSignal) {
1069
1520
  abortSignal?.addEventListener("abort", () => {
1070
1521
  this.stopped = true;
1071
1522
  });
1523
+ if (this.stopped) {
1524
+ return;
1525
+ }
1072
1526
 
1073
1527
  if (this.initialPrompt) {
1528
+ this.copilotLog("processing initial prompt");
1074
1529
  await this.handleSyntheticMessage(this.initialPrompt, {
1075
1530
  includeImages: this.includeInitialImages,
1076
1531
  });
1077
1532
  }
1533
+ if (this.stopped) {
1534
+ return;
1535
+ }
1536
+ if (this.resumeMode) {
1537
+ await this.drainBufferedMessagesForResume();
1538
+ }
1539
+ if (this.stopped) {
1540
+ return;
1541
+ }
1542
+ this.copilotLog("running startup backfill");
1078
1543
  await this.backfillPendingUserMessages();
1544
+ if (this.stopped) {
1545
+ return;
1546
+ }
1079
1547
 
1080
1548
  while (!this.stopped) {
1081
1549
  if (this.needsReconnectRecovery && !this.runningTurn) {
@@ -1085,10 +1553,13 @@ class BridgeRunner {
1085
1553
  try {
1086
1554
  processed = await this.processIncomingBatch();
1087
1555
  } catch (error) {
1556
+ if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
1557
+ break;
1558
+ }
1088
1559
  log(`Error while processing messages: ${error.message}`);
1089
1560
  await this.reportError(`处理任务失败: ${error.message}`);
1090
1561
  }
1091
- if (!processed) {
1562
+ if (!processed && !this.stopped) {
1092
1563
  await delay(this.pollIntervalMs);
1093
1564
  }
1094
1565
  }
@@ -1098,6 +1569,42 @@ class BridgeRunner {
1098
1569
  this.needsReconnectRecovery = true;
1099
1570
  }
1100
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
+
1101
1608
  async recoverAfterReconnect() {
1102
1609
  if (!this.needsReconnectRecovery) {
1103
1610
  return;
@@ -1114,23 +1621,68 @@ class BridgeRunner {
1114
1621
  await this.replayLastRuntimeStatus();
1115
1622
  }
1116
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
+
1117
1659
  async processIncomingBatch() {
1118
1660
  const result = await this.conductor.receiveMessages(this.taskId, 20);
1119
1661
  const messages = Array.isArray(result?.messages) ? result.messages : [];
1120
1662
  if (messages.length === 0) {
1121
1663
  return false;
1122
1664
  }
1665
+ const ackToken = result.next_ack_token || result.nextAckToken;
1666
+ this.copilotLog(
1667
+ `received batch size=${messages.length} hasMore=${Boolean(result?.has_more)} ackToken=${ackToken ? "yes" : "no"} roles=${messages
1668
+ .map((item) => String(item?.role || "unknown").toLowerCase())
1669
+ .join(",")}`,
1670
+ );
1123
1671
 
1124
1672
  for (const message of messages) {
1673
+ if (this.stopped) {
1674
+ break;
1675
+ }
1125
1676
  if (!this.shouldRespond(message)) {
1677
+ this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
1126
1678
  continue;
1127
1679
  }
1128
1680
  await this.respondToMessage(message);
1129
1681
  }
1130
1682
 
1131
- const ackToken = result.next_ack_token || result.nextAckToken;
1132
1683
  if (ackToken) {
1133
1684
  await this.conductor.ackMessages(this.taskId, ackToken);
1685
+ this.copilotLog(`acked batch ackToken=${truncateText(String(ackToken), 40)}`);
1134
1686
  }
1135
1687
  const hasMore = Boolean(result?.has_more);
1136
1688
  return hasMore;
@@ -1148,6 +1700,7 @@ class BridgeRunner {
1148
1700
  const backendUrl = process.env.CONDUCTOR_BACKEND_URL;
1149
1701
  const token = process.env.CONDUCTOR_AGENT_TOKEN;
1150
1702
  if (!backendUrl || !token) {
1703
+ this.copilotLog("skip backfill: missing backend url or token");
1151
1704
  return;
1152
1705
  }
1153
1706
 
@@ -1163,10 +1716,12 @@ class BridgeRunner {
1163
1716
  },
1164
1717
  );
1165
1718
  if (!response.ok) {
1719
+ this.copilotLog(`backfill request failed status=${response.status}`);
1166
1720
  return;
1167
1721
  }
1168
1722
  const history = await response.json();
1169
1723
  if (!Array.isArray(history) || history.length === 0) {
1724
+ this.copilotLog("backfill: no history messages");
1170
1725
  return;
1171
1726
  }
1172
1727
 
@@ -1178,12 +1733,41 @@ class BridgeRunner {
1178
1733
  }
1179
1734
  }
1180
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
+
1181
1751
  const pendingUserMessages = history
1182
1752
  .slice(lastSdkIndex + 1)
1183
1753
  .filter((item) => String(item?.role || "").toLowerCase() === "user")
1184
1754
  .filter((item) => typeof item?.content === "string" && item.content.trim());
1185
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
+ }
1765
+ this.copilotLog(
1766
+ `backfill loaded history=${history.length} handledUsers=${handledUserIds.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
1767
+ );
1768
+
1186
1769
  for (const pending of pendingUserMessages) {
1770
+ this.copilotLog(`backfill replay message id=${pending.id ? String(pending.id) : "unknown"}`);
1187
1771
  await this.respondToMessage({
1188
1772
  message_id: pending.id ? String(pending.id) : undefined,
1189
1773
  role: "user",
@@ -1238,6 +1822,11 @@ class BridgeRunner {
1238
1822
  this.lastRuntimeStatusPayload = {
1239
1823
  ...runtime,
1240
1824
  };
1825
+ this.copilotLog(
1826
+ `runtime replyTo=${replyTo || "latest"} state=${runtime.state || ""} phase=${runtime.phase || ""} inProgress=${Boolean(
1827
+ runtime.reply_in_progress,
1828
+ )} status="${sanitizeForLog(runtime.status_line || "", 120)}" done="${sanitizeForLog(runtime.status_done_line || "", 120)}" preview="${sanitizeForLog(runtime.reply_preview || "", 120)}"`,
1829
+ );
1241
1830
 
1242
1831
  try {
1243
1832
  await this.conductor.sendRuntimeStatus(this.taskId, {
@@ -1266,14 +1855,32 @@ class BridgeRunner {
1266
1855
  async respondToMessage(message) {
1267
1856
  const content = String(message.content || "").trim();
1268
1857
  if (!content) {
1858
+ this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
1269
1859
  return;
1270
1860
  }
1271
1861
  const replyTo = message.message_id;
1272
1862
  if (replyTo && this.processedMessageIds.has(replyTo)) {
1863
+ this.copilotLog(`skip duplicated message replyTo=${replyTo}`);
1273
1864
  return;
1274
1865
  }
1275
1866
  this.lastRuntimeStatusSignature = null;
1276
1867
  this.runningTurn = true;
1868
+ const turnStartedAt = Date.now();
1869
+ let turnWatchdog = null;
1870
+ if (this.isCopilotBackend) {
1871
+ turnWatchdog = setInterval(() => {
1872
+ const runtime = this.lastRuntimeStatusPayload || {};
1873
+ this.copilotLog(
1874
+ `turn waiting replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} state=${runtime.state || ""} phase=${runtime.phase || ""} status="${sanitizeForLog(runtime.status_line || runtime.status_done_line || "", 120)}"`,
1875
+ );
1876
+ }, 30000);
1877
+ if (typeof turnWatchdog.unref === "function") {
1878
+ turnWatchdog.unref();
1879
+ }
1880
+ }
1881
+ this.copilotLog(
1882
+ `turn start replyTo=${replyTo || "latest"} role=${String(message.role || "").toLowerCase()} contentLen=${content.length} preview="${sanitizeForLog(content, 120)}"`,
1883
+ );
1277
1884
  log(`Processing message ${replyTo} (${message.role})`);
1278
1885
  try {
1279
1886
  await this.reportRuntimeStatus(
@@ -1291,6 +1898,11 @@ class BridgeRunner {
1291
1898
  void this.reportRuntimeStatus(payload, replyTo);
1292
1899
  },
1293
1900
  });
1901
+ this.copilotLog(
1902
+ `runTurn completed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} answerLen=${String(
1903
+ result.text || "",
1904
+ ).trim().length} items=${Array.isArray(result.items) ? result.items.length : 0}`,
1905
+ );
1294
1906
 
1295
1907
  await this.reportRuntimeStatus(
1296
1908
  {
@@ -1320,8 +1932,18 @@ class BridgeRunner {
1320
1932
  if (replyTo) {
1321
1933
  this.processedMessageIds.add(replyTo);
1322
1934
  }
1935
+ this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
1323
1936
  } catch (error) {
1324
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
+ }
1944
+ this.copilotLog(
1945
+ `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
1946
+ );
1325
1947
  await this.reportRuntimeStatus(
1326
1948
  {
1327
1949
  state: "ERROR",
@@ -1333,6 +1955,12 @@ class BridgeRunner {
1333
1955
  );
1334
1956
  await this.reportError(`${this.backendName} 处理失败: ${errorMessage}`, replyTo);
1335
1957
  } finally {
1958
+ if (turnWatchdog) {
1959
+ clearInterval(turnWatchdog);
1960
+ }
1961
+ this.copilotLog(
1962
+ `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
1963
+ );
1336
1964
  this.runningTurn = false;
1337
1965
  }
1338
1966
  }
@@ -1340,6 +1968,8 @@ class BridgeRunner {
1340
1968
  async handleSyntheticMessage(content, { includeImages }) {
1341
1969
  this.lastRuntimeStatusSignature = null;
1342
1970
  this.runningTurn = true;
1971
+ const startedAt = Date.now();
1972
+ this.copilotLog(`synthetic turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`);
1343
1973
  try {
1344
1974
  const result = await this.backendSession.runTurn(content, {
1345
1975
  useInitialImages: includeImages,
@@ -1347,6 +1977,9 @@ class BridgeRunner {
1347
1977
  void this.reportRuntimeStatus(payload, "initial");
1348
1978
  },
1349
1979
  });
1980
+ this.copilotLog(
1981
+ `synthetic runTurn completed elapsedMs=${Date.now() - startedAt} answerLen=${String(result.text || "").trim().length}`,
1982
+ );
1350
1983
  const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
1351
1984
  const intro = `${backendLabel} 已根据初始提示给出回复:`;
1352
1985
  const replyText =
@@ -1364,9 +1997,18 @@ class BridgeRunner {
1364
1997
  cli_args: this.cliArgs,
1365
1998
  synthetic: true,
1366
1999
  });
2000
+ this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
1367
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
+ }
2006
+ this.copilotLog(
2007
+ `synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(error?.message || error, 200)}"`,
2008
+ );
1368
2009
  await this.reportError(`初始提示执行失败: ${error.message}`);
1369
2010
  } finally {
2011
+ this.copilotLog(`synthetic turn end elapsedMs=${Date.now() - startedAt}`);
1370
2012
  this.runningTurn = false;
1371
2013
  }
1372
2014
  }
@@ -1424,7 +2066,9 @@ function extractAgentTextFromMetadata(metadata) {
1424
2066
 
1425
2067
  function log(message) {
1426
2068
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1427
- process.stdout.write(`[${CLI_NAME} ${ts}] ${message}\n`);
2069
+ const line = `[${CLI_NAME} ${ts}] ${message}\n`;
2070
+ process.stdout.write(line);
2071
+ appendFireLocalLog(line);
1428
2072
  }
1429
2073
 
1430
2074
  function logBackendReply(backend, text, { usage, replyTo }) {
@@ -1432,6 +2076,22 @@ function logBackendReply(backend, text, { usage, replyTo }) {
1432
2076
  log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
1433
2077
  }
1434
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
+
1435
2095
  function isDirectRun() {
1436
2096
  try {
1437
2097
  if (!process.argv[1]) {
@@ -1448,7 +2108,9 @@ function isDirectRun() {
1448
2108
  if (isDirectRun()) {
1449
2109
  main().catch((error) => {
1450
2110
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1451
- process.stderr.write(`[${CLI_NAME} ${ts}] failed: ${error.stack || error.message}\n`);
2111
+ const line = `[${CLI_NAME} ${ts}] failed: ${formatFatalError(error)}\n`;
2112
+ process.stderr.write(line);
2113
+ appendFireLocalLog(line);
1452
2114
  process.exitCode = 1;
1453
2115
  });
1454
2116
  }