@love-moon/conductor-cli 0.7.5 → 0.7.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.7.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 7bbb412: Add `CONDUCTOR_HOME` support for relocating user-level configuration, logs,
8
+ Fire locks, sessions, update metadata, and AI manager caches while leaving
9
+ project-scoped `.conductor` directories and Fire task markers in place.
10
+
11
+ Migrate device authorization to `conductor.conductor-ai.top` while preserving
12
+ compatibility with the legacy official endpoint and self-hosted backends.
13
+
14
+ - Updated dependencies [7bbb412]
15
+ - @love-moon/conductor-sdk@0.7.6
16
+ - @love-moon/ai-sdk@0.7.6
17
+
3
18
  ## 0.7.5
4
19
 
5
20
  ### Patch Changes
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import process from "node:process";
7
6
  import { fileURLToPath } from "node:url";
@@ -10,6 +9,7 @@ import yargs from "yargs/yargs";
10
9
  import { hideBin } from "yargs/helpers";
11
10
  import { loadConfig } from "@love-moon/conductor-sdk";
12
11
  import { envForExplicitConfigFile } from "../src/config-env.js";
12
+ import { resolveConductorConfigPath } from "../src/conductor-paths.js";
13
13
 
14
14
  const isMainModule = (() => {
15
15
  const currentFile = fileURLToPath(import.meta.url);
@@ -18,8 +18,7 @@ const isMainModule = (() => {
18
18
  })();
19
19
 
20
20
  function resolveDefaultConfigPath(env = process.env) {
21
- const home = env.HOME || env.USERPROFILE || os.homedir();
22
- return path.join(home, ".conductor", "config.yaml");
21
+ return resolveConductorConfigPath(undefined, env);
23
22
  }
24
23
 
25
24
  function readTextFile(filePath) {
@@ -55,7 +54,7 @@ export async function connectFeishuChannel(options = {}) {
55
54
  throw new Error("fetch is not available");
56
55
  }
57
56
 
58
- const resolvedConfigPath = path.resolve(options.configFile || resolveDefaultConfigPath(env));
57
+ const resolvedConfigPath = resolveConductorConfigPath(options.configFile, env);
59
58
  const rawYaml = readTextFile(resolvedConfigPath);
60
59
  const config = loadConfig(resolvedConfigPath, {
61
60
  env: envForExplicitConfigFile(options.configFile, env),
@@ -9,6 +9,7 @@ import yargs from 'yargs';
9
9
  import { hideBin } from 'yargs/helpers';
10
10
  import { launch as launchChrome } from 'chrome-launcher';
11
11
  import CDP from 'chrome-remote-interface';
12
+ import { resolveConductorConfigPath } from '../src/conductor-paths.js';
12
13
 
13
14
 
14
15
  const BACKEND_URLS = {
@@ -52,7 +53,7 @@ const PAGE_AUTOMATION_PATH = new URL('pageAutomation.js', AUTOMATION_SRC_ROOT);
52
53
  const PROVIDERS_DIR = new URL('providers/', AUTOMATION_SRC_ROOT);
53
54
  let automationScriptCache;
54
55
 
55
- const CONFIG_PATH = path.join(homedir(), '.conductor', 'config.yaml');
56
+ const CONFIG_PATH = resolveConductorConfigPath();
56
57
 
57
58
  function expandHomeDir(maybePath) {
58
59
  if (!maybePath) {
@@ -80,7 +81,7 @@ async function loadUserConfig() {
80
81
  }
81
82
  } catch (error) {
82
83
  if (error.code !== 'ENOENT') {
83
- console.warn('Failed to read ~/.conductor/config.yaml:', error);
84
+ console.warn(`Failed to read ${CONFIG_PATH}:`, error);
84
85
  }
85
86
  cachedUserConfig = {};
86
87
  }
@@ -9,9 +9,10 @@ import { execFileSync, execSync } from "node:child_process";
9
9
  import yargs from "yargs/yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
11
  import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
12
+ import { resolveConductorConfigPath } from "../src/conductor-paths.js";
12
13
 
13
- const CONFIG_DIR = path.join(os.homedir(), ".conductor");
14
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.yaml");
14
+ const CONFIG_FILE = resolveConductorConfigPath();
15
+ const CONFIG_DIR = path.dirname(CONFIG_FILE);
15
16
  const packageJson = JSON.parse(
16
17
  fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
17
18
  );
@@ -68,6 +69,9 @@ const defaultDaemonName = os.hostname() || "my-daemon";
68
69
  const cliVersion = packageJson.version || "unknown";
69
70
  const OPENCODE_INSTALL_URL = "https://opencode.ai/install";
70
71
  const OPENCODE_NPM_PACKAGE = "opencode-ai";
72
+ const LEGACY_CONDUCTOR_PUBLIC_HOST = "conductor-ai.top";
73
+ const CONDUCTOR_PUBLIC_HOST = "conductor.conductor-ai.top";
74
+ const CONDUCTOR_PUBLIC_ORIGIN = "https://conductor.conductor-ai.top";
71
75
 
72
76
  const COLORS = {
73
77
  yellow: "\x1b[33m",
@@ -518,7 +522,9 @@ async function authorizeDeviceAndGetToken() {
518
522
  console.log(colorize("Open this link in your browser to authorize this device:", "cyan"));
519
523
  console.log("");
520
524
  console.log(`Device code: ${colorize(startData.user_code, "bold")}`);
521
- console.log(`Direct link: ${startData.verification_uri_complete}`);
525
+ console.log(
526
+ `Direct link: ${normalizeOfficialConductorUrl(startData.verification_uri_complete, backendUrl)}`,
527
+ );
522
528
  console.log("");
523
529
  console.log("Only approve the request if the web page shows the same device code.");
524
530
  console.log("");
@@ -551,10 +557,17 @@ async function authorizeDeviceAndGetToken() {
551
557
  continue;
552
558
  }
553
559
  if (pollData.status === "approved") {
560
+ const resolvedBackendUrl = normalizeOfficialConductorUrl(
561
+ pollData.backend_url || backendUrl,
562
+ backendUrl,
563
+ );
554
564
  const result = {
555
565
  agentToken: pollData.agent_token,
556
- backendUrl: pollData.backend_url || backendUrl,
557
- websocketUrl: pollData.websocket_url || null,
566
+ backendUrl: resolvedBackendUrl,
567
+ websocketUrl: normalizeOfficialConductorUrl(
568
+ pollData.websocket_url || null,
569
+ backendUrl,
570
+ ),
558
571
  };
559
572
  lastDeviceAuthConfig = result;
560
573
  console.log(colorize("✓ Device authorized", "green"));
@@ -571,6 +584,43 @@ async function authorizeDeviceAndGetToken() {
571
584
  throw new Error("Device authorization timed out");
572
585
  }
573
586
 
587
+ function isOfficialConductorBackend(value) {
588
+ if (typeof value !== "string" || !value.trim()) {
589
+ return false;
590
+ }
591
+ try {
592
+ const hostname = new URL(value).hostname;
593
+ return hostname === LEGACY_CONDUCTOR_PUBLIC_HOST || hostname === CONDUCTOR_PUBLIC_HOST;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+
599
+ function normalizeOfficialConductorUrl(value, requestBackendUrl) {
600
+ if (typeof value !== "string" || !value.trim()) {
601
+ return value;
602
+ }
603
+ if (!isOfficialConductorBackend(requestBackendUrl)) {
604
+ return value;
605
+ }
606
+
607
+ try {
608
+ const parsed = new URL(value);
609
+ if (parsed.hostname !== LEGACY_CONDUCTOR_PUBLIC_HOST) {
610
+ return value;
611
+ }
612
+
613
+ const canonicalOrigin = new URL(CONDUCTOR_PUBLIC_ORIGIN);
614
+ parsed.protocol = parsed.protocol === "ws:" || parsed.protocol === "wss:"
615
+ ? "wss:"
616
+ : canonicalOrigin.protocol;
617
+ parsed.host = canonicalOrigin.host;
618
+ return parsed.toString();
619
+ } catch {
620
+ return value;
621
+ }
622
+ }
623
+
574
624
  async function parseJsonResponse(response) {
575
625
  try {
576
626
  return await response.json();
@@ -9,10 +9,16 @@ import { hideBin } from "yargs/helpers";
9
9
  import yaml from "js-yaml";
10
10
 
11
11
  import { startDaemon } from "../src/daemon.js";
12
+ import {
13
+ materializeConductorPathEnv,
14
+ resolveConductorConfigPath,
15
+ resolveConductorHome,
16
+ } from "../src/conductor-paths.js";
12
17
 
13
18
  const argv = hideBin(process.argv);
14
19
 
15
20
  const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor-daemon";
21
+ const CONDUCTOR_HOME = resolveConductorHome();
16
22
 
17
23
  function parseJsonArrayEnv(value) {
18
24
  if (typeof value !== "string" || !value.trim()) {
@@ -75,8 +81,7 @@ function formatBeijingTimestampForFile(date = new Date()) {
75
81
 
76
82
  function loadUserConfig(configFilePath) {
77
83
  try {
78
- const home = os.homedir();
79
- const configPath = configFilePath || path.join(home, ".conductor", "config.yaml");
84
+ const configPath = resolveConductorConfigPath(configFilePath);
80
85
  if (!fs.existsSync(configPath)) {
81
86
  return {};
82
87
  }
@@ -146,7 +151,7 @@ const args = yargs(argv)
146
151
  .option("nohup", {
147
152
  type: "boolean",
148
153
  default: false,
149
- describe: "Run in background and write logs to ~/.conductor/logs/<timestamp>.log",
154
+ describe: `Run in background and write logs to ${path.join(CONDUCTOR_HOME, "logs", "<timestamp>.log")}`,
150
155
  })
151
156
  .option("force", {
152
157
  type: "boolean",
@@ -162,15 +167,21 @@ const args = yargs(argv)
162
167
  type: "string",
163
168
  describe: "Path to Conductor config file",
164
169
  })
165
- .example("$0 --config-file ~/.conductor/config.yaml", "Run with daemon_name from config")
170
+ .example(`$0 --config-file ${resolveConductorConfigPath()}`, "Run with daemon_name from config")
166
171
  .example("$0 --nohup", "Run daemon in background with logfile")
167
172
  .example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
168
173
  .help()
169
174
  .strict()
170
175
  .parse();
171
176
 
177
+ const materializedConductorPathEnv = materializeConductorPathEnv(args.configFile);
178
+ Object.assign(process.env, materializedConductorPathEnv);
179
+ const effectiveConfigFile = args.configFile
180
+ ? materializedConductorPathEnv.CONDUCTOR_CONFIG
181
+ : undefined;
182
+
172
183
  if (args.nohup) {
173
- const workspaceRoot = resolveWorkspaceRoot(args.configFile);
184
+ const workspaceRoot = resolveWorkspaceRoot(effectiveConfigFile);
174
185
  const runningPid = findRunningDaemonPid(workspaceRoot);
175
186
  if (runningPid && !args.force) {
176
187
  process.stderr.write(
@@ -179,7 +190,7 @@ if (args.nohup) {
179
190
  process.exit(1);
180
191
  }
181
192
 
182
- const logsDir = path.join(os.homedir(), ".conductor", "logs");
193
+ const logsDir = path.join(resolveConductorHome(), "logs");
183
194
  fs.mkdirSync(logsDir, { recursive: true });
184
195
  const timestamp = formatBeijingTimestampForFile(new Date());
185
196
  const logPath = path.join(logsDir, `${timestamp}.log`);
@@ -197,7 +208,7 @@ if (args.nohup) {
197
208
 
198
209
  startDaemon({
199
210
  CLEAN_ALL: args.cleanAll,
200
- CONFIG_FILE: args.configFile,
211
+ CONFIG_FILE: effectiveConfigFile,
201
212
  FORCE: args.force,
202
213
  ...resolveLauncherConfig(),
203
214
  });
@@ -36,6 +36,7 @@ import {
36
36
  normalizeRuntimeBackendAlias,
37
37
  normalizeRuntimeBackendName,
38
38
  } from "../src/runtime-backends.js";
39
+ import { resolveConductorConfigPath, resolveConductorHome } from "../src/conductor-paths.js";
39
40
 
40
41
  const __filename = fileURLToPath(import.meta.url);
41
42
  const __dirname = path.dirname(__filename);
@@ -111,8 +112,7 @@ export function shouldFireReportTaskStatus({ launchedByDaemon = false, phase } =
111
112
 
112
113
  // Load allow_cli_list from config file (no defaults - must be configured)
113
114
  function loadFireConfigYaml(configFilePath) {
114
- const home = os.homedir();
115
- const configPath = configFilePath || process.env.CONDUCTOR_CONFIG || path.join(home, ".conductor", "config.yaml");
115
+ const configPath = resolveConductorConfigPath(configFilePath);
116
116
  try {
117
117
  if (fs.existsSync(configPath)) {
118
118
  const content = fs.readFileSync(configPath, "utf8");
@@ -313,14 +313,14 @@ function resolveLockWorkingDirectory(workingDirectory) {
313
313
  }
314
314
  }
315
315
 
316
- export function resolveFreshSessionBootstrapLockPath(backendName, workingDirectory) {
316
+ export function resolveFreshSessionBootstrapLockPath(backendName, workingDirectory, env = process.env) {
317
317
  const normalizedBackend = String(backendName || "").trim().toLowerCase();
318
318
  if (normalizedBackend !== "codex") {
319
319
  return null;
320
320
  }
321
321
  const lockKey = `${normalizedBackend}:${resolveLockWorkingDirectory(workingDirectory)}`;
322
322
  const digest = createHash("sha1").update(lockKey).digest("hex");
323
- return path.join(os.homedir(), ".conductor", "locks", `session-bootstrap-${digest}.lock`);
323
+ return path.join(resolveConductorHome(env), "locks", `session-bootstrap-${digest}.lock`);
324
324
  }
325
325
 
326
326
  function acquireFileLock(lockPath) {
@@ -605,7 +605,7 @@ async function main() {
605
605
  if (discoveryError) {
606
606
  throw discoveryError;
607
607
  }
608
- process.stdout.write(`No supported backends configured.\n\nAdd allow_cli_list to your config file (~/.conductor/config.yaml):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n kimi: kimi\n opencode: opencode\n`);
608
+ process.stdout.write(`No supported backends configured.\n\nAdd allow_cli_list to your config file (${resolveConductorConfigPath(cliArgs.configFile)}):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n kimi: kimi\n opencode: opencode\n`);
609
609
  } else {
610
610
  if (supportedBackends.length > 0) {
611
611
  process.stdout.write(`Supported backends (from config):\n`);
@@ -1306,6 +1306,7 @@ export async function parseCliArgs(argvInput = process.argv) {
1306
1306
  // Handle help early
1307
1307
  if (helpWithoutSeparator) {
1308
1308
  const defaultBackend = supportedBackends[0] || externalBackends[0] || "none";
1309
+ const defaultConfigPath = resolveConductorConfigPath(configFileFromArgs);
1309
1310
  process.stdout.write(`${CLI_NAME} - Conductor-aware AI coding agent runner
1310
1311
 
1311
1312
  Usage: ${CLI_NAME} [options] -- [backend options and prompt]
@@ -1320,7 +1321,7 @@ Options:
1320
1321
  -v, --version Show Conductor CLI version and exit
1321
1322
  -h, --help Show this help message
1322
1323
 
1323
- Config file format (~/.conductor/config.yaml):
1324
+ Config file format (${defaultConfigPath}):
1324
1325
  allow_cli_list:
1325
1326
  codex: codex --dangerously-bypass-approvals-and-sandbox
1326
1327
  claude: claude --dangerously-skip-permissions
@@ -1335,9 +1336,11 @@ Examples:
1335
1336
  ${CLI_NAME} --backend codex --resume <id> # Resume Codex session
1336
1337
  ${CLI_NAME} --backend kimi --resume <id> # Resume Kimi session
1337
1338
  ${CLI_NAME} --list-backends # Show configured backends
1338
- ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
1339
+ ${CLI_NAME} --config-file ${defaultConfigPath} -- "fix the bug"
1339
1340
 
1340
1341
  Environment:
1342
+ CONDUCTOR_HOME User data directory [default: ~/.conductor]
1343
+ CONDUCTOR_CONFIG Config file override (takes precedence over CONDUCTOR_HOME)
1341
1344
  CONDUCTOR_BACKEND Default backend
1342
1345
  CONDUCTOR_PROJECT_ID Project ID to attach to
1343
1346
  CONDUCTOR_TASK_ID Attach to existing task instead of creating new one
@@ -1460,30 +1463,31 @@ function normalizeTaskId(value) {
1460
1463
  return value.trim();
1461
1464
  }
1462
1465
 
1463
- function resolveFireStateDir(workingDirectory) {
1466
+ export function resolveFireStateDir(workingDirectory, env = process.env) {
1464
1467
  // Priority:
1465
1468
  // 1. CONDUCTOR_FIRE_STATE_DIR env override (used by tests to isolate the
1466
1469
  // marker dir into a tmpdir; also handy for ops to relocate state).
1467
- // 2. Explicit `workingDirectory` argument (legacy callers).
1468
- // 3. ~/.conductor/state matches the conductor convention used by the
1469
- // session bootstrap locks (see line ~311).
1470
+ // 2. Explicit `workingDirectory` argument. Production marker writes use
1471
+ // this project-scoped path so commands such as send-file can discover
1472
+ // the active task while walking up from the current directory.
1473
+ // 3. $CONDUCTOR_HOME/state (defaults to ~/.conductor/state) only when no
1474
+ // project working directory is available.
1470
1475
  //
1471
1476
  // We deliberately do NOT default to process.cwd(), which would pollute
1472
1477
  // every project directory a user runs `conductor fire` from and cause tests
1473
1478
  // to leak marker files into the repo.
1474
1479
  const envOverride =
1475
- typeof process.env.CONDUCTOR_FIRE_STATE_DIR === "string" &&
1476
- process.env.CONDUCTOR_FIRE_STATE_DIR.trim()
1477
- ? process.env.CONDUCTOR_FIRE_STATE_DIR.trim()
1480
+ typeof env.CONDUCTOR_FIRE_STATE_DIR === "string" &&
1481
+ env.CONDUCTOR_FIRE_STATE_DIR.trim()
1482
+ ? env.CONDUCTOR_FIRE_STATE_DIR.trim()
1478
1483
  : "";
1479
1484
  if (envOverride) {
1480
1485
  return path.resolve(envOverride);
1481
1486
  }
1482
- const baseDir =
1483
- typeof workingDirectory === "string" && workingDirectory.trim()
1484
- ? path.resolve(workingDirectory.trim())
1485
- : os.homedir();
1486
- return path.join(baseDir, ".conductor", "state");
1487
+ if (typeof workingDirectory === "string" && workingDirectory.trim()) {
1488
+ return path.join(path.resolve(workingDirectory.trim()), ".conductor", "state");
1489
+ }
1490
+ return path.join(resolveConductorHome(env), "state");
1487
1491
  }
1488
1492
 
1489
1493
  /**
@@ -2,7 +2,6 @@
2
2
 
3
3
  import fs from "node:fs";
4
4
  import fsp from "node:fs/promises";
5
- import os from "node:os";
6
5
  import path from "node:path";
7
6
  import process from "node:process";
8
7
  import { fileURLToPath } from "node:url";
@@ -11,9 +10,9 @@ import yargs from "yargs/yargs";
11
10
  import { hideBin } from "yargs/helpers";
12
11
  import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
13
12
  import { envForExplicitConfigFile } from "../src/config-env.js";
13
+ import { resolveConductorConfigPath } from "../src/conductor-paths.js";
14
14
 
15
15
  const DEFAULT_MIME_TYPE = "application/octet-stream";
16
- const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".conductor", "config.yaml");
17
16
  const FIRE_TASK_MARKER_PREFIX = "active-fire";
18
17
 
19
18
  const EXTENSION_TO_MIME = {
@@ -110,7 +109,7 @@ export function detectTaskId(options = {}) {
110
109
  }
111
110
 
112
111
  function loadCliConfig(configFile, env = process.env) {
113
- const configPath = configFile ? path.resolve(configFile) : DEFAULT_CONFIG_PATH;
112
+ const configPath = resolveConductorConfigPath(configFile, env);
114
113
  const configEnv = envForExplicitConfigFile(configFile, env);
115
114
  if (fs.existsSync(configPath)) {
116
115
  return loadConfig(configPath, { env: configEnv });
@@ -117,7 +117,7 @@ async function main() {
117
117
  default: false,
118
118
  describe: "Overwrite an existing config-ai-serve.yaml",
119
119
  })
120
- .example("$0 init", "Create ~/.conductor/config-ai-serve.yaml")
120
+ .example("$0 init", "Create config-ai-serve.yaml under CONDUCTOR_HOME")
121
121
  .example("$0 init --config-file /tmp/custom/config.yaml", "Create /tmp/custom/config-ai-serve.yaml"),
122
122
  async (args) => {
123
123
  const { conductorConfigPath, serveAiConfigPath } = resolveServeAiConfigPaths(args.configFile);
package/bin/conductor.js CHANGED
@@ -140,6 +140,10 @@ Options:
140
140
  -h, --help Show this help message
141
141
  -v, --version Show version information
142
142
 
143
+ Environment:
144
+ CONDUCTOR_HOME User data directory (default: ~/.conductor)
145
+ CONDUCTOR_CONFIG Config file override (takes precedence over CONDUCTOR_HOME)
146
+
143
147
  Examples:
144
148
  conductor fire -- "fix the bug"
145
149
  conductor fire --backend claude -- "add feature"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.7.5",
4
- "gitCommitId": "702c7c4",
3
+ "version": "0.7.6",
4
+ "gitCommitId": "c939df2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.7.5",
28
- "@love-moon/conductor-sdk": "0.7.5",
27
+ "@love-moon/ai-sdk": "0.7.6",
28
+ "@love-moon/conductor-sdk": "0.7.6",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.7.5"
41
+ "@love-moon/chat-web": "0.7.6"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
@@ -9,7 +9,7 @@ const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot"];
9
9
 
10
10
  /**
11
11
  * @param {object} opts
12
- * @param {string} [opts.configPath] Path to ~/.conductor/config.yaml. Defaults to the manager facade default.
12
+ * @param {string} [opts.configPath] Path to config.yaml. Defaults to $CONDUCTOR_HOME/config.yaml.
13
13
  */
14
14
  export function createAiManagerHandlers(opts = {}) {
15
15
  const manager = new AiManager(opts.configPath ? { configPath: opts.configPath } : undefined);
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
 
5
4
  import { buildUpgradeCommand, fetchLatestVersion, isNewerVersion } from "./version-check.js";
5
+ import { resolveConductorHome } from "./conductor-paths.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;
@@ -44,8 +44,13 @@ function isTimestampOlderThan(value, ageMs, nowMs) {
44
44
  }
45
45
 
46
46
  export function resolveVersionCheckCachePath(options = {}) {
47
- const homeDir = options.homeDir || process.env.HOME || os.homedir() || "/tmp";
48
- return path.join(homeDir, ".conductor", DEFAULT_CACHE_FILE);
47
+ const env = options.env || process.env;
48
+ const conductorHome = options.conductorHome
49
+ ? path.resolve(options.conductorHome)
50
+ : options.homeDir
51
+ ? resolveConductorHome({}, { userHome: options.homeDir })
52
+ : resolveConductorHome(env);
53
+ return path.join(conductorHome, DEFAULT_CACHE_FILE);
49
54
  }
50
55
 
51
56
  export function normalizeVersionCheckCache(value) {
@@ -171,7 +176,9 @@ export async function maybeCheckForUpdates(options = {}) {
171
176
  const fetchLatestVersionFn = options.fetchLatestVersion || fetchLatestVersion;
172
177
  const cacheOptions = {
173
178
  cachePath: options.cachePath,
174
- homeDir: options.homeDir || env.HOME,
179
+ conductorHome: options.conductorHome,
180
+ homeDir: options.homeDir,
181
+ env,
175
182
  readFile: options.readFile,
176
183
  writeFile: options.writeFile,
177
184
  mkdir: options.mkdir,
@@ -0,0 +1,61 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const CONDUCTOR_HOME_ENV_VAR = "CONDUCTOR_HOME";
5
+ export const CONDUCTOR_CONFIG_ENV_VAR = "CONDUCTOR_CONFIG";
6
+
7
+ function normalizeOptionalString(value) {
8
+ return typeof value === "string" && value.trim() ? value.trim() : "";
9
+ }
10
+
11
+ export function resolveUserHome(env = process.env, fallbackHome = os.homedir()) {
12
+ return (
13
+ normalizeOptionalString(env?.HOME) ||
14
+ normalizeOptionalString(env?.USERPROFILE) ||
15
+ normalizeOptionalString(fallbackHome) ||
16
+ "/tmp"
17
+ );
18
+ }
19
+
20
+ export function resolveHomeRelativePath(value, env = process.env, fallbackHome = os.homedir()) {
21
+ const normalized = normalizeOptionalString(value);
22
+ if (!normalized) {
23
+ return "";
24
+ }
25
+ const userHome = resolveUserHome(env, fallbackHome);
26
+ if (normalized === "~") {
27
+ return path.resolve(userHome);
28
+ }
29
+ if (normalized.startsWith("~/") || normalized.startsWith("~\\")) {
30
+ return path.resolve(userHome, normalized.slice(2));
31
+ }
32
+ return path.resolve(normalized);
33
+ }
34
+
35
+ export function resolveConductorHome(env = process.env, options = {}) {
36
+ const configuredHome = normalizeOptionalString(env?.[CONDUCTOR_HOME_ENV_VAR]);
37
+ if (configuredHome) {
38
+ return resolveHomeRelativePath(configuredHome, env, options.fallbackHome);
39
+ }
40
+ const userHome = normalizeOptionalString(options.userHome) || resolveUserHome(env, options.fallbackHome);
41
+ return path.join(path.resolve(userHome), ".conductor");
42
+ }
43
+
44
+ export function resolveConductorConfigPath(configFile, env = process.env, options = {}) {
45
+ const explicitPath = normalizeOptionalString(configFile);
46
+ if (explicitPath) {
47
+ return resolveHomeRelativePath(explicitPath, env, options.fallbackHome);
48
+ }
49
+ const configuredPath = normalizeOptionalString(env?.[CONDUCTOR_CONFIG_ENV_VAR]);
50
+ if (configuredPath) {
51
+ return resolveHomeRelativePath(configuredPath, env, options.fallbackHome);
52
+ }
53
+ return path.join(resolveConductorHome(env, options), "config.yaml");
54
+ }
55
+
56
+ export function materializeConductorPathEnv(configFile, env = process.env, options = {}) {
57
+ return {
58
+ CONDUCTOR_HOME: resolveConductorHome(env, options),
59
+ CONDUCTOR_CONFIG: resolveConductorConfigPath(configFile, env, options),
60
+ };
61
+ }
@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
7
7
 
8
8
  import yaml from "js-yaml";
9
9
 
10
+ import { resolveConductorConfigPath } from "./conductor-paths.js";
11
+
10
12
  const VALID_ACTIONS = new Set(["list", "run", "status"]);
11
13
  const MAX_TAIL_CHARS = 12_000;
12
14
  const MAX_RUNS = 200;
@@ -25,7 +27,7 @@ export const CUSTOM_COMMANDS_CAPABILITY = "custom_commands";
25
27
  * @param {typeof spawn} [opts.spawnFn]
26
28
  */
27
29
  export function createCustomCommandHandlers(opts = {}) {
28
- const configPath = opts.configPath || path.join(os.homedir(), ".conductor", "config.yaml");
30
+ const configPath = resolveConductorConfigPath(opts.configPath);
29
31
  const spawnFn = opts.spawnFn || spawn;
30
32
  const runs = new Map();
31
33
  const runningByKey = new Map();
@@ -202,7 +204,7 @@ export async function loadCustomCommands(configPath) {
202
204
  * @param {string} configPath
203
205
  * @returns {CustomCommand[]}
204
206
  */
205
- export function parseCustomCommandsConfig(config, configPath = path.join(os.homedir(), ".conductor", "config.yaml")) {
207
+ export function parseCustomCommandsConfig(config, configPath = resolveConductorConfigPath()) {
206
208
  const root = config && typeof config === "object" ? config : {};
207
209
  const rawCommands = root.custom_commands;
208
210
  if (rawCommands === undefined || rawCommands === null) {
package/src/daemon.js CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  } from "@love-moon/conductor-sdk";
18
18
  import { DaemonLogCollector } from "./log-collector.js";
19
19
  import { envForExplicitConfigFile } from "./config-env.js";
20
+ import {
21
+ materializeConductorPathEnv,
22
+ resolveConductorConfigPath,
23
+ resolveConductorHome,
24
+ } from "./conductor-paths.js";
20
25
  import { createAiManagerHandlers, handleAiManagerRequest } from "./ai-manager-handlers.js";
21
26
  import {
22
27
  CUSTOM_COMMANDS_CAPABILITY,
@@ -62,8 +67,6 @@ const __dirname = path.dirname(__filename);
62
67
  const PACKAGE_ROOT = path.join(__dirname, "..");
63
68
  const moduleRequire = createRequire(import.meta.url);
64
69
  const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
65
- const DAEMON_LOG_DIR = path.join(os.homedir(), ".conductor", "logs");
66
- const DAEMON_LOG_PATH = path.join(DAEMON_LOG_DIR, "conductor-daemon.log");
67
70
  const CLI_VERSION = (() => {
68
71
  try {
69
72
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
@@ -71,6 +74,14 @@ const CLI_VERSION = (() => {
71
74
  return "unknown";
72
75
  }
73
76
  })();
77
+
78
+ export function resolveDaemonLogPaths(env = process.env) {
79
+ const logDir = path.join(resolveConductorHome(env), "logs");
80
+ return {
81
+ logDir,
82
+ logPath: path.join(logDir, "conductor-daemon.log"),
83
+ };
84
+ }
74
85
  const PLAN_LIMIT_MESSAGES = {
75
86
  manual_fire_active_task: "Free plan limit reached: only 1 active fire task is allowed.",
76
87
  app_active_task: "Free plan limit reached: only 1 active app task is allowed.",
@@ -119,8 +130,9 @@ export function probePtyTaskCapability({
119
130
 
120
131
  function appendDaemonLog(line) {
121
132
  try {
122
- fs.mkdirSync(DAEMON_LOG_DIR, { recursive: true });
123
- fs.appendFileSync(DAEMON_LOG_PATH, line);
133
+ const { logDir, logPath } = resolveDaemonLogPaths();
134
+ fs.mkdirSync(logDir, { recursive: true });
135
+ fs.appendFileSync(logPath, line);
124
136
  } catch {
125
137
  // ignore file log errors
126
138
  }
@@ -156,8 +168,7 @@ function sleepSync(ms) {
156
168
 
157
169
  function getUserConfig(configFilePath) {
158
170
  try {
159
- const home = os.homedir();
160
- const configPath = configFilePath || path.join(home, ".conductor", "config.yaml");
171
+ const configPath = resolveConductorConfigPath(configFilePath);
161
172
  if (fs.existsSync(configPath)) {
162
173
  const content = fs.readFileSync(configPath, "utf8");
163
174
  const parsed = yaml.load(content);
@@ -179,7 +190,7 @@ function getUserConfig(configFilePath) {
179
190
  //
180
191
  // Resolution order:
181
192
  // 1. CONDUCTOR_FIRE_TMUX_MODE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
182
- // 2. fire_tmux_mode boolean in ~/.conductor/config.yaml
193
+ // 2. fire_tmux_mode boolean in the resolved Conductor config.yaml
183
194
  // 3. Default: false
184
195
  function getFireTmuxModeEnabled(userConfig) {
185
196
  const rawEnv = process.env.CONDUCTOR_FIRE_TMUX_MODE;
@@ -669,10 +680,15 @@ export function startDaemon(config = {}, deps = {}) {
669
680
  };
670
681
 
671
682
  let fileConfig;
683
+ const materializedConductorPathEnv = materializeConductorPathEnv(
684
+ config.CONFIG_FILE,
685
+ process.env,
686
+ );
687
+ const effectiveConfigPath = materializedConductorPathEnv.CONDUCTOR_CONFIG;
672
688
  const configFileEnv = envForExplicitConfigFile(config.CONFIG_FILE, process.env);
673
689
  try {
674
690
  fileConfig = loadConfig(config.CONFIG_FILE, { env: configFileEnv });
675
- log(`Loaded config from ${config.CONFIG_FILE || "~/.conductor/config.yaml"}`);
691
+ log(`Loaded config from ${effectiveConfigPath}`);
676
692
  } catch (err) {
677
693
  if (!(err instanceof ConfigFileNotFound)) {
678
694
  log(`Failed to load config: ${err.message}`);
@@ -712,7 +728,7 @@ export function startDaemon(config = {}, deps = {}) {
712
728
  os.hostname()
713
729
  ).trim();
714
730
  if (!AGENT_NAME) {
715
- logError("Daemon name is required. Set daemon_name in ~/.conductor/config.yaml or CONDUCTOR_DAEMON_NAME.");
731
+ logError(`Daemon name is required. Set daemon_name in ${effectiveConfigPath} or CONDUCTOR_DAEMON_NAME.`);
716
732
  return exitAndReturn(1);
717
733
  }
718
734
  const homeDir = process.env.HOME || os.homedir() || "/tmp";
@@ -2144,8 +2160,8 @@ export function startDaemon(config = {}, deps = {}) {
2144
2160
  if (advertisedCapabilities.length > 0) {
2145
2161
  extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
2146
2162
  }
2147
- const aiManagerHandlers = createAiManagerHandlers({ configPath: config.CONFIG_FILE });
2148
- const customCommandHandlers = createCustomCommandHandlers({ configPath: config.CONFIG_FILE });
2163
+ const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
2164
+ const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
2149
2165
 
2150
2166
  const client = createWebSocketClient(sdkConfig, {
2151
2167
  extraHeaders,
@@ -2690,15 +2706,16 @@ export function startDaemon(config = {}, deps = {}) {
2690
2706
 
2691
2707
  let logFd = null;
2692
2708
  if (shouldRespawn) {
2709
+ const { logDir: daemonLogDir, logPath: daemonLogPath } = resolveDaemonLogPaths();
2693
2710
  try {
2694
- mkdirSyncFn(DAEMON_LOG_DIR, { recursive: true });
2711
+ mkdirSyncFn(daemonLogDir, { recursive: true });
2695
2712
  } catch {
2696
2713
  /* ignore */
2697
2714
  }
2698
- logFd = fs.openSync(DAEMON_LOG_PATH, "a");
2715
+ logFd = fs.openSync(daemonLogPath, "a");
2699
2716
  if (!isBackgroundProcess) {
2700
2717
  log(
2701
- `[${reason}] Foreground daemon will be respawned in background. Logs: ${DAEMON_LOG_PATH}`
2718
+ `[${reason}] Foreground daemon will be respawned in background. Logs: ${daemonLogPath}`
2702
2719
  );
2703
2720
  }
2704
2721
  }
@@ -2719,6 +2736,7 @@ export function startDaemon(config = {}, deps = {}) {
2719
2736
  stdio: ["ignore", logFd, logFd],
2720
2737
  env: {
2721
2738
  ...process.env,
2739
+ ...materializedConductorPathEnv,
2722
2740
  CONDUCTOR_LOCK_HANDOFF_TOKEN: handoffToken,
2723
2741
  CONDUCTOR_LOCK_HANDOFF_FROM_PID: String(process.pid),
2724
2742
  CONDUCTOR_LOCK_HANDOFF_EXPIRES_AT: String(handoffExpiresAt),
@@ -5272,15 +5290,13 @@ export function startDaemon(config = {}, deps = {}) {
5272
5290
 
5273
5291
  const env = {
5274
5292
  ...stripPtyTaskScopedEnv(process.env),
5293
+ ...materializedConductorPathEnv,
5275
5294
  PWD: taskDir,
5276
5295
  CONDUCTOR_PROJECT_ID: projectId,
5277
5296
  CONDUCTOR_TASK_ID: taskId,
5278
5297
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
5279
5298
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
5280
5299
  };
5281
- if (config.CONFIG_FILE) {
5282
- env.CONDUCTOR_CONFIG = config.CONFIG_FILE;
5283
- }
5284
5300
  if (AGENT_TOKEN) {
5285
5301
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
5286
5302
  }
@@ -5915,6 +5931,7 @@ export function startDaemon(config = {}, deps = {}) {
5915
5931
 
5916
5932
  const env = {
5917
5933
  ...stripPtyTaskScopedEnv(process.env),
5934
+ ...materializedConductorPathEnv,
5918
5935
  PWD: taskDir,
5919
5936
  CONDUCTOR_PROJECT_ID: normalizedProjectId,
5920
5937
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
@@ -5922,9 +5939,6 @@ export function startDaemon(config = {}, deps = {}) {
5922
5939
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
5923
5940
  };
5924
5941
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
5925
- if (config.CONFIG_FILE) {
5926
- env.CONDUCTOR_CONFIG = config.CONFIG_FILE;
5927
- }
5928
5942
  if (AGENT_TOKEN) {
5929
5943
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
5930
5944
  }
@@ -15,12 +15,12 @@
15
15
  */
16
16
 
17
17
  import fs from "node:fs";
18
- import os from "node:os";
19
18
  import path from "node:path";
20
19
  import process from "node:process";
21
20
  import { fileURLToPath } from "node:url";
22
21
 
23
22
  import { envForExplicitConfigFile } from "./config-env.js";
23
+ import { resolveConductorConfigPath } from "./conductor-paths.js";
24
24
 
25
25
  // RFC §4 exit codes
26
26
  export const EXIT = {
@@ -50,14 +50,8 @@ export function getCliVersion() {
50
50
  return cachedPkgVersion;
51
51
  }
52
52
 
53
- const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".conductor", "config.yaml");
54
-
55
53
  export function resolveConfigPath(configFile, env = process.env) {
56
- if (configFile) {
57
- return path.resolve(configFile);
58
- }
59
- const home = env.HOME || env.USERPROFILE || os.homedir();
60
- return path.join(home, ".conductor", "config.yaml");
54
+ return resolveConductorConfigPath(configFile, env);
61
55
  }
62
56
 
63
57
  /**
@@ -1,6 +1,5 @@
1
1
  import crypto from "node:crypto";
2
2
  import { promises as fsp } from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
5
4
 
6
5
  import yaml from "js-yaml";
@@ -19,6 +18,7 @@ import {
19
18
  normalizeRuntimeBackendAlias,
20
19
  resolveConfiguredRuntimeBackend,
21
20
  } from "../runtime-backends.js";
21
+ import { resolveConductorConfigPath, resolveConductorHome } from "../conductor-paths.js";
22
22
 
23
23
  function normalizeBackend(backend) {
24
24
  return String(backend || "").trim().toLowerCase();
@@ -28,23 +28,23 @@ function normalizeSessionId(sessionId) {
28
28
  return typeof sessionId === "string" ? sessionId.trim() : "";
29
29
  }
30
30
 
31
- function resolveHomeDir(options) {
32
- if (options?.homeDir) {
33
- return options.homeDir;
31
+ function resolveConductorStorageDir(options = {}) {
32
+ if (typeof options.conductorHome === "string" && options.conductorHome.trim()) {
33
+ return path.resolve(options.conductorHome.trim());
34
34
  }
35
- return os.homedir();
35
+ const env = options.env || process.env;
36
+ if (typeof options.homeDir === "string" && options.homeDir.trim()) {
37
+ return resolveConductorHome(env, { userHome: options.homeDir.trim() });
38
+ }
39
+ return resolveConductorHome(env);
36
40
  }
37
41
 
38
42
  function resolveConfigFilePath(options = {}) {
39
- const configuredPath =
40
- typeof options?.configFilePath === "string" && options.configFilePath.trim()
41
- ? options.configFilePath.trim()
42
- : typeof process.env.CONDUCTOR_CONFIG === "string" && process.env.CONDUCTOR_CONFIG.trim()
43
- ? process.env.CONDUCTOR_CONFIG.trim()
44
- : "";
45
- return configuredPath
46
- ? path.resolve(configuredPath)
47
- : path.join(resolveHomeDir(options), ".conductor", "config.yaml");
43
+ const env = options.env || process.env;
44
+ if (typeof options.homeDir === "string" && options.homeDir.trim() && !env.CONDUCTOR_HOME) {
45
+ return resolveConductorConfigPath(options.configFilePath, env, { userHome: options.homeDir.trim() });
46
+ }
47
+ return resolveConductorConfigPath(options.configFilePath, env);
48
48
  }
49
49
 
50
50
  function normalizeProjectPathCandidate(value) {
@@ -93,10 +93,10 @@ function md5Hex(value) {
93
93
  }
94
94
 
95
95
  async function loadConductorSessionRecords(options = {}) {
96
- const homeDir = resolveHomeDir(options);
96
+ const conductorHome = resolveConductorStorageDir(options);
97
97
  const defaultPaths = [
98
- path.join(homeDir, ".conductor", "session.yaml"),
99
- path.join(homeDir, ".conductor", "sessions"),
98
+ path.join(conductorHome, "session.yaml"),
99
+ path.join(conductorHome, "sessions"),
100
100
  ];
101
101
  const recordFiles = [];
102
102
  const pushFile = (filePath) => {
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
4
4
 
5
5
  import yaml from "js-yaml";
6
6
  import { BUILT_IN_BACKENDS as AI_SDK_BUILT_IN_BACKENDS } from "@love-moon/ai-sdk";
7
+ import { resolveConductorConfigPath } from "./conductor-paths.js";
7
8
 
8
9
  // CLI display order for built-in backends. ai-sdk owns the canonical set of
9
10
  // built-in backends; CLI just picks an ordering for "Supported Backends:" log
@@ -349,10 +350,7 @@ export function isCommandOptionalBuiltInRuntimeBackend(backend) {
349
350
  }
350
351
 
351
352
  function readConfigEnvValue(configFilePath, key) {
352
- const targetPath =
353
- typeof configFilePath === "string" && configFilePath.trim()
354
- ? path.resolve(configFilePath.trim())
355
- : path.join(process.env.HOME || "", ".conductor", "config.yaml");
353
+ const targetPath = resolveConductorConfigPath(configFilePath);
356
354
  try {
357
355
  if (!targetPath || !fs.existsSync(targetPath)) {
358
356
  return "";
@@ -1,24 +1,18 @@
1
1
  import fs from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
 
5
4
  import yaml from "js-yaml";
5
+ import { resolveConductorConfigPath } from "../conductor-paths.js";
6
6
 
7
7
  export const DEFAULT_CONDUCTOR_CONFIG_BASENAME = "config.yaml";
8
8
  export const DEFAULT_SERVE_AI_CONFIG_BASENAME = "config-ai-serve.yaml";
9
9
 
10
- function resolvePrimaryConfigPath(configFilePath) {
11
- if (typeof configFilePath === "string" && configFilePath.trim()) {
12
- return path.resolve(configFilePath.trim());
13
- }
14
- if (typeof process.env.CONDUCTOR_CONFIG === "string" && process.env.CONDUCTOR_CONFIG.trim()) {
15
- return path.resolve(process.env.CONDUCTOR_CONFIG.trim());
16
- }
17
- return path.join(os.homedir(), ".conductor", DEFAULT_CONDUCTOR_CONFIG_BASENAME);
10
+ function resolvePrimaryConfigPath(configFilePath, env = process.env) {
11
+ return resolveConductorConfigPath(configFilePath, env);
18
12
  }
19
13
 
20
- export function resolveServeAiConfigPaths(configFilePath) {
21
- const conductorConfigPath = resolvePrimaryConfigPath(configFilePath);
14
+ export function resolveServeAiConfigPaths(configFilePath, env = process.env) {
15
+ const conductorConfigPath = resolvePrimaryConfigPath(configFilePath, env);
22
16
  return {
23
17
  conductorConfigPath,
24
18
  serveAiConfigPath: path.join(path.dirname(conductorConfigPath), DEFAULT_SERVE_AI_CONFIG_BASENAME),
@@ -37,8 +31,8 @@ function parseYamlFile(filePath) {
37
31
  return parsed;
38
32
  }
39
33
 
40
- export function loadServeAiRuntimeConfig(configFilePath) {
41
- const { conductorConfigPath, serveAiConfigPath } = resolveServeAiConfigPaths(configFilePath);
34
+ export function loadServeAiRuntimeConfig(configFilePath, env = process.env) {
35
+ const { conductorConfigPath, serveAiConfigPath } = resolveServeAiConfigPaths(configFilePath, env);
42
36
  const conductorExists = fs.existsSync(conductorConfigPath);
43
37
  const serveAiExists = fs.existsSync(serveAiConfigPath);
44
38