@love-moon/conductor-cli 0.2.35 → 0.2.37

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.
@@ -48,19 +48,19 @@ function resolveLauncherConfig() {
48
48
 
49
49
  if (inheritedLauncherScript && inheritedSubcommand === "daemon" && inheritedSubcommandArgs) {
50
50
  return {
51
- restartLauncherScript: inheritedLauncherScript,
52
- restartLauncherArgs: ["daemon", ...stripNohupArgs(inheritedSubcommandArgs)],
53
- versionCheckScript: inheritedLauncherScript,
54
- versionCheckArgs: ["--version"],
51
+ RESTART_LAUNCHER_SCRIPT: inheritedLauncherScript,
52
+ RESTART_LAUNCHER_ARGS: ["daemon", ...stripNohupArgs(inheritedSubcommandArgs)],
53
+ VERSION_CHECK_SCRIPT: inheritedLauncherScript,
54
+ VERSION_CHECK_ARGS: ["--version"],
55
55
  };
56
56
  }
57
57
 
58
58
  const daemonScript = path.resolve(process.argv[1]);
59
59
  return {
60
- restartLauncherScript: daemonScript,
61
- restartLauncherArgs: argv,
62
- versionCheckScript: inheritedLauncherScript,
63
- versionCheckArgs: ["--version"],
60
+ RESTART_LAUNCHER_SCRIPT: daemonScript,
61
+ RESTART_LAUNCHER_ARGS: argv,
62
+ VERSION_CHECK_SCRIPT: inheritedLauncherScript,
63
+ VERSION_CHECK_ARGS: ["--version"],
64
64
  };
65
65
  }
66
66
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.35",
4
- "gitCommitId": "686ee4d",
3
+ "version": "0.2.37",
4
+ "gitCommitId": "c656a7d",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -18,8 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@love-moon/ai-bridge": "0.1.4",
21
- "@love-moon/ai-sdk": "0.2.35",
22
- "@love-moon/conductor-sdk": "0.2.35",
21
+ "@love-moon/ai-manager": "0.2.37",
22
+ "@love-moon/ai-sdk": "0.2.37",
23
+ "@love-moon/conductor-sdk": "0.2.37",
23
24
  "chrome-launcher": "^1.2.1",
24
25
  "chrome-remote-interface": "^0.33.0",
25
26
  "dotenv": "^16.4.5",
@@ -39,6 +40,7 @@
39
40
  ],
40
41
  "overrides": {
41
42
  "@love-moon/ai-sdk": "file:../modules/ai-sdk",
43
+ "@love-moon/ai-manager": "file:../modules/ai-manager",
42
44
  "@love-moon/conductor-sdk": "file:../modules/conductor-sdk"
43
45
  }
44
46
  }
@@ -0,0 +1,158 @@
1
+ // Daemon-side glue between the realtime WebSocket and the @love-moon/ai-manager module.
2
+ // The web backend sends `ai_manager_request`; we dispatch by `action`, run it, and
3
+ // reply with `ai_manager_response` carrying the same `request_id`.
4
+
5
+ import { AiManager } from "@love-moon/ai-manager";
6
+
7
+ const VALID_ACTIONS = new Set(["status", "quota", "list_accounts", "switch_account"]);
8
+
9
+ /**
10
+ * @param {object} opts
11
+ * @param {string} [opts.configPath] Path to ~/.conductor/config.yaml. Defaults to ai-manager's default.
12
+ */
13
+ export function createAiManagerHandlers(opts = {}) {
14
+ const manager = new AiManager(opts.configPath ? { configPath: opts.configPath } : undefined);
15
+
16
+ async function status() {
17
+ // Probe install first; only check network for tools that are actually
18
+ // present so we don't pay an outbound HTTP timeout for a CLI the user
19
+ // never installed.
20
+ const [install, current] = await Promise.all([
21
+ manager.checkInstallAll(),
22
+ manager.getCurrentCodexAccount().catch(() => null),
23
+ ]);
24
+ const network = {};
25
+ const tools = ["codex", "claude", "kimi"];
26
+ await Promise.all(
27
+ tools.map(async (tool) => {
28
+ if (install[tool]?.installed) {
29
+ network[tool] = await manager.checkNetwork(tool);
30
+ } else {
31
+ network[tool] = {
32
+ reachable: false,
33
+ endpoint: "",
34
+ error: "not installed",
35
+ };
36
+ }
37
+ }),
38
+ );
39
+ return { install, network, currentCodexAccount: current };
40
+ }
41
+
42
+ async function quota(args = {}) {
43
+ const tools = pickToolFilter(args);
44
+ const out = {};
45
+ if (tools.has("codex")) {
46
+ try {
47
+ out.codex = await manager.getCodexQuota({
48
+ forceRefresh: Boolean(args.forceRefresh),
49
+ });
50
+ } catch (err) {
51
+ out.codex = { tool: "codex", error: errMsg(err), source: "unknown" };
52
+ }
53
+ }
54
+ if (tools.has("claude")) {
55
+ try {
56
+ out.claude = await manager.getClaudeQuota({
57
+ forceRefresh: Boolean(args.forceRefresh),
58
+ });
59
+ } catch (err) {
60
+ out.claude = { tool: "claude", error: errMsg(err), source: "unknown" };
61
+ }
62
+ }
63
+ if (tools.has("kimi")) {
64
+ try {
65
+ out.kimi = await manager.getKimiQuota({
66
+ forceRefresh: Boolean(args.forceRefresh),
67
+ });
68
+ } catch (err) {
69
+ out.kimi = { tool: "kimi", error: errMsg(err), source: "unknown" };
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ async function listAccounts() {
76
+ return { accounts: await manager.listCodexAccounts() };
77
+ }
78
+
79
+ async function switchAccount(args = {}) {
80
+ if (!args.name || typeof args.name !== "string") {
81
+ throw new Error("switch_account requires a `name` string");
82
+ }
83
+ return await manager.switchCodexAccount(args.name);
84
+ }
85
+
86
+ /**
87
+ * Run a single action and return a `result` object, never throwing.
88
+ * @param {{action:string,args?:object}} payload
89
+ */
90
+ async function dispatch(payload) {
91
+ const action = payload?.action;
92
+ if (!VALID_ACTIONS.has(action)) {
93
+ return { error: `unknown action: ${action}` };
94
+ }
95
+ try {
96
+ switch (action) {
97
+ case "status":
98
+ return { result: await status() };
99
+ case "quota":
100
+ return { result: await quota(payload?.args ?? {}) };
101
+ case "list_accounts":
102
+ return { result: await listAccounts() };
103
+ case "switch_account":
104
+ return { result: await switchAccount(payload?.args ?? {}) };
105
+ default:
106
+ return { error: `unhandled action: ${action}` };
107
+ }
108
+ } catch (err) {
109
+ return { error: errMsg(err) };
110
+ }
111
+ }
112
+
113
+ return { dispatch, manager };
114
+ }
115
+
116
+ /**
117
+ * Wire a handler against an event payload from the web backend, sending the
118
+ * response back through `client.sendJson` once the action completes.
119
+ *
120
+ * @param {object} client - conductor websocket client (must have sendJson)
121
+ * @param {ReturnType<typeof createAiManagerHandlers>} handlers
122
+ * @param {object} payload - event.payload with shape { request_id, action, args }
123
+ */
124
+ export async function handleAiManagerRequest(client, handlers, payload) {
125
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
126
+ const action = payload?.action ? String(payload.action) : "";
127
+ if (!requestId) {
128
+ // Without a request_id we cannot route the response anywhere; drop and log upstream.
129
+ return { error: "missing request_id" };
130
+ }
131
+
132
+ const out = await handlers.dispatch({ action, args: payload?.args });
133
+ await client
134
+ .sendJson({
135
+ type: "ai_manager_response",
136
+ payload: {
137
+ request_id: requestId,
138
+ action,
139
+ result: out.result,
140
+ error: out.error,
141
+ },
142
+ })
143
+ .catch(() => {});
144
+ return out;
145
+ }
146
+
147
+ function pickToolFilter(args) {
148
+ const t = args?.tool;
149
+ if (t === "codex") return new Set(["codex"]);
150
+ if (t === "claude") return new Set(["claude"]);
151
+ if (t === "kimi") return new Set(["kimi"]);
152
+ return new Set(["codex", "claude", "kimi"]);
153
+ }
154
+
155
+ function errMsg(err) {
156
+ if (err instanceof Error) return err.message;
157
+ return String(err);
158
+ }
package/src/daemon.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  ProjectContext,
17
17
  } from "@love-moon/conductor-sdk";
18
18
  import { DaemonLogCollector } from "./log-collector.js";
19
+ import { createAiManagerHandlers, handleAiManagerRequest } from "./ai-manager-handlers.js";
19
20
  import { resolveResumeContext } from "./fire/resume.js";
20
21
  import {
21
22
  filterRuntimeSupportedAllowCliList,
@@ -690,6 +691,17 @@ export function startDaemon(config = {}, deps = {}) {
690
691
  autoUpdateSupportedInstall &&
691
692
  (process.env.CONDUCTOR_AUTO_UPDATE !== "false") &&
692
693
  (userConfig.auto_update !== false);
694
+ // Auto-update respawn was historically broken in prod (camelCase/UPPER_SNAKE
695
+ // config-key mismatch between conductor-daemon.js and daemon.js), so no fleet
696
+ // has actually restarted itself via this path. The key mismatch is now fixed,
697
+ // which means auto-update would start respawning daemons globally. To avoid a
698
+ // silent activation, keep respawn gated behind an explicit opt-in until it
699
+ // has been validated on a canary.
700
+ const AUTO_UPDATE_RESPAWN_ENABLED =
701
+ config.AUTO_UPDATE_RESPAWN === true ||
702
+ config.AUTO_UPDATE_RESPAWN === "true" ||
703
+ process.env.CONDUCTOR_AUTO_UPDATE_RESPAWN === "true" ||
704
+ userConfig.auto_update_respawn === true;
693
705
  const UPDATE_WINDOW = parseUpdateWindowFn(
694
706
  process.env.CONDUCTOR_UPDATE_WINDOW || userConfig.update_window || "02:00-04:00"
695
707
  );
@@ -1449,13 +1461,15 @@ export function startDaemon(config = {}, deps = {}) {
1449
1461
  "x-conductor-backends": SUPPORTED_BACKENDS.join(","),
1450
1462
  "x-conductor-version": cliVersion,
1451
1463
  };
1452
- const advertisedCapabilities = ["project_path_validation"];
1464
+ const advertisedCapabilities = ["project_path_validation", "restart_daemon"];
1453
1465
  if (ptyTaskCapabilityEnabled) {
1454
1466
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
1455
1467
  }
1456
1468
  if (advertisedCapabilities.length > 0) {
1457
1469
  extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
1458
1470
  }
1471
+ const aiManagerHandlers = createAiManagerHandlers({ configPath: config.CONFIG_FILE });
1472
+
1459
1473
  const client = createWebSocketClient(sdkConfig, {
1460
1474
  extraHeaders,
1461
1475
  onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
@@ -1894,7 +1908,7 @@ export function startDaemon(config = {}, deps = {}) {
1894
1908
  return newPkg.version || null;
1895
1909
  }
1896
1910
 
1897
- async function performAutoUpdate(targetVersion) {
1911
+ async function installCliVersion(targetVersion, tag) {
1898
1912
  const pm = detectPackageManagerFn({
1899
1913
  launcherPath: restartLauncherScript || versionCheckScript,
1900
1914
  packageRoot: installedPackageRoot,
@@ -1902,7 +1916,7 @@ export function startDaemon(config = {}, deps = {}) {
1902
1916
  const pkgSpec = `${PACKAGE_NAME}@${targetVersion}`;
1903
1917
 
1904
1918
  if (pm === "pnpm") {
1905
- log("[auto-update] Preparing pnpm native dependency allowlist for node-pty");
1919
+ log(`[${tag}] Preparing pnpm native dependency allowlist for node-pty`);
1906
1920
  await ensurePnpmOnlyBuiltDependencies({
1907
1921
  runCommand: runBufferedCommand,
1908
1922
  dependencies: ["node-pty"],
@@ -1910,9 +1924,7 @@ export function startDaemon(config = {}, deps = {}) {
1910
1924
  });
1911
1925
  }
1912
1926
 
1913
- log(`[auto-update] Installing ${pkgSpec} via ${pm}...`);
1914
-
1915
- // Step 1: install
1927
+ log(`[${tag}] Installing ${pkgSpec} via ${pm}...`);
1916
1928
  const result = await runInstallCommand(pm, pkgSpec);
1917
1929
  if (!result.success) {
1918
1930
  throw new Error(
@@ -1920,13 +1932,6 @@ export function startDaemon(config = {}, deps = {}) {
1920
1932
  );
1921
1933
  }
1922
1934
 
1923
- // Step 2: re-check active tasks — a task may have arrived during the install
1924
- if (hasActiveTasks()) {
1925
- log("[auto-update] Active tasks appeared during install; aborting restart (will retry later)");
1926
- return;
1927
- }
1928
-
1929
- // Step 3: verify installed version using the globally resolved CLI entry point.
1930
1935
  try {
1931
1936
  const installedVersion = await readInstalledCliVersion();
1932
1937
  if (installedVersion !== targetVersion) {
@@ -1938,7 +1943,6 @@ export function startDaemon(config = {}, deps = {}) {
1938
1943
  throw new Error(`Version verification failed: ${verifyErr?.message || verifyErr}`);
1939
1944
  }
1940
1945
 
1941
- // Step 4: repair and verify native dependencies before shutting down the healthy daemon.
1942
1946
  try {
1943
1947
  await repairAndVerifyGlobalNodePty({
1944
1948
  packageManager: pm,
@@ -1950,26 +1954,33 @@ export function startDaemon(config = {}, deps = {}) {
1950
1954
  throw new Error(`Native dependency verification failed: ${verifyErr?.message || verifyErr}`);
1951
1955
  }
1952
1956
 
1953
- log(`[auto-update] Verified ${targetVersion} and node-pty. Restarting daemon...`);
1957
+ log(`[${tag}] Installed and verified ${targetVersion} (node-pty OK)`);
1958
+ }
1959
+
1960
+ async function restartDaemonProcess(reason, { allowForegroundRespawn = false } = {}) {
1961
+ const shouldRespawn = isBackgroundProcess || allowForegroundRespawn;
1962
+ if (shouldRespawn && !restartLauncherScript) {
1963
+ throw new Error("Missing daemon restart launcher script");
1964
+ }
1954
1965
 
1955
1966
  let logFd = null;
1956
- if (isBackgroundProcess) {
1957
- if (!restartLauncherScript) {
1958
- throw new Error("Missing daemon restart launcher script");
1959
- }
1967
+ if (shouldRespawn) {
1960
1968
  try {
1961
1969
  mkdirSyncFn(DAEMON_LOG_DIR, { recursive: true });
1962
1970
  } catch {
1963
1971
  /* ignore */
1964
1972
  }
1965
1973
  logFd = fs.openSync(DAEMON_LOG_PATH, "a");
1974
+ if (!isBackgroundProcess) {
1975
+ log(
1976
+ `[${reason}] Foreground daemon will be respawned in background. Logs: ${DAEMON_LOG_PATH}`
1977
+ );
1978
+ }
1966
1979
  }
1967
1980
 
1968
- // Step 5: graceful shutdown
1969
- await shutdownDaemon("auto-update");
1981
+ await shutdownDaemon(reason);
1970
1982
 
1971
- // Step 6: re-spawn (only in background/nohup mode)
1972
- if (isBackgroundProcess) {
1983
+ if (shouldRespawn) {
1973
1984
  const handoffToken = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1974
1985
  const handoffExpiresAt = Date.now() + 15_000;
1975
1986
  try {
@@ -1989,7 +2000,7 @@ export function startDaemon(config = {}, deps = {}) {
1989
2000
  },
1990
2001
  });
1991
2002
  child.unref();
1992
- log(`[auto-update] New daemon spawned (PID ${child.pid})`);
2003
+ log(`[${reason}] New daemon spawned (PID ${child.pid})`);
1993
2004
  } catch (error) {
1994
2005
  cleanupLock();
1995
2006
  exitFn(1);
@@ -2000,11 +2011,108 @@ export function startDaemon(config = {}, deps = {}) {
2000
2011
  }
2001
2012
  }
2002
2013
  } else {
2014
+ log(`[${reason}] Foreground mode — please restart the daemon manually.`);
2015
+ }
2016
+ exitFn(0);
2017
+ }
2018
+
2019
+ async function performAutoUpdate(targetVersion) {
2020
+ await installCliVersion(targetVersion, "auto-update");
2021
+
2022
+ if (!AUTO_UPDATE_RESPAWN_ENABLED) {
2003
2023
  log(
2004
- `[auto-update] Updated to ${targetVersion}. Foreground mode please restart the daemon.`
2024
+ `[auto-update] Installed ${targetVersion}. Respawn is gated off (set CONDUCTOR_AUTO_UPDATE_RESPAWN=true to enable); new version will take effect on next manual restart.`
2005
2025
  );
2026
+ return;
2027
+ }
2028
+
2029
+ // Re-check active tasks — a task may have arrived during the install
2030
+ if (hasActiveTasks()) {
2031
+ log("[auto-update] Active tasks appeared during install; aborting restart (will retry later)");
2032
+ return;
2033
+ }
2034
+
2035
+ log(`[auto-update] Restarting daemon after upgrade to ${targetVersion}...`);
2036
+ await restartDaemonProcess("auto-update");
2037
+ }
2038
+
2039
+ async function handleRestartDaemon(payload) {
2040
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
2041
+ const targetVersionRaw = payload?.target_version
2042
+ ? String(payload.target_version).trim()
2043
+ : "latest";
2044
+
2045
+ if (daemonShuttingDown) {
2046
+ log(`[restart_daemon] Ignored (${requestId}): daemon already shutting down`);
2047
+ sendAgentCommandAck({
2048
+ requestId,
2049
+ eventType: "restart_daemon",
2050
+ accepted: false,
2051
+ }).catch(() => {});
2052
+ return;
2053
+ }
2054
+ if (autoUpdateInProgress) {
2055
+ log(`[restart_daemon] Ignored (${requestId}): restart already in progress`);
2056
+ sendAgentCommandAck({
2057
+ requestId,
2058
+ eventType: "restart_daemon",
2059
+ accepted: false,
2060
+ }).catch(() => {});
2061
+ return;
2062
+ }
2063
+
2064
+ autoUpdateInProgress = true;
2065
+ try {
2066
+ log(
2067
+ `[restart_daemon] Received (request_id=${requestId}, target=${targetVersionRaw}, current=${cliVersion})`
2068
+ );
2069
+ // Ack receipt before blocking work so the server learns this daemon
2070
+ // accepted the command even if install/shutdown takes several seconds.
2071
+ await sendAgentCommandAck({
2072
+ requestId,
2073
+ eventType: "restart_daemon",
2074
+ accepted: true,
2075
+ }).catch(() => {});
2076
+
2077
+ let resolvedTarget = null;
2078
+ if (targetVersionRaw === "latest") {
2079
+ try {
2080
+ const latest = await fetchLatestVersionFn();
2081
+ if (latest && SEMVER_RE.test(latest) && isNewerVersionFn(latest, cliVersion)) {
2082
+ resolvedTarget = latest;
2083
+ } else if (latest) {
2084
+ log(`[restart_daemon] Already on latest (${cliVersion}); plain restart`);
2085
+ }
2086
+ } catch (err) {
2087
+ logError(`[restart_daemon] Failed to fetch latest version: ${err?.message || err}`);
2088
+ }
2089
+ } else if (SEMVER_RE.test(targetVersionRaw) && targetVersionRaw !== cliVersion) {
2090
+ resolvedTarget = targetVersionRaw;
2091
+ }
2092
+
2093
+ if (resolvedTarget) {
2094
+ try {
2095
+ await installCliVersion(resolvedTarget, "restart_daemon");
2096
+ } catch (err) {
2097
+ logError(
2098
+ `[restart_daemon] Install failed, falling back to plain restart: ${err?.message || err}`
2099
+ );
2100
+ }
2101
+ }
2102
+
2103
+ try {
2104
+ await restartDaemonProcess("restart_daemon", { allowForegroundRespawn: true });
2105
+ } catch (err) {
2106
+ logError(`[restart_daemon] Restart failed after shutdown; exiting: ${err?.message || err}`);
2107
+ cleanupLock();
2108
+ exitFn(1);
2109
+ }
2110
+ } finally {
2111
+ // Clear in case restartDaemonProcess never actually exited (e.g. in tests
2112
+ // where exitFn is mocked). In real runtime exitFn is process.exit, so
2113
+ // this line is unreachable on both success and failure paths.
2114
+ autoUpdateInProgress = false;
2006
2115
  }
2007
- exitFn(0);
2008
2116
  }
2009
2117
 
2010
2118
  const getActiveTaskIds = () => [
@@ -3431,6 +3539,16 @@ export function startDaemon(config = {}, deps = {}) {
3431
3539
  if (event.type === "validate_project_path") {
3432
3540
  void handleValidateProjectPath(event.payload);
3433
3541
  }
3542
+ if (event.type === "ai_manager_request") {
3543
+ handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
3544
+ logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
3545
+ });
3546
+ }
3547
+ if (event.type === "restart_daemon") {
3548
+ void handleRestartDaemon(event.payload).catch((error) => {
3549
+ logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
3550
+ });
3551
+ }
3434
3552
  }
3435
3553
 
3436
3554
  function markWatchdogHealthy(signal, at = Date.now()) {