@love-moon/conductor-cli 0.2.38 → 0.2.40

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.
@@ -29,7 +29,9 @@ import {
29
29
  import {
30
30
  filterRuntimeSupportedAllowCliList,
31
31
  isBuiltInRuntimeBackend,
32
+ isCommandOptionalBuiltInRuntimeBackend,
32
33
  listAdvertisedBackends,
34
+ parseCommandParts,
33
35
  resolveConfiguredRuntimeBackend,
34
36
  normalizeRuntimeBackendAlias,
35
37
  normalizeRuntimeBackendName,
@@ -69,9 +71,20 @@ const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1
69
71
  "",
70
72
  );
71
73
 
72
- export function buildConductorConnectHeaders(version = pkgJson.version) {
74
+ export function buildConductorConnectHeaders(
75
+ version = pkgJson.version,
76
+ options = {},
77
+ ) {
78
+ const backends = Array.isArray(options.backends)
79
+ ? options.backends.map((entry) => String(entry || "").trim()).filter(Boolean)
80
+ : [];
81
+ const capabilities = Array.isArray(options.capabilities)
82
+ ? options.capabilities.map((entry) => String(entry || "").trim()).filter(Boolean)
83
+ : [];
73
84
  return {
74
85
  "x-conductor-version": version,
86
+ ...(backends.length > 0 ? { "x-conductor-backends": backends.join(",") } : {}),
87
+ ...(capabilities.length > 0 ? { "x-conductor-capabilities": capabilities.join(",") } : {}),
75
88
  };
76
89
  }
77
90
 
@@ -89,6 +102,13 @@ export function shouldRunReconnectRecovery({
89
102
  return !runner.shouldSuppressReconnectRecovery();
90
103
  }
91
104
 
105
+ export function shouldFireReportTaskStatus({ launchedByDaemon = false, phase } = {}) {
106
+ if (phase === "final") {
107
+ return true;
108
+ }
109
+ return !launchedByDaemon;
110
+ }
111
+
92
112
  // Load allow_cli_list from config file (no defaults - must be configured)
93
113
  function loadFireConfigYaml(configFilePath) {
94
114
  const home = os.homedir();
@@ -148,70 +168,8 @@ export function resolveConfiguredPrePrompt({ configFilePath, backend, sessionBac
148
168
  return undefined;
149
169
  }
150
170
 
151
- function parseCommandParts(commandLine) {
152
- const input = String(commandLine || "").trim();
153
- if (!input) {
154
- return [];
155
- }
156
-
157
- const parts = [];
158
- let current = "";
159
- let quote = "";
160
- let escaping = false;
161
- let tokenStarted = false;
162
-
163
- for (const char of input) {
164
- if (escaping) {
165
- current += char;
166
- tokenStarted = true;
167
- escaping = false;
168
- continue;
169
- }
170
-
171
- if (char === "\\") {
172
- escaping = true;
173
- tokenStarted = true;
174
- continue;
175
- }
176
-
177
- if (quote) {
178
- if (char === quote) {
179
- quote = "";
180
- } else {
181
- current += char;
182
- }
183
- tokenStarted = true;
184
- continue;
185
- }
186
-
187
- if (char === "'" || char === "\"") {
188
- quote = char;
189
- tokenStarted = true;
190
- continue;
191
- }
192
-
193
- if (/\s/.test(char)) {
194
- if (tokenStarted) {
195
- parts.push(current);
196
- current = "";
197
- tokenStarted = false;
198
- }
199
- continue;
200
- }
201
-
202
- current += char;
203
- tokenStarted = true;
204
- }
205
-
206
- if (tokenStarted) {
207
- parts.push(current);
208
- }
209
-
210
- return parts;
211
- }
212
-
213
171
  function extractModelOptionFromCommandLine(commandLine) {
214
- const parts = parseCommandParts(commandLine);
172
+ const { parts } = parseCommandParts(commandLine);
215
173
  for (let index = 0; index < parts.length; index += 1) {
216
174
  const token = String(parts[index] || "").trim();
217
175
  if (!token) {
@@ -577,6 +535,39 @@ export class FireWatchdog {
577
535
  }
578
536
  }
579
537
 
538
+ export function createPendingRemoteInterruptQueue() {
539
+ const pending = [];
540
+
541
+ return {
542
+ enqueue(event) {
543
+ return new Promise((resolve) => {
544
+ pending.push({ event, resolve });
545
+ });
546
+ },
547
+
548
+ async flushWith(dispatch) {
549
+ while (pending.length > 0) {
550
+ const next = pending.shift();
551
+ if (!next) {
552
+ continue;
553
+ }
554
+ try {
555
+ next.resolve(await dispatch(next.event));
556
+ } catch {
557
+ next.resolve(false);
558
+ }
559
+ }
560
+ },
561
+
562
+ rejectAll() {
563
+ while (pending.length > 0) {
564
+ const next = pending.shift();
565
+ next?.resolve(false);
566
+ }
567
+ },
568
+ };
569
+ }
570
+
580
571
  async function main() {
581
572
  syncPwdEnvWithProcessCwdForDaemonLaunch();
582
573
  const cliArgs = await parseCliArgs();
@@ -624,7 +615,8 @@ async function main() {
624
615
  let resumeContext = null;
625
616
  if (cliArgs.resumeSessionId) {
626
617
  const bootstrap = await bootstrapResumeContextForFire({
627
- backend: cliArgs.sessionBackend || cliArgs.backend,
618
+ backend: cliArgs.backend,
619
+ sessionBackend: cliArgs.sessionBackend,
628
620
  configFile: cliArgs.configFile,
629
621
  resumeSessionId: cliArgs.resumeSessionId,
630
622
  });
@@ -637,6 +629,8 @@ async function main() {
637
629
  let reconnectRunner = null;
638
630
  let reconnectTaskId = null;
639
631
  let pendingRemoteStopEvent = null;
632
+ const pendingRemoteInterruptQueue = createPendingRemoteInterruptQueue();
633
+ const completedRefreshSessionRequests = new Map();
640
634
  let conductor = null;
641
635
  let reconnectResumeInFlight = false;
642
636
  let fireShuttingDown = false;
@@ -676,7 +670,7 @@ async function main() {
676
670
  source: "conductor-fire",
677
671
  metadata: { reconnect: true },
678
672
  });
679
- if (!launchedByDaemon) {
673
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "reconnect_running" })) {
680
674
  await conductor.sendTaskStatus(reconnectTaskId, {
681
675
  status: "RUNNING",
682
676
  summary: "conductor fire reconnected",
@@ -706,6 +700,56 @@ async function main() {
706
700
  pendingRemoteStopEvent = event;
707
701
  };
708
702
 
703
+ const handleInterruptTurnCommand = async (event) => {
704
+ fireWatchdog.onInbound();
705
+ if (!event || typeof event !== "object") {
706
+ return false;
707
+ }
708
+ const taskId = typeof event.taskId === "string" ? event.taskId : "";
709
+ if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
710
+ return false;
711
+ }
712
+ if (reconnectRunner && typeof reconnectRunner.requestInterruptFromRemote === "function") {
713
+ return await reconnectRunner.requestInterruptFromRemote(event);
714
+ }
715
+ return await pendingRemoteInterruptQueue.enqueue(event);
716
+ };
717
+
718
+ const rememberCompletedRefreshSessionRequest = (requestId, accepted) => {
719
+ if (!requestId) {
720
+ return accepted;
721
+ }
722
+ completedRefreshSessionRequests.set(requestId, accepted);
723
+ while (completedRefreshSessionRequests.size > 20) {
724
+ const oldest = completedRefreshSessionRequests.keys().next();
725
+ if (oldest.done) {
726
+ break;
727
+ }
728
+ completedRefreshSessionRequests.delete(oldest.value);
729
+ }
730
+ return accepted;
731
+ };
732
+
733
+ const handleRefreshSessionCommand = async (event) => {
734
+ fireWatchdog.onInbound();
735
+ if (!event || typeof event !== "object") {
736
+ return false;
737
+ }
738
+ const taskId = typeof event.taskId === "string" ? event.taskId : "";
739
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
740
+ if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
741
+ return false;
742
+ }
743
+ if (requestId && completedRefreshSessionRequests.has(requestId)) {
744
+ return completedRefreshSessionRequests.get(requestId) === true;
745
+ }
746
+ if (reconnectRunner && typeof reconnectRunner.requestRefreshSessionFromRemote === "function") {
747
+ const accepted = await reconnectRunner.requestRefreshSessionFromRemote(event);
748
+ return rememberCompletedRefreshSessionRequest(requestId, accepted !== false);
749
+ }
750
+ return rememberCompletedRefreshSessionRequest(requestId, false);
751
+ };
752
+
709
753
  if (cliArgs.configFile) {
710
754
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
711
755
  }
@@ -740,7 +784,10 @@ async function main() {
740
784
  conductor = await ConductorClient.connect({
741
785
  projectPath: runtimeProjectPath,
742
786
  extraEnv: env,
743
- extraHeaders: buildConductorConnectHeaders(),
787
+ extraHeaders: buildConductorConnectHeaders(pkgJson.version, {
788
+ backends: [cliArgs.backend],
789
+ capabilities: ["refresh_session_inplace"],
790
+ }),
744
791
  configFile: cliArgs.configFile,
745
792
  onConnected: (event) => {
746
793
  fireWatchdog.onConnected(event);
@@ -756,6 +803,8 @@ async function main() {
756
803
  fireWatchdog.onPong(event);
757
804
  },
758
805
  onStopTask: handleStopTaskCommand,
806
+ onInterruptTurn: handleInterruptTurnCommand,
807
+ onRefreshSession: handleRefreshSessionCommand,
759
808
  });
760
809
 
761
810
  const taskContext = await ensureTaskContext(conductor, {
@@ -795,7 +844,10 @@ async function main() {
795
844
  }
796
845
  }
797
846
 
798
- const resolvedResumeSessionId = cliArgs.resumeSessionId;
847
+ let nextResumeSessionId = cliArgs.resumeSessionId;
848
+ let nextInitialPrompt =
849
+ taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "";
850
+ let pendingRefreshSessionRequest = null;
799
851
 
800
852
  const sessionCommandLine = resolveAiSessionCommandLine(
801
853
  cliArgs.backend,
@@ -810,19 +862,6 @@ async function main() {
810
862
  env: process.env,
811
863
  });
812
864
 
813
- backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
814
- initialImages: cliArgs.initialImages,
815
- cwd: runtimeProjectPath,
816
- resumeSessionId: resolvedResumeSessionId,
817
- configFile: cliArgs.configFile,
818
- ...(cliArgs.sessionOptions || {}),
819
- ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
820
- logger: { log },
821
- ...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
822
- sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
823
- resumePersistedSession: Boolean(!resolvedResumeSessionId && taskContext.taskId),
824
- });
825
-
826
865
  log(`Using backend: ${cliArgs.backend}`);
827
866
 
828
867
  try {
@@ -835,25 +874,6 @@ async function main() {
835
874
  log(`Failed to report agent resume: ${error?.message || error}`);
836
875
  }
837
876
 
838
- const runner = new BridgeRunner({
839
- backendSession,
840
- conductor,
841
- taskId: taskContext.taskId,
842
- pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
843
- initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
844
- initialPromptDelivery: taskContext.initialPromptDelivery || "none",
845
- includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
846
- cliArgs: cliArgs.rawBackendArgs,
847
- backendName: cliArgs.backend,
848
- resumeSessionId: resolvedResumeSessionId,
849
- daemonName: resolvedDaemonName,
850
- });
851
- reconnectRunner = runner;
852
- if (pendingRemoteStopEvent) {
853
- await runner.requestStopFromRemote(pendingRemoteStopEvent);
854
- pendingRemoteStopEvent = null;
855
- }
856
-
857
877
  const signals = new AbortController();
858
878
  let shutdownSignal = null;
859
879
  let backendShutdownRequested = false;
@@ -887,7 +907,7 @@ async function main() {
887
907
  process.on("SIGINT", onSigint);
888
908
  process.on("SIGTERM", onSigterm);
889
909
 
890
- if (!launchedByDaemon) {
910
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "running" })) {
891
911
  try {
892
912
  await conductor.sendTaskStatus(taskContext.taskId, {
893
913
  status: "RUNNING",
@@ -897,68 +917,148 @@ async function main() {
897
917
  }
898
918
  }
899
919
 
900
- let runnerError = null;
901
920
  try {
902
- if (!resolvedResumeSessionId && String(cliArgs.sessionBackend || cliArgs.backend).trim().toLowerCase() === "codex") {
903
- await withFreshSessionBootstrapLock(cliArgs.sessionBackend || cliArgs.backend, runtimeProjectPath, async () => {
904
- await runner.announceBackendSession();
921
+ while (true) {
922
+ const currentRefreshSessionRequest = pendingRefreshSessionRequest;
923
+ let runnerError = null;
924
+
925
+ backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
926
+ initialImages: cliArgs.initialImages,
927
+ cwd: runtimeProjectPath,
928
+ resumeSessionId: nextResumeSessionId,
929
+ configFile: cliArgs.configFile,
930
+ ...(cliArgs.sessionOptions || {}),
931
+ ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
932
+ logger: { log },
933
+ ...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
934
+ sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
935
+ resumePersistedSession: Boolean(!nextResumeSessionId && taskContext.taskId),
905
936
  });
906
- }
907
- await runner.start(signals.signal);
908
- } catch (error) {
909
- runnerError = error;
910
- throw error;
911
- } finally {
912
- process.off("SIGINT", onSigint);
913
- process.off("SIGTERM", onSigterm);
914
- if (!launchedByDaemon) {
915
- const remoteStopReason = typeof runner.getRemoteStopReason === "function" ? runner.getRemoteStopReason() : null;
916
- const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
917
- // When the task was deleted by the user, the DB record is already gone —
918
- // attempting to send a final status update would fail with 500 and the
919
- // SDK durable outbox would retry forever, preventing the process from
920
- // exiting.
921
- const taskDeletedByUser = remoteStopReason === "deleted_by_user";
922
- const finalStatus = shutdownSignal
923
- ? {
924
- status: "KILLED",
925
- summary: `terminated by ${shutdownSignal}`,
926
- }
927
- : runnerError
937
+
938
+ const runner = new BridgeRunner({
939
+ backendSession,
940
+ conductor,
941
+ taskId: taskContext.taskId,
942
+ pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
943
+ initialPrompt: nextInitialPrompt,
944
+ initialPromptDelivery: taskContext.initialPromptDelivery || "none",
945
+ includeInitialImages: Boolean(nextInitialPrompt && cliArgs.initialImages.length),
946
+ cliArgs: cliArgs.rawBackendArgs,
947
+ backendName: cliArgs.backend,
948
+ resumeSessionId: nextResumeSessionId,
949
+ daemonName: resolvedDaemonName,
950
+ });
951
+ reconnectRunner = runner;
952
+ if (pendingRemoteStopEvent) {
953
+ await runner.requestStopFromRemote(pendingRemoteStopEvent);
954
+ pendingRemoteStopEvent = null;
955
+ }
956
+ await pendingRemoteInterruptQueue.flushWith((event) => runner.requestInterruptFromRemote(event));
957
+
958
+ try {
959
+ if (currentRefreshSessionRequest) {
960
+ await runner.announceBackendSession();
961
+ currentRefreshSessionRequest.resolve(true);
962
+ pendingRefreshSessionRequest = null;
963
+ } else if (
964
+ !nextResumeSessionId &&
965
+ String(cliArgs.sessionBackend || cliArgs.backend).trim().toLowerCase() === "codex"
966
+ ) {
967
+ await withFreshSessionBootstrapLock(
968
+ cliArgs.sessionBackend || cliArgs.backend,
969
+ runtimeProjectPath,
970
+ async () => {
971
+ await runner.announceBackendSession();
972
+ },
973
+ );
974
+ }
975
+ await runner.start(signals.signal);
976
+ } catch (error) {
977
+ runnerError = error;
978
+ if (currentRefreshSessionRequest) {
979
+ currentRefreshSessionRequest.resolve(false);
980
+ pendingRefreshSessionRequest = null;
981
+ }
982
+ }
983
+
984
+ const refreshSessionRequest =
985
+ typeof runner.getRefreshSessionRequest === "function"
986
+ ? runner.getRefreshSessionRequest()
987
+ : null;
988
+ if (!runnerError && !shutdownSignal && refreshSessionRequest) {
989
+ const refreshedSessionId =
990
+ refreshSessionRequest.sessionId ||
991
+ (typeof runner.boundSessionId === "string" ? runner.boundSessionId.trim() : "") ||
992
+ nextResumeSessionId;
993
+ if (refreshedSessionId) {
994
+ nextResumeSessionId = refreshedSessionId;
995
+ nextInitialPrompt = "";
996
+ pendingRefreshSessionRequest = refreshSessionRequest;
997
+ continue;
998
+ }
999
+ runnerError = new Error("refresh_session requires a bound session id");
1000
+ }
1001
+ if (refreshSessionRequest && refreshSessionRequest !== pendingRefreshSessionRequest) {
1002
+ refreshSessionRequest.resolve(false);
1003
+ }
1004
+
1005
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "final" })) {
1006
+ const remoteStopReason = typeof runner.getRemoteStopReason === "function" ? runner.getRemoteStopReason() : null;
1007
+ const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
1008
+ // When the task was deleted by the user, the DB record is already gone —
1009
+ // attempting to send a final status update would fail with 500 and the
1010
+ // SDK durable outbox would retry forever, preventing the process from
1011
+ // exiting.
1012
+ const taskDeletedByUser = remoteStopReason === "deleted_by_user";
1013
+ const finalStatus = shutdownSignal
928
1014
  ? {
929
1015
  status: "KILLED",
930
- summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
1016
+ summary: `terminated by ${shutdownSignal}`,
931
1017
  }
932
- : remoteStopSummary
1018
+ : runnerError
933
1019
  ? {
934
1020
  status: "KILLED",
935
- summary: remoteStopSummary,
1021
+ summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
936
1022
  }
937
- : {
938
- status: "COMPLETED",
939
- summary: "conductor fire exited",
940
- };
941
- if (!taskDeletedByUser) {
942
- try {
943
- const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
944
- if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
945
- await conductor.flushPendingUpstreamEvents({
946
- timeoutMs: 5_000,
947
- retryIntervalMs: 250,
948
- });
1023
+ : remoteStopSummary
1024
+ ? {
1025
+ status: "KILLED",
1026
+ summary: remoteStopSummary,
1027
+ }
1028
+ : {
1029
+ status: "COMPLETED",
1030
+ summary: "conductor fire exited",
1031
+ };
1032
+ if (!taskDeletedByUser) {
1033
+ try {
1034
+ const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
1035
+ if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
1036
+ await conductor.flushPendingUpstreamEvents({
1037
+ timeoutMs: 5_000,
1038
+ retryIntervalMs: 250,
1039
+ });
1040
+ }
1041
+ } catch (error) {
1042
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
1043
+ }
1044
+ } else {
1045
+ log(`Skipping final status report: task was deleted by user`);
1046
+ // Also clear any pending durable outbox retries (e.g. task_stop_ack)
1047
+ // that would keep failing against the deleted task.
1048
+ if (typeof conductor.clearDurableOutboxTimer === "function") {
1049
+ conductor.clearDurableOutboxTimer();
949
1050
  }
950
- } catch (error) {
951
- log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
952
- }
953
- } else {
954
- log(`Skipping final status report: task was deleted by user`);
955
- // Also clear any pending durable outbox retries (e.g. task_stop_ack)
956
- // that would keep failing against the deleted task.
957
- if (typeof conductor.clearDurableOutboxTimer === "function") {
958
- conductor.clearDurableOutboxTimer();
959
1051
  }
960
1052
  }
1053
+
1054
+ if (runnerError) {
1055
+ throw runnerError;
1056
+ }
1057
+ break;
961
1058
  }
1059
+ } finally {
1060
+ process.off("SIGINT", onSigint);
1061
+ process.off("SIGTERM", onSigterm);
962
1062
  if (shutdownSignal === "SIGINT") {
963
1063
  process.exitCode = 130;
964
1064
  } else if (shutdownSignal === "SIGTERM") {
@@ -966,6 +1066,7 @@ async function main() {
966
1066
  }
967
1067
  }
968
1068
  } finally {
1069
+ pendingRemoteInterruptQueue.rejectAll();
969
1070
  fireShuttingDown = true;
970
1071
  fireWatchdog.stop();
971
1072
  if (backendSession && typeof backendSession.close === "function") {
@@ -1183,6 +1284,12 @@ Environment:
1183
1284
  const requestedBackend = conductorArgs.backend
1184
1285
  ? normalizeRuntimeBackendName(conductorArgs.backend)
1185
1286
  : supportedBackends[0] || externalBackends[0];
1287
+ if ((conductorArgs.listBackends || listBackendsWithoutSeparator) && discoveryError) {
1288
+ throw discoveryError;
1289
+ }
1290
+ if (!conductorArgs.backend && discoveryError && isCommandOptionalBuiltInRuntimeBackend(requestedBackend)) {
1291
+ throw discoveryError;
1292
+ }
1186
1293
  const configuredBackend = await resolveConfiguredRuntimeBackend(requestedBackend, allowCliList, {
1187
1294
  configFilePath: configFileFromArgs,
1188
1295
  });
@@ -1207,7 +1314,9 @@ Environment:
1207
1314
  const isAllowedExternalBackend =
1208
1315
  !isBuiltInRuntimeBackend(sessionBackend) &&
1209
1316
  advertisedExternalBackends.has(sessionBackend);
1210
- if (backend && shouldRequireBackend && !hasConfiguredEntry && !isAllowedExternalBackend) {
1317
+ const isCommandOptionalBuiltInBackend =
1318
+ isCommandOptionalBuiltInRuntimeBackend(sessionBackend || backend);
1319
+ if (backend && shouldRequireBackend && !hasConfiguredEntry && !isAllowedExternalBackend && !isCommandOptionalBuiltInBackend) {
1211
1320
  throw new Error(
1212
1321
  `Unsupported backend "${backend}". Supported backends: ${[...runtimeSupportedBackends].join(", ") || "none configured"}.`,
1213
1322
  );
@@ -1559,6 +1668,7 @@ export async function resolveResumeContext(backend, sessionId, options = {}) {
1559
1668
 
1560
1669
  export async function bootstrapResumeContextForFire({
1561
1670
  backend,
1671
+ sessionBackend,
1562
1672
  configFile,
1563
1673
  resumeSessionId,
1564
1674
  env = process.env,
@@ -1582,7 +1692,8 @@ export async function bootstrapResumeContextForFire({
1582
1692
  return { resumeContext, runtimeProjectPath };
1583
1693
  }
1584
1694
 
1585
- resumeContext = await resolveResumeContextFn(backend, resumeSessionId, {
1695
+ const resumeLookupBackend = backend || sessionBackend;
1696
+ resumeContext = await resolveResumeContextFn(resumeLookupBackend, resumeSessionId, {
1586
1697
  configFilePath: configFile,
1587
1698
  });
1588
1699
  const sessionLocation = resumeContext.sessionPath ? ` at ${resumeContext.sessionPath}` : "";
@@ -1731,6 +1842,10 @@ export class BridgeRunner {
1731
1842
  os.hostname();
1732
1843
  this.needsReconnectRecovery = false;
1733
1844
  this.remoteStopInfo = null;
1845
+ this.refreshSessionRequest = null;
1846
+ this.remoteInterruptsByReplyTo = new Map();
1847
+ this.pendingInterruptRetryTimers = new Map();
1848
+ this.activeTurnReplyTo = "";
1734
1849
  this.sessionAnnouncementSent = false;
1735
1850
  this.boundSessionId = "";
1736
1851
  this.errorLoop = null;
@@ -1947,7 +2062,11 @@ export class BridgeRunner {
1947
2062
  }
1948
2063
 
1949
2064
  shouldSuppressReconnectRecovery() {
1950
- return this.stopped || Boolean(this.remoteStopInfo);
2065
+ return this.stopped || Boolean(this.remoteStopInfo) || Boolean(this.refreshSessionRequest);
2066
+ }
2067
+
2068
+ getRefreshSessionRequest() {
2069
+ return this.refreshSessionRequest;
1951
2070
  }
1952
2071
 
1953
2072
  getRemoteStopReason() {
@@ -1990,6 +2109,248 @@ export class BridgeRunner {
1990
2109
  }
1991
2110
  }
1992
2111
 
2112
+ async requestRefreshSessionFromRemote(event = {}) {
2113
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
2114
+ if (taskId && taskId !== this.taskId) {
2115
+ return false;
2116
+ }
2117
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
2118
+ const sessionId = typeof event.sessionId === "string" ? event.sessionId.trim() : "";
2119
+ const sessionFilePath =
2120
+ typeof event.sessionFilePath === "string" && event.sessionFilePath.trim()
2121
+ ? event.sessionFilePath.trim()
2122
+ : "";
2123
+ if (!sessionId) {
2124
+ return false;
2125
+ }
2126
+ if (this.refreshSessionRequest) {
2127
+ if (
2128
+ requestId &&
2129
+ this.refreshSessionRequest.requestId &&
2130
+ this.refreshSessionRequest.requestId === requestId
2131
+ ) {
2132
+ return await this.refreshSessionRequest.promise;
2133
+ }
2134
+ return false;
2135
+ }
2136
+
2137
+ let resolveRefresh;
2138
+ const promise = new Promise((resolve) => {
2139
+ resolveRefresh = resolve;
2140
+ });
2141
+ this.refreshSessionRequest = {
2142
+ requestId: requestId || null,
2143
+ sessionId,
2144
+ sessionFilePath: sessionFilePath || null,
2145
+ promise,
2146
+ resolve: resolveRefresh,
2147
+ };
2148
+ log(`Received refresh_session for ${this.taskId}; rebuilding backend session in-process`);
2149
+ this.stopped = true;
2150
+ if (typeof this.backendSession?.close === "function") {
2151
+ try {
2152
+ await this.backendSession.close();
2153
+ } catch (error) {
2154
+ log(`Failed to close backend session for refresh ${this.taskId}: ${error?.message || error}`);
2155
+ }
2156
+ }
2157
+ return await promise;
2158
+ }
2159
+
2160
+ normalizeReplyTarget(replyTo) {
2161
+ return typeof replyTo === "string" ? replyTo.trim() : "";
2162
+ }
2163
+
2164
+ isTurnInterruptedError(error) {
2165
+ const reason = typeof error?.reason === "string" ? error.reason.trim().toLowerCase() : "";
2166
+ if (reason === "turn_interrupted" || reason === "turn_cancelled") {
2167
+ return true;
2168
+ }
2169
+ const turnStatus = typeof error?.turnStatus === "string" ? error.turnStatus.trim().toLowerCase() : "";
2170
+ if (
2171
+ turnStatus === "interrupted" ||
2172
+ turnStatus === "cancelled" ||
2173
+ turnStatus === "canceled" ||
2174
+ turnStatus === "aborted"
2175
+ ) {
2176
+ return true;
2177
+ }
2178
+ const name = typeof error?.name === "string" ? error.name.trim().toLowerCase() : "";
2179
+ if (name === "aborterror") {
2180
+ return true;
2181
+ }
2182
+ const message = String(error?.message || error || "").toLowerCase();
2183
+ return (
2184
+ message.includes(" interrupted") ||
2185
+ message.includes("interrupt ") ||
2186
+ message.includes("turn interrupted") ||
2187
+ message.includes("cancelled") ||
2188
+ message.includes("canceled") ||
2189
+ message.includes("aborted")
2190
+ );
2191
+ }
2192
+
2193
+ async requestInterruptFromRemote(event = {}) {
2194
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
2195
+ if (taskId && taskId !== this.taskId) {
2196
+ return false;
2197
+ }
2198
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
2199
+ const reason = typeof event.reason === "string" ? event.reason.trim() : "";
2200
+ const targetReplyTo = this.normalizeReplyTarget(event.targetReplyTo);
2201
+ if (!targetReplyTo) {
2202
+ return false;
2203
+ }
2204
+ if (this.processedMessageIds.has(targetReplyTo)) {
2205
+ this.copilotLog(`ignore late interrupt_turn for processed replyTo=${targetReplyTo}`);
2206
+ return false;
2207
+ }
2208
+
2209
+ const existing = this.remoteInterruptsByReplyTo.get(targetReplyTo) || {};
2210
+ const interruptInfo = {
2211
+ requestId: requestId || existing.requestId || null,
2212
+ reason: reason || existing.reason || "user_interrupt",
2213
+ issued: Boolean(existing.issued),
2214
+ };
2215
+ this.remoteInterruptsByReplyTo.set(targetReplyTo, interruptInfo);
2216
+ log(
2217
+ `Received interrupt_turn for ${this.taskId} replyTo=${targetReplyTo}${
2218
+ interruptInfo.reason ? ` (${interruptInfo.reason})` : ""
2219
+ }`,
2220
+ );
2221
+ return await this.issueInterruptForReplyTarget(targetReplyTo);
2222
+ }
2223
+
2224
+ async issueInterruptForReplyTarget(replyTo) {
2225
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2226
+ if (!normalizedReplyTo) {
2227
+ return false;
2228
+ }
2229
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(normalizedReplyTo);
2230
+ if (!interruptInfo) {
2231
+ return false;
2232
+ }
2233
+ if (interruptInfo.issued) {
2234
+ return true;
2235
+ }
2236
+ const supportsTurnInterrupt = typeof this.backendSession?.interruptCurrentTurn === "function";
2237
+ const isActiveTarget = this.runningTurn && normalizedReplyTo === this.activeTurnReplyTo;
2238
+ const isInFlightTarget = this.inFlightMessageIds.has(normalizedReplyTo);
2239
+
2240
+ if (!isActiveTarget && isInFlightTarget) {
2241
+ this.copilotLog(`interrupt arrived after replyTo=${normalizedReplyTo} stopped being interruptible`);
2242
+ return false;
2243
+ }
2244
+
2245
+ if (!isActiveTarget) {
2246
+ if (!supportsTurnInterrupt) {
2247
+ log(`Backend session for ${this.taskId} does not support turn interruption`);
2248
+ return false;
2249
+ }
2250
+ this.copilotLog(`queued interrupt request for future replyTo=${normalizedReplyTo}`);
2251
+ return true;
2252
+ }
2253
+
2254
+ if (!supportsTurnInterrupt) {
2255
+ log(`Backend session for ${this.taskId} does not support turn interruption`);
2256
+ return false;
2257
+ }
2258
+ try {
2259
+ const interrupted = await this.backendSession.interruptCurrentTurn();
2260
+ if (interrupted === false) {
2261
+ interruptInfo.issued = false;
2262
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2263
+ if (
2264
+ this.runningTurn &&
2265
+ this.activeTurnReplyTo === normalizedReplyTo &&
2266
+ this.inFlightMessageIds.has(normalizedReplyTo)
2267
+ ) {
2268
+ this.copilotLog(`backend interrupt not ready replyTo=${normalizedReplyTo}; retrying`);
2269
+ this.scheduleInterruptRetryForReplyTarget(normalizedReplyTo);
2270
+ return true;
2271
+ }
2272
+ return false;
2273
+ }
2274
+ interruptInfo.issued = true;
2275
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2276
+ this.copilotLog(`requested backend interrupt replyTo=${normalizedReplyTo}`);
2277
+ return true;
2278
+ } catch (error) {
2279
+ interruptInfo.issued = false;
2280
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2281
+ log(`Failed to interrupt replyTo=${normalizedReplyTo} for ${this.taskId}: ${error?.message || error}`);
2282
+ return false;
2283
+ }
2284
+ }
2285
+
2286
+ scheduleInterruptRetryForReplyTarget(replyTo) {
2287
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2288
+ if (!normalizedReplyTo || this.pendingInterruptRetryTimers.has(normalizedReplyTo)) {
2289
+ return;
2290
+ }
2291
+
2292
+ const timer = setTimeout(() => {
2293
+ this.pendingInterruptRetryTimers.delete(normalizedReplyTo);
2294
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(normalizedReplyTo);
2295
+ if (
2296
+ !interruptInfo ||
2297
+ interruptInfo.issued ||
2298
+ this.processedMessageIds.has(normalizedReplyTo) ||
2299
+ !this.runningTurn ||
2300
+ this.activeTurnReplyTo !== normalizedReplyTo ||
2301
+ !this.inFlightMessageIds.has(normalizedReplyTo)
2302
+ ) {
2303
+ return;
2304
+ }
2305
+ void this.issueInterruptForReplyTarget(normalizedReplyTo);
2306
+ }, 50);
2307
+ if (typeof timer.unref === "function") {
2308
+ timer.unref();
2309
+ }
2310
+ this.pendingInterruptRetryTimers.set(normalizedReplyTo, timer);
2311
+ }
2312
+
2313
+ clearInterruptRetryForReplyTarget(replyTo) {
2314
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2315
+ const timer = this.pendingInterruptRetryTimers.get(normalizedReplyTo);
2316
+ if (!timer) {
2317
+ return;
2318
+ }
2319
+ clearTimeout(timer);
2320
+ this.pendingInterruptRetryTimers.delete(normalizedReplyTo);
2321
+ }
2322
+
2323
+ async handleInterruptedTurn(replyTo, interruptInfo) {
2324
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2325
+ this.clearInterruptRetryForReplyTarget(normalizedReplyTo);
2326
+ this.copilotLog(`turn interrupted replyTo=${normalizedReplyTo || "latest"}`);
2327
+ await this.reportRuntimeStatus(
2328
+ {
2329
+ phase: "interrupted",
2330
+ reply_in_progress: false,
2331
+ status_done_line: "Conversation interrupted",
2332
+ },
2333
+ normalizedReplyTo,
2334
+ );
2335
+ try {
2336
+ await this.conductor.sendMessage(this.taskId, "Conversation interrupted", {
2337
+ backend: this.backendName,
2338
+ reply_to: normalizedReplyTo || undefined,
2339
+ interrupted: true,
2340
+ interruption_request_id: interruptInfo?.requestId || undefined,
2341
+ reason: interruptInfo?.reason || undefined,
2342
+ cli_args: this.cliArgs,
2343
+ });
2344
+ } catch (error) {
2345
+ log(`Failed to send interrupt confirmation for ${this.taskId}: ${error?.message || error}`);
2346
+ }
2347
+ if (normalizedReplyTo) {
2348
+ this.processedMessageIds.add(normalizedReplyTo);
2349
+ this.remoteInterruptsByReplyTo.delete(normalizedReplyTo);
2350
+ }
2351
+ this.resetErrorLoop();
2352
+ }
2353
+
1993
2354
  async recoverAfterReconnect() {
1994
2355
  if (!this.needsReconnectRecovery) {
1995
2356
  return;
@@ -2502,6 +2863,7 @@ export class BridgeRunner {
2502
2863
  }
2503
2864
  this.lastRuntimeStatusSignature = null;
2504
2865
  this.runningTurn = true;
2866
+ this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
2505
2867
  const turnStartedAt = Date.now();
2506
2868
  let turnWatchdog = null;
2507
2869
  if (this.isCopilotBackend) {
@@ -2540,12 +2902,15 @@ export class BridgeRunner {
2540
2902
  );
2541
2903
  }
2542
2904
 
2543
- const result = await this.backendSession.runTurn(content, {
2905
+ const turnPromise = this.backendSession.runTurn(content, {
2544
2906
  useInitialImages,
2545
2907
  onProgress: (payload) => {
2546
2908
  void this.reportRuntimeStatus(payload, replyTo);
2547
2909
  },
2548
2910
  });
2911
+ await this.issueInterruptForReplyTarget(replyTo);
2912
+ const result = await turnPromise;
2913
+ this.activeTurnReplyTo = "";
2549
2914
  this.copilotLog(
2550
2915
  `runTurn completed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} answerLen=${String(
2551
2916
  result.text || "",
@@ -2582,6 +2947,10 @@ export class BridgeRunner {
2582
2947
  });
2583
2948
  }
2584
2949
  await this.syncBackendSessionBinding();
2950
+ if (replyTo) {
2951
+ this.clearInterruptRetryForReplyTarget(replyTo);
2952
+ this.remoteInterruptsByReplyTo.delete(replyTo);
2953
+ }
2585
2954
  if (replyTo) {
2586
2955
  this.processedMessageIds.add(replyTo);
2587
2956
  }
@@ -2600,6 +2969,7 @@ export class BridgeRunner {
2600
2969
  this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
2601
2970
  }
2602
2971
  } catch (error) {
2972
+ this.activeTurnReplyTo = "";
2603
2973
  const errorMessage = error instanceof Error ? error.message : String(error);
2604
2974
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2605
2975
  this.copilotLog(
@@ -2607,6 +2977,11 @@ export class BridgeRunner {
2607
2977
  );
2608
2978
  return;
2609
2979
  }
2980
+ const interruptInfo = replyTo ? this.remoteInterruptsByReplyTo.get(replyTo) : null;
2981
+ if (interruptInfo && this.isTurnInterruptedError(error)) {
2982
+ await this.handleInterruptedTurn(replyTo, interruptInfo);
2983
+ return;
2984
+ }
2610
2985
  if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
2611
2986
  return;
2612
2987
  }
@@ -2663,7 +3038,9 @@ export class BridgeRunner {
2663
3038
  }
2664
3039
  if (replyTo) {
2665
3040
  this.inFlightMessageIds.delete(replyTo);
3041
+ this.clearInterruptRetryForReplyTarget(replyTo);
2666
3042
  }
3043
+ this.activeTurnReplyTo = "";
2667
3044
  this.copilotLog(
2668
3045
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
2669
3046
  );