@love-moon/conductor-cli 0.2.37 → 0.2.39

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.
@@ -53,6 +53,14 @@ export function isLaunchedByDaemon(env = process.env) {
53
53
  );
54
54
  }
55
55
 
56
+ export function syncPwdEnvWithProcessCwdForDaemonLaunch(env = process.env, cwdFn = process.cwd) {
57
+ if (!isLaunchedByDaemon(env)) {
58
+ return false;
59
+ }
60
+ env.PWD = cwdFn();
61
+ return true;
62
+ }
63
+
56
64
  const ENABLE_FIRE_LOCAL_LOG = !isLaunchedByDaemon(process.env);
57
65
 
58
66
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
@@ -81,6 +89,13 @@ export function shouldRunReconnectRecovery({
81
89
  return !runner.shouldSuppressReconnectRecovery();
82
90
  }
83
91
 
92
+ export function shouldFireReportTaskStatus({ launchedByDaemon = false, phase } = {}) {
93
+ if (phase === "final") {
94
+ return true;
95
+ }
96
+ return !launchedByDaemon;
97
+ }
98
+
84
99
  // Load allow_cli_list from config file (no defaults - must be configured)
85
100
  function loadFireConfigYaml(configFilePath) {
86
101
  const home = os.homedir();
@@ -569,7 +584,41 @@ export class FireWatchdog {
569
584
  }
570
585
  }
571
586
 
587
+ export function createPendingRemoteInterruptQueue() {
588
+ const pending = [];
589
+
590
+ return {
591
+ enqueue(event) {
592
+ return new Promise((resolve) => {
593
+ pending.push({ event, resolve });
594
+ });
595
+ },
596
+
597
+ async flushWith(dispatch) {
598
+ while (pending.length > 0) {
599
+ const next = pending.shift();
600
+ if (!next) {
601
+ continue;
602
+ }
603
+ try {
604
+ next.resolve(await dispatch(next.event));
605
+ } catch {
606
+ next.resolve(false);
607
+ }
608
+ }
609
+ },
610
+
611
+ rejectAll() {
612
+ while (pending.length > 0) {
613
+ const next = pending.shift();
614
+ next?.resolve(false);
615
+ }
616
+ },
617
+ };
618
+ }
619
+
572
620
  async function main() {
621
+ syncPwdEnvWithProcessCwdForDaemonLaunch();
573
622
  const cliArgs = await parseCliArgs();
574
623
  let runtimeProjectPath = process.cwd();
575
624
  let backendSession = null;
@@ -628,6 +677,7 @@ async function main() {
628
677
  let reconnectRunner = null;
629
678
  let reconnectTaskId = null;
630
679
  let pendingRemoteStopEvent = null;
680
+ const pendingRemoteInterruptQueue = createPendingRemoteInterruptQueue();
631
681
  let conductor = null;
632
682
  let reconnectResumeInFlight = false;
633
683
  let fireShuttingDown = false;
@@ -667,7 +717,7 @@ async function main() {
667
717
  source: "conductor-fire",
668
718
  metadata: { reconnect: true },
669
719
  });
670
- if (!launchedByDaemon) {
720
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "reconnect_running" })) {
671
721
  await conductor.sendTaskStatus(reconnectTaskId, {
672
722
  status: "RUNNING",
673
723
  summary: "conductor fire reconnected",
@@ -697,6 +747,21 @@ async function main() {
697
747
  pendingRemoteStopEvent = event;
698
748
  };
699
749
 
750
+ const handleInterruptTurnCommand = async (event) => {
751
+ fireWatchdog.onInbound();
752
+ if (!event || typeof event !== "object") {
753
+ return false;
754
+ }
755
+ const taskId = typeof event.taskId === "string" ? event.taskId : "";
756
+ if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
757
+ return false;
758
+ }
759
+ if (reconnectRunner && typeof reconnectRunner.requestInterruptFromRemote === "function") {
760
+ return await reconnectRunner.requestInterruptFromRemote(event);
761
+ }
762
+ return await pendingRemoteInterruptQueue.enqueue(event);
763
+ };
764
+
700
765
  if (cliArgs.configFile) {
701
766
  env.CONDUCTOR_CONFIG = cliArgs.configFile;
702
767
  }
@@ -747,6 +812,7 @@ async function main() {
747
812
  fireWatchdog.onPong(event);
748
813
  },
749
814
  onStopTask: handleStopTaskCommand,
815
+ onInterruptTurn: handleInterruptTurnCommand,
750
816
  });
751
817
 
752
818
  const taskContext = await ensureTaskContext(conductor, {
@@ -844,6 +910,7 @@ async function main() {
844
910
  await runner.requestStopFromRemote(pendingRemoteStopEvent);
845
911
  pendingRemoteStopEvent = null;
846
912
  }
913
+ await pendingRemoteInterruptQueue.flushWith((event) => runner.requestInterruptFromRemote(event));
847
914
 
848
915
  const signals = new AbortController();
849
916
  let shutdownSignal = null;
@@ -878,7 +945,7 @@ async function main() {
878
945
  process.on("SIGINT", onSigint);
879
946
  process.on("SIGTERM", onSigterm);
880
947
 
881
- if (!launchedByDaemon) {
948
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "running" })) {
882
949
  try {
883
950
  await conductor.sendTaskStatus(taskContext.taskId, {
884
951
  status: "RUNNING",
@@ -902,7 +969,7 @@ async function main() {
902
969
  } finally {
903
970
  process.off("SIGINT", onSigint);
904
971
  process.off("SIGTERM", onSigterm);
905
- if (!launchedByDaemon) {
972
+ if (shouldFireReportTaskStatus({ launchedByDaemon, phase: "final" })) {
906
973
  const remoteStopReason = typeof runner.getRemoteStopReason === "function" ? runner.getRemoteStopReason() : null;
907
974
  const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
908
975
  // When the task was deleted by the user, the DB record is already gone —
@@ -925,10 +992,10 @@ async function main() {
925
992
  status: "KILLED",
926
993
  summary: remoteStopSummary,
927
994
  }
928
- : {
929
- status: "COMPLETED",
930
- summary: "conductor fire exited",
931
- };
995
+ : {
996
+ status: "COMPLETED",
997
+ summary: "conductor fire exited",
998
+ };
932
999
  if (!taskDeletedByUser) {
933
1000
  try {
934
1001
  const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
@@ -957,6 +1024,7 @@ async function main() {
957
1024
  }
958
1025
  }
959
1026
  } finally {
1027
+ pendingRemoteInterruptQueue.rejectAll();
960
1028
  fireShuttingDown = true;
961
1029
  fireWatchdog.stop();
962
1030
  if (backendSession && typeof backendSession.close === "function") {
@@ -1722,6 +1790,9 @@ export class BridgeRunner {
1722
1790
  os.hostname();
1723
1791
  this.needsReconnectRecovery = false;
1724
1792
  this.remoteStopInfo = null;
1793
+ this.remoteInterruptsByReplyTo = new Map();
1794
+ this.pendingInterruptRetryTimers = new Map();
1795
+ this.activeTurnReplyTo = "";
1725
1796
  this.sessionAnnouncementSent = false;
1726
1797
  this.boundSessionId = "";
1727
1798
  this.errorLoop = null;
@@ -1981,6 +2052,200 @@ export class BridgeRunner {
1981
2052
  }
1982
2053
  }
1983
2054
 
2055
+ normalizeReplyTarget(replyTo) {
2056
+ return typeof replyTo === "string" ? replyTo.trim() : "";
2057
+ }
2058
+
2059
+ isTurnInterruptedError(error) {
2060
+ const reason = typeof error?.reason === "string" ? error.reason.trim().toLowerCase() : "";
2061
+ if (reason === "turn_interrupted" || reason === "turn_cancelled") {
2062
+ return true;
2063
+ }
2064
+ const turnStatus = typeof error?.turnStatus === "string" ? error.turnStatus.trim().toLowerCase() : "";
2065
+ if (
2066
+ turnStatus === "interrupted" ||
2067
+ turnStatus === "cancelled" ||
2068
+ turnStatus === "canceled" ||
2069
+ turnStatus === "aborted"
2070
+ ) {
2071
+ return true;
2072
+ }
2073
+ const name = typeof error?.name === "string" ? error.name.trim().toLowerCase() : "";
2074
+ if (name === "aborterror") {
2075
+ return true;
2076
+ }
2077
+ const message = String(error?.message || error || "").toLowerCase();
2078
+ return (
2079
+ message.includes(" interrupted") ||
2080
+ message.includes("interrupt ") ||
2081
+ message.includes("turn interrupted") ||
2082
+ message.includes("cancelled") ||
2083
+ message.includes("canceled") ||
2084
+ message.includes("aborted")
2085
+ );
2086
+ }
2087
+
2088
+ async requestInterruptFromRemote(event = {}) {
2089
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
2090
+ if (taskId && taskId !== this.taskId) {
2091
+ return false;
2092
+ }
2093
+ const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
2094
+ const reason = typeof event.reason === "string" ? event.reason.trim() : "";
2095
+ const targetReplyTo = this.normalizeReplyTarget(event.targetReplyTo);
2096
+ if (!targetReplyTo) {
2097
+ return false;
2098
+ }
2099
+ if (this.processedMessageIds.has(targetReplyTo)) {
2100
+ this.copilotLog(`ignore late interrupt_turn for processed replyTo=${targetReplyTo}`);
2101
+ return false;
2102
+ }
2103
+
2104
+ const existing = this.remoteInterruptsByReplyTo.get(targetReplyTo) || {};
2105
+ const interruptInfo = {
2106
+ requestId: requestId || existing.requestId || null,
2107
+ reason: reason || existing.reason || "user_interrupt",
2108
+ issued: Boolean(existing.issued),
2109
+ };
2110
+ this.remoteInterruptsByReplyTo.set(targetReplyTo, interruptInfo);
2111
+ log(
2112
+ `Received interrupt_turn for ${this.taskId} replyTo=${targetReplyTo}${
2113
+ interruptInfo.reason ? ` (${interruptInfo.reason})` : ""
2114
+ }`,
2115
+ );
2116
+ return await this.issueInterruptForReplyTarget(targetReplyTo);
2117
+ }
2118
+
2119
+ async issueInterruptForReplyTarget(replyTo) {
2120
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2121
+ if (!normalizedReplyTo) {
2122
+ return false;
2123
+ }
2124
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(normalizedReplyTo);
2125
+ if (!interruptInfo) {
2126
+ return false;
2127
+ }
2128
+ if (interruptInfo.issued) {
2129
+ return true;
2130
+ }
2131
+ const supportsTurnInterrupt = typeof this.backendSession?.interruptCurrentTurn === "function";
2132
+ const isActiveTarget = this.runningTurn && normalizedReplyTo === this.activeTurnReplyTo;
2133
+ const isInFlightTarget = this.inFlightMessageIds.has(normalizedReplyTo);
2134
+
2135
+ if (!isActiveTarget && isInFlightTarget) {
2136
+ this.copilotLog(`interrupt arrived after replyTo=${normalizedReplyTo} stopped being interruptible`);
2137
+ return false;
2138
+ }
2139
+
2140
+ if (!isActiveTarget) {
2141
+ if (!supportsTurnInterrupt) {
2142
+ log(`Backend session for ${this.taskId} does not support turn interruption`);
2143
+ return false;
2144
+ }
2145
+ this.copilotLog(`queued interrupt request for future replyTo=${normalizedReplyTo}`);
2146
+ return true;
2147
+ }
2148
+
2149
+ if (!supportsTurnInterrupt) {
2150
+ log(`Backend session for ${this.taskId} does not support turn interruption`);
2151
+ return false;
2152
+ }
2153
+ try {
2154
+ const interrupted = await this.backendSession.interruptCurrentTurn();
2155
+ if (interrupted === false) {
2156
+ interruptInfo.issued = false;
2157
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2158
+ if (
2159
+ this.runningTurn &&
2160
+ this.activeTurnReplyTo === normalizedReplyTo &&
2161
+ this.inFlightMessageIds.has(normalizedReplyTo)
2162
+ ) {
2163
+ this.copilotLog(`backend interrupt not ready replyTo=${normalizedReplyTo}; retrying`);
2164
+ this.scheduleInterruptRetryForReplyTarget(normalizedReplyTo);
2165
+ return true;
2166
+ }
2167
+ return false;
2168
+ }
2169
+ interruptInfo.issued = true;
2170
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2171
+ this.copilotLog(`requested backend interrupt replyTo=${normalizedReplyTo}`);
2172
+ return true;
2173
+ } catch (error) {
2174
+ interruptInfo.issued = false;
2175
+ this.remoteInterruptsByReplyTo.set(normalizedReplyTo, interruptInfo);
2176
+ log(`Failed to interrupt replyTo=${normalizedReplyTo} for ${this.taskId}: ${error?.message || error}`);
2177
+ return false;
2178
+ }
2179
+ }
2180
+
2181
+ scheduleInterruptRetryForReplyTarget(replyTo) {
2182
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2183
+ if (!normalizedReplyTo || this.pendingInterruptRetryTimers.has(normalizedReplyTo)) {
2184
+ return;
2185
+ }
2186
+
2187
+ const timer = setTimeout(() => {
2188
+ this.pendingInterruptRetryTimers.delete(normalizedReplyTo);
2189
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(normalizedReplyTo);
2190
+ if (
2191
+ !interruptInfo ||
2192
+ interruptInfo.issued ||
2193
+ this.processedMessageIds.has(normalizedReplyTo) ||
2194
+ !this.runningTurn ||
2195
+ this.activeTurnReplyTo !== normalizedReplyTo ||
2196
+ !this.inFlightMessageIds.has(normalizedReplyTo)
2197
+ ) {
2198
+ return;
2199
+ }
2200
+ void this.issueInterruptForReplyTarget(normalizedReplyTo);
2201
+ }, 50);
2202
+ if (typeof timer.unref === "function") {
2203
+ timer.unref();
2204
+ }
2205
+ this.pendingInterruptRetryTimers.set(normalizedReplyTo, timer);
2206
+ }
2207
+
2208
+ clearInterruptRetryForReplyTarget(replyTo) {
2209
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2210
+ const timer = this.pendingInterruptRetryTimers.get(normalizedReplyTo);
2211
+ if (!timer) {
2212
+ return;
2213
+ }
2214
+ clearTimeout(timer);
2215
+ this.pendingInterruptRetryTimers.delete(normalizedReplyTo);
2216
+ }
2217
+
2218
+ async handleInterruptedTurn(replyTo, interruptInfo) {
2219
+ const normalizedReplyTo = this.normalizeReplyTarget(replyTo);
2220
+ this.clearInterruptRetryForReplyTarget(normalizedReplyTo);
2221
+ this.copilotLog(`turn interrupted replyTo=${normalizedReplyTo || "latest"}`);
2222
+ await this.reportRuntimeStatus(
2223
+ {
2224
+ phase: "interrupted",
2225
+ reply_in_progress: false,
2226
+ status_done_line: "Conversation interrupted",
2227
+ },
2228
+ normalizedReplyTo,
2229
+ );
2230
+ try {
2231
+ await this.conductor.sendMessage(this.taskId, "Conversation interrupted", {
2232
+ backend: this.backendName,
2233
+ reply_to: normalizedReplyTo || undefined,
2234
+ interrupted: true,
2235
+ interruption_request_id: interruptInfo?.requestId || undefined,
2236
+ reason: interruptInfo?.reason || undefined,
2237
+ cli_args: this.cliArgs,
2238
+ });
2239
+ } catch (error) {
2240
+ log(`Failed to send interrupt confirmation for ${this.taskId}: ${error?.message || error}`);
2241
+ }
2242
+ if (normalizedReplyTo) {
2243
+ this.processedMessageIds.add(normalizedReplyTo);
2244
+ this.remoteInterruptsByReplyTo.delete(normalizedReplyTo);
2245
+ }
2246
+ this.resetErrorLoop();
2247
+ }
2248
+
1984
2249
  async recoverAfterReconnect() {
1985
2250
  if (!this.needsReconnectRecovery) {
1986
2251
  return;
@@ -2493,6 +2758,7 @@ export class BridgeRunner {
2493
2758
  }
2494
2759
  this.lastRuntimeStatusSignature = null;
2495
2760
  this.runningTurn = true;
2761
+ this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
2496
2762
  const turnStartedAt = Date.now();
2497
2763
  let turnWatchdog = null;
2498
2764
  if (this.isCopilotBackend) {
@@ -2531,12 +2797,15 @@ export class BridgeRunner {
2531
2797
  );
2532
2798
  }
2533
2799
 
2534
- const result = await this.backendSession.runTurn(content, {
2800
+ const turnPromise = this.backendSession.runTurn(content, {
2535
2801
  useInitialImages,
2536
2802
  onProgress: (payload) => {
2537
2803
  void this.reportRuntimeStatus(payload, replyTo);
2538
2804
  },
2539
2805
  });
2806
+ await this.issueInterruptForReplyTarget(replyTo);
2807
+ const result = await turnPromise;
2808
+ this.activeTurnReplyTo = "";
2540
2809
  this.copilotLog(
2541
2810
  `runTurn completed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} answerLen=${String(
2542
2811
  result.text || "",
@@ -2573,6 +2842,10 @@ export class BridgeRunner {
2573
2842
  });
2574
2843
  }
2575
2844
  await this.syncBackendSessionBinding();
2845
+ if (replyTo) {
2846
+ this.clearInterruptRetryForReplyTarget(replyTo);
2847
+ this.remoteInterruptsByReplyTo.delete(replyTo);
2848
+ }
2576
2849
  if (replyTo) {
2577
2850
  this.processedMessageIds.add(replyTo);
2578
2851
  }
@@ -2591,6 +2864,7 @@ export class BridgeRunner {
2591
2864
  this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
2592
2865
  }
2593
2866
  } catch (error) {
2867
+ this.activeTurnReplyTo = "";
2594
2868
  const errorMessage = error instanceof Error ? error.message : String(error);
2595
2869
  if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
2596
2870
  this.copilotLog(
@@ -2598,6 +2872,11 @@ export class BridgeRunner {
2598
2872
  );
2599
2873
  return;
2600
2874
  }
2875
+ const interruptInfo = replyTo ? this.remoteInterruptsByReplyTo.get(replyTo) : null;
2876
+ if (interruptInfo && this.isTurnInterruptedError(error)) {
2877
+ await this.handleInterruptedTurn(replyTo, interruptInfo);
2878
+ return;
2879
+ }
2601
2880
  if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
2602
2881
  return;
2603
2882
  }
@@ -2654,7 +2933,9 @@ export class BridgeRunner {
2654
2933
  }
2655
2934
  if (replyTo) {
2656
2935
  this.inFlightMessageIds.delete(replyTo);
2936
+ this.clearInterruptRetryForReplyTarget(replyTo);
2657
2937
  }
2938
+ this.activeTurnReplyTo = "";
2658
2939
  this.copilotLog(
2659
2940
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
2660
2941
  );
@@ -6,13 +6,14 @@
6
6
 
7
7
  import { fileURLToPath } from "node:url";
8
8
  import path from "node:path";
9
- import { createRequire } from "node:module";
10
9
  import fs from "node:fs";
11
10
  import { spawn } from "node:child_process";
12
11
  import process from "node:process";
13
12
  import readline from "node:readline/promises";
14
13
  import {
15
14
  PACKAGE_NAME,
15
+ buildUpgradeCommand,
16
+ resolveInstallMethod,
16
17
  fetchLatestVersion,
17
18
  isNewerVersion,
18
19
  detectPackageManager,
@@ -24,11 +25,14 @@ import {
24
25
 
25
26
  const __filename = fileURLToPath(import.meta.url);
26
27
  const __dirname = path.dirname(__filename);
27
- const require = createRequire(import.meta.url);
28
28
  const PKG_ROOT = path.join(__dirname, "..");
29
29
 
30
30
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
31
31
  const CURRENT_VERSION = pkgJson.version;
32
+ const INSTALL_METHOD = resolveInstallMethod({
33
+ env: process.env,
34
+ packageRoot: PKG_ROOT,
35
+ });
32
36
 
33
37
  // ANSI 颜色代码
34
38
  const COLORS = {
@@ -56,6 +60,13 @@ async function main() {
56
60
  process.exit(0);
57
61
  }
58
62
 
63
+ if (INSTALL_METHOD === "homebrew") {
64
+ console.log(colorize("🍺 Homebrew-managed install detected", "cyan"));
65
+ console.log("");
66
+ console.log(` Use ${colorize(buildUpgradeCommand({ env: process.env }), "green")} to upgrade conductor.`);
67
+ process.exit(0);
68
+ }
69
+
59
70
  console.log(colorize(`📦 ${PACKAGE_NAME}`, "cyan"));
60
71
  console.log(` Current version: ${CURRENT_VERSION}`);
61
72
  console.log("");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.37",
4
- "gitCommitId": "c656a7d",
3
+ "version": "0.2.39",
4
+ "gitCommitId": "30204c8",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@love-moon/ai-bridge": "0.1.4",
21
- "@love-moon/ai-manager": "0.2.37",
22
- "@love-moon/ai-sdk": "0.2.37",
23
- "@love-moon/conductor-sdk": "0.2.37",
21
+ "@love-moon/ai-manager": "0.2.39",
22
+ "@love-moon/ai-sdk": "0.2.39",
23
+ "@love-moon/conductor-sdk": "0.2.39",
24
24
  "chrome-launcher": "^1.2.1",
25
25
  "chrome-remote-interface": "^0.33.0",
26
26
  "dotenv": "^16.4.5",
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { fetchLatestVersion, isNewerVersion } from "./version-check.js";
5
+ import { buildUpgradeCommand, fetchLatestVersion, isNewerVersion } from "./version-check.js";
6
6
 
7
7
  export const DEFAULT_VERSION_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
8
8
  export const DEFAULT_VERSION_NOTIFY_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -115,8 +115,13 @@ export function shouldSkipVersionCheck(options = {}) {
115
115
  return { skip: false, reason: null };
116
116
  }
117
117
 
118
- export function buildUpdateNotice({ currentVersion, latestVersion }) {
119
- return `New conductor version available: ${currentVersion} -> ${latestVersion}. Run: conductor update`;
118
+ export function buildUpdateNotice({ currentVersion, latestVersion, installMethod, env }) {
119
+ const noticeEnv =
120
+ installMethod && !env?.CONDUCTOR_INSTALL_METHOD
121
+ ? { ...env, CONDUCTOR_INSTALL_METHOD: installMethod }
122
+ : env;
123
+ const upgradeCommand = buildUpgradeCommand({ env: noticeEnv });
124
+ return `New conductor version available: ${currentVersion} -> ${latestVersion}. Run: ${upgradeCommand}`;
120
125
  }
121
126
 
122
127
  function shouldNotifyVersion({ latestVersion, currentVersion, cache, nowMs, notifyIntervalMs }) {
@@ -147,6 +152,7 @@ export async function maybeCheckForUpdates(options = {}) {
147
152
  const env = options.env || process.env;
148
153
  const currentVersion = normalizeOptionalString(options.currentVersion);
149
154
  const subcommand = normalizeOptionalString(options.subcommand);
155
+ const installMethod = normalizeOptionalString(env.CONDUCTOR_INSTALL_METHOD);
150
156
  const nowMs = options.nowMs ?? Date.now();
151
157
  const checkIntervalMs = options.checkIntervalMs ?? DEFAULT_VERSION_CHECK_INTERVAL_MS;
152
158
  const notifyIntervalMs = options.notifyIntervalMs ?? DEFAULT_VERSION_NOTIFY_INTERVAL_MS;
@@ -182,7 +188,12 @@ export async function maybeCheckForUpdates(options = {}) {
182
188
  nowMs,
183
189
  notifyIntervalMs,
184
190
  })) {
185
- writeNotice(buildUpdateNotice({ currentVersion, latestVersion: cache.latestVersion }));
191
+ writeNotice(buildUpdateNotice({
192
+ currentVersion,
193
+ latestVersion: cache.latestVersion,
194
+ installMethod,
195
+ env,
196
+ }));
186
197
  cache = createUpdatedCache(cache, {
187
198
  lastNotifiedVersion: cache.latestVersion,
188
199
  lastNotifiedAt: new Date(nowMs).toISOString(),
@@ -223,7 +234,12 @@ export async function maybeCheckForUpdates(options = {}) {
223
234
  nowMs,
224
235
  notifyIntervalMs,
225
236
  })) {
226
- writeNotice(buildUpdateNotice({ currentVersion, latestVersion: versionToNotify }));
237
+ writeNotice(buildUpdateNotice({
238
+ currentVersion,
239
+ latestVersion: versionToNotify,
240
+ installMethod,
241
+ env,
242
+ }));
227
243
  cache = createUpdatedCache(cache, {
228
244
  lastNotifiedVersion: versionToNotify,
229
245
  lastNotifiedAt: new Date(nowMs).toISOString(),
package/src/daemon.js CHANGED
@@ -29,12 +29,14 @@ import {
29
29
  } from "./runtime-backends.js";
30
30
  import {
31
31
  PACKAGE_NAME,
32
+ buildUpgradeCommand,
32
33
  fetchLatestVersion,
33
34
  isNewerVersion,
34
35
  detectPackageManager,
35
36
  parseUpdateWindow,
36
37
  isInUpdateWindow,
37
38
  isManagedInstallPath,
39
+ resolveInstallMethod,
38
40
  } from "./version-check.js";
39
41
  import {
40
42
  ensurePnpmOnlyBuiltDependencies,
@@ -662,12 +664,22 @@ export function startDaemon(config = {}, deps = {}) {
662
664
  const parseUpdateWindowFn = deps.parseUpdateWindow || parseUpdateWindow;
663
665
  const isInUpdateWindowFn = deps.isInUpdateWindow || isInUpdateWindow;
664
666
  const isManagedInstallPathFn = deps.isManagedInstallPath || isManagedInstallPath;
667
+ const resolveInstallMethodFn = deps.resolveInstallMethod || resolveInstallMethod;
665
668
  const installedPackageRoot = deps.packageRoot || PACKAGE_ROOT;
666
669
  const cliVersion = deps.cliVersion || CLI_VERSION;
667
670
  const isBackgroundProcess = deps.isBackgroundProcess ?? !process.stdout.isTTY;
668
671
  const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
672
+ const installMethod = resolveInstallMethodFn({
673
+ env: process.env,
674
+ packageRoot: installedPackageRoot,
675
+ readFileSync: deps.readFileSync || fs.readFileSync,
676
+ });
669
677
  const autoUpdateSupportedInstall =
670
- autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
678
+ (installMethod !== "homebrew") &&
679
+ (autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot, {
680
+ env: process.env,
681
+ readFileSync: deps.readFileSync || fs.readFileSync,
682
+ }));
671
683
  const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
672
684
  const lockHandoffToken =
673
685
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
@@ -1570,18 +1582,34 @@ export function startDaemon(config = {}, deps = {}) {
1570
1582
  });
1571
1583
 
1572
1584
  if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
1573
- log("[auto-update] Disabled for local/dev install; set CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL=true to override");
1585
+ if (installMethod === "homebrew") {
1586
+ log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
1587
+ } else {
1588
+ log("[auto-update] Disabled for local/dev install; set CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL=true to override");
1589
+ }
1574
1590
  }
1575
1591
 
1576
- watchdogTimer = setInterval(() => {
1592
+ const runMaintenanceTick = async () => {
1577
1593
  void runDaemonWatchdog();
1578
- // Auto-update checks (internally throttled)
1579
- void checkForUpdate().catch(() => {});
1580
- void tryAutoUpdate().catch(() => {});
1594
+ try {
1595
+ await checkForUpdate();
1596
+ } catch {
1597
+ // ignore non-critical version check failures
1598
+ }
1599
+ try {
1600
+ await tryAutoUpdate();
1601
+ } catch {
1602
+ // ignore non-critical auto-update failures
1603
+ }
1604
+ };
1605
+
1606
+ watchdogTimer = setInterval(() => {
1607
+ void runMaintenanceTick();
1581
1608
  }, DAEMON_WATCHDOG_INTERVAL_MS);
1582
1609
  if (typeof watchdogTimer?.unref === "function") {
1583
1610
  watchdogTimer.unref();
1584
1611
  }
1612
+ void runMaintenanceTick();
1585
1613
  })();
1586
1614
 
1587
1615
  function markBackendHttpSuccess(at = Date.now()) {
@@ -3808,6 +3836,10 @@ export function startDaemon(config = {}, deps = {}) {
3808
3836
  return true;
3809
3837
  }
3810
3838
 
3839
+ function shouldDaemonReportFireChildTerminalStatus(record) {
3840
+ return !Boolean(record?.managedByFireBridge);
3841
+ }
3842
+
3811
3843
  function handleStopTask(payload) {
3812
3844
  const taskId = payload?.task_id;
3813
3845
  if (!taskId) return;
@@ -4246,6 +4278,7 @@ export function startDaemon(config = {}, deps = {}) {
4246
4278
 
4247
4279
  const env = {
4248
4280
  ...process.env,
4281
+ PWD: taskDir,
4249
4282
  CONDUCTOR_PROJECT_ID: projectId,
4250
4283
  CONDUCTOR_TASK_ID: taskId,
4251
4284
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
@@ -4309,6 +4342,7 @@ export function startDaemon(config = {}, deps = {}) {
4309
4342
  projectId,
4310
4343
  logPath,
4311
4344
  stopForceKillTimer: null,
4345
+ managedByFireBridge: true,
4312
4346
  });
4313
4347
 
4314
4348
  client
@@ -4378,7 +4412,7 @@ export function startDaemon(config = {}, deps = {}) {
4378
4412
  ? "completed"
4379
4413
  : `exited with code ${code}`;
4380
4414
 
4381
- if (!suppressExitStatusReport) {
4415
+ if (!suppressExitStatusReport && shouldDaemonReportFireChildTerminalStatus(active)) {
4382
4416
  client
4383
4417
  .sendJson({
4384
4418
  type: "task_status_update",
@@ -4733,6 +4767,7 @@ export function startDaemon(config = {}, deps = {}) {
4733
4767
  projectId: normalizedProjectId,
4734
4768
  logPath,
4735
4769
  stopForceKillTimer: null,
4770
+ managedByFireBridge: true,
4736
4771
  });
4737
4772
 
4738
4773
  client
@@ -4795,7 +4830,7 @@ export function startDaemon(config = {}, deps = {}) {
4795
4830
  ? "completed"
4796
4831
  : `exited with code ${code}`;
4797
4832
 
4798
- if (!suppressExitStatusReport) {
4833
+ if (!suppressExitStatusReport && shouldDaemonReportFireChildTerminalStatus(active)) {
4799
4834
  client
4800
4835
  .sendJson({
4801
4836
  type: "task_status_update",
@@ -4835,6 +4870,9 @@ export function startDaemon(config = {}, deps = {}) {
4835
4870
  await Promise.allSettled(
4836
4871
  activeEntries.map(async ([taskId, record]) => {
4837
4872
  suppressedExitStatusReports.add(taskId);
4873
+ if (!shouldDaemonReportFireChildTerminalStatus(record)) {
4874
+ return;
4875
+ }
4838
4876
  try {
4839
4877
  await withTimeout(
4840
4878
  client.sendJson({
@@ -28,7 +28,7 @@ function appendProviderModulePaths(parts, value) {
28
28
  if (!raw) {
29
29
  return;
30
30
  }
31
- for (const item of raw.split(process.platform === "win32" ? ";" : ":")) {
31
+ for (const item of splitProviderModulePathString(raw)) {
32
32
  const normalized = item.trim();
33
33
  if (normalized) {
34
34
  parts.push(normalized);
@@ -36,6 +36,49 @@ function appendProviderModulePaths(parts, value) {
36
36
  }
37
37
  }
38
38
 
39
+ function looksLikeProviderModulePath(value) {
40
+ const normalized = String(value || "").trim();
41
+ if (!normalized) {
42
+ return false;
43
+ }
44
+ return (
45
+ normalized.startsWith("/") ||
46
+ normalized.startsWith("./") ||
47
+ normalized.startsWith("../") ||
48
+ normalized.startsWith("~/") ||
49
+ normalized.startsWith("file:") ||
50
+ normalized.includes("/") ||
51
+ normalized.includes("\\") ||
52
+ /\.[cm]?[jt]sx?$/i.test(normalized) ||
53
+ /^[A-Za-z]:[\\/]/.test(normalized)
54
+ );
55
+ }
56
+
57
+ function splitProviderModulePathString(raw) {
58
+ const normalized = String(raw || "").trim();
59
+ if (!normalized) {
60
+ return [];
61
+ }
62
+
63
+ const platformParts = normalized
64
+ .split(path.delimiter)
65
+ .map((item) => item.trim())
66
+ .filter(Boolean);
67
+ if (platformParts.length > 1 || !normalized.includes(",")) {
68
+ return platformParts;
69
+ }
70
+
71
+ const commaParts = normalized
72
+ .split(",")
73
+ .map((item) => item.trim())
74
+ .filter(Boolean);
75
+ if (commaParts.length > 1 && commaParts.every(looksLikeProviderModulePath)) {
76
+ return commaParts;
77
+ }
78
+
79
+ return platformParts;
80
+ }
81
+
39
82
  function listProviderModulePaths(providerPathEnv) {
40
83
  const parts = [];
41
84
  appendProviderModulePaths(parts, providerPathEnv);
@@ -2,14 +2,17 @@
2
2
  * Shared version-check utilities used by both `conductor update` and the daemon auto-update flow.
3
3
  */
4
4
 
5
+ import fs from "node:fs";
5
6
  import http from "node:http";
6
7
  import https from "node:https";
7
8
  import path from "node:path";
8
9
  import { execFileSync, execSync } from "node:child_process";
9
10
 
10
11
  export const PACKAGE_NAME = "@love-moon/conductor-cli";
12
+ export const DEFAULT_HOMEBREW_FORMULA = "lovemoon-ai/tap/conductor";
11
13
  const DEFAULT_UPDATE_WINDOW = { startMinutes: 120, endMinutes: 240 };
12
14
  const REQUEST_TIMEOUT_MS = 10_000;
15
+ const INSTALL_METHOD_FILENAME = ".install-method";
13
16
 
14
17
  function resolveTimeoutMs(value) {
15
18
  const parsed = Number.parseInt(String(value ?? ""), 10);
@@ -100,6 +103,50 @@ function getRegistryBaseUrl(overrideRegistryUrl) {
100
103
  return candidate;
101
104
  }
102
105
 
106
+ function normalizeInstallMethod(value) {
107
+ if (typeof value !== "string") {
108
+ return null;
109
+ }
110
+ const normalized = value.trim().toLowerCase();
111
+ return normalized || null;
112
+ }
113
+
114
+ export function resolveHomebrewFormula(env = process.env) {
115
+ const configured = typeof env?.CONDUCTOR_HOMEBREW_FORMULA === "string"
116
+ ? env.CONDUCTOR_HOMEBREW_FORMULA.trim()
117
+ : "";
118
+ return configured || DEFAULT_HOMEBREW_FORMULA;
119
+ }
120
+
121
+ export function resolveInstallMethod(options = {}) {
122
+ const env = options.env || process.env;
123
+ const envMethod = normalizeInstallMethod(env?.CONDUCTOR_INSTALL_METHOD);
124
+ if (envMethod) {
125
+ return envMethod;
126
+ }
127
+
128
+ const packageRoot = typeof options.packageRoot === "string" ? options.packageRoot.trim() : "";
129
+ if (!packageRoot) {
130
+ return null;
131
+ }
132
+
133
+ const readFileSyncFn = options.readFileSync || fs.readFileSync;
134
+ const installMethodPath = path.join(path.resolve(packageRoot), INSTALL_METHOD_FILENAME);
135
+ try {
136
+ return normalizeInstallMethod(readFileSyncFn(installMethodPath, "utf-8"));
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ export function buildUpgradeCommand(options = {}) {
143
+ const installMethod = resolveInstallMethod(options);
144
+ if (installMethod === "homebrew") {
145
+ return `brew upgrade ${resolveHomebrewFormula(options.env || process.env)}`;
146
+ }
147
+ return "conductor update";
148
+ }
149
+
103
150
  /**
104
151
  * Compare two semver-like version strings.
105
152
  * Returns `true` when `latest` is strictly newer than `current`.
@@ -217,7 +264,10 @@ export function parseUpdateWindow(str) {
217
264
  * Auto-update should only mutate managed/global installs. Local repo runs and pnpm-linked
218
265
  * worktrees are treated as development installs and are skipped by default.
219
266
  */
220
- export function isManagedInstallPath(packageRoot) {
267
+ export function isManagedInstallPath(packageRoot, options = {}) {
268
+ if (resolveInstallMethod({ ...options, packageRoot }) === "homebrew") {
269
+ return false;
270
+ }
221
271
  if (typeof packageRoot !== "string" || !packageRoot.trim()) {
222
272
  return false;
223
273
  }