@love-moon/conductor-cli 0.7.5 → 0.7.7

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,43 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.7.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 400f3a7: Report a terminal task status when a stop request finds no active process, so a
8
+ task whose Fire already died converges instead of sitting in `killing` forever.
9
+
10
+ Drop queued terminal status events before an in-place restart reuses a working
11
+ directory. The durable upstream outbox lives inside that directory, so an
12
+ undelivered `KILLED` from the previous run was flushed on startup and killed the
13
+ task that had just finished resuming.
14
+
15
+ - 67498dc: Report a Fire that dies inside its tmux session instead of leaving the task
16
+ hanging. In tmux mode the daemon's child is the short-lived `tmux new-session`
17
+ client, not the Fire, so an abnormal death (crash, OOM, SIGKILL) went unreported
18
+ and the task sat at `running` until reconcile relabelled it as a user stop. The
19
+ Fire now records its own exit code into its log under a per-launch nonce, and the
20
+ liveness reaper classifies the death from that marker and publishes a terminal
21
+ status with the real cause.
22
+ - Updated dependencies [400f3a7]
23
+ - @love-moon/conductor-sdk@0.7.7
24
+ - @love-moon/ai-sdk@0.7.7
25
+
26
+ ## 0.7.6
27
+
28
+ ### Patch Changes
29
+
30
+ - 7bbb412: Add `CONDUCTOR_HOME` support for relocating user-level configuration, logs,
31
+ Fire locks, sessions, update metadata, and AI manager caches while leaving
32
+ project-scoped `.conductor` directories and Fire task markers in place.
33
+
34
+ Migrate device authorization to `conductor.conductor-ai.top` while preserving
35
+ compatibility with the legacy official endpoint and self-hosted backends.
36
+
37
+ - Updated dependencies [7bbb412]
38
+ - @love-moon/conductor-sdk@0.7.6
39
+ - @love-moon/ai-sdk@0.7.6
40
+
3
41
  ## 0.7.5
4
42
 
5
43
  ### 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.7",
4
+ "gitCommitId": "26b2dbb",
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.7",
28
+ "@love-moon/conductor-sdk": "0.7.7",
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.7"
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) {