@love-moon/conductor-cli 0.2.34 → 0.2.36

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.
@@ -82,24 +82,149 @@ export function shouldRunReconnectRecovery({
82
82
  }
83
83
 
84
84
  // Load allow_cli_list from config file (no defaults - must be configured)
85
- async function loadAllowCliList(configFilePath) {
85
+ function loadFireConfigYaml(configFilePath) {
86
86
  const home = os.homedir();
87
87
  const configPath = configFilePath || process.env.CONDUCTOR_CONFIG || path.join(home, ".conductor", "config.yaml");
88
- let parsed = null;
89
88
  try {
90
89
  if (fs.existsSync(configPath)) {
91
90
  const content = fs.readFileSync(configPath, "utf8");
92
- parsed = yaml.load(content);
91
+ const parsed = yaml.load(content);
92
+ if (parsed && typeof parsed === "object") {
93
+ return { configPath, parsed };
94
+ }
93
95
  }
94
96
  } catch (error) {
95
97
  // ignore error
96
98
  }
97
- if (parsed && typeof parsed === "object" && parsed.allow_cli_list) {
99
+ return { configPath, parsed: null };
100
+ }
101
+
102
+ // Load allow_cli_list from config file (no defaults - must be configured)
103
+ async function loadAllowCliList(configFilePath) {
104
+ const { configPath, parsed } = loadFireConfigYaml(configFilePath);
105
+ if (parsed && parsed.allow_cli_list) {
98
106
  return await filterRuntimeSupportedAllowCliList(parsed.allow_cli_list, { configFilePath: configPath });
99
107
  }
100
108
  return {};
101
109
  }
102
110
 
111
+ function loadPrePromptMap(configFilePath) {
112
+ const { parsed } = loadFireConfigYaml(configFilePath);
113
+ if (parsed && parsed.pre_prompt && typeof parsed.pre_prompt === "object") {
114
+ return parsed.pre_prompt;
115
+ }
116
+ return {};
117
+ }
118
+
119
+ export function expandEnvVars(text, env = process.env) {
120
+ if (typeof text !== "string" || !text) {
121
+ return "";
122
+ }
123
+ return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, bare) => {
124
+ const key = braced || bare;
125
+ return Object.prototype.hasOwnProperty.call(env, key) && env[key] != null ? String(env[key]) : match;
126
+ });
127
+ }
128
+
129
+ export function resolveConfiguredPrePrompt({ configFilePath, backend, sessionBackend, env = process.env } = {}) {
130
+ const prePromptMap = loadPrePromptMap(configFilePath);
131
+ const candidates = [backend, sessionBackend]
132
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
133
+ .filter(Boolean);
134
+ for (const candidate of candidates) {
135
+ if (typeof prePromptMap[candidate] === "string") {
136
+ const resolved = expandEnvVars(prePromptMap[candidate], env);
137
+ return resolved || undefined;
138
+ }
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ function parseCommandParts(commandLine) {
144
+ const input = String(commandLine || "").trim();
145
+ if (!input) {
146
+ return [];
147
+ }
148
+
149
+ const parts = [];
150
+ let current = "";
151
+ let quote = "";
152
+ let escaping = false;
153
+ let tokenStarted = false;
154
+
155
+ for (const char of input) {
156
+ if (escaping) {
157
+ current += char;
158
+ tokenStarted = true;
159
+ escaping = false;
160
+ continue;
161
+ }
162
+
163
+ if (char === "\\") {
164
+ escaping = true;
165
+ tokenStarted = true;
166
+ continue;
167
+ }
168
+
169
+ if (quote) {
170
+ if (char === quote) {
171
+ quote = "";
172
+ } else {
173
+ current += char;
174
+ }
175
+ tokenStarted = true;
176
+ continue;
177
+ }
178
+
179
+ if (char === "'" || char === "\"") {
180
+ quote = char;
181
+ tokenStarted = true;
182
+ continue;
183
+ }
184
+
185
+ if (/\s/.test(char)) {
186
+ if (tokenStarted) {
187
+ parts.push(current);
188
+ current = "";
189
+ tokenStarted = false;
190
+ }
191
+ continue;
192
+ }
193
+
194
+ current += char;
195
+ tokenStarted = true;
196
+ }
197
+
198
+ if (tokenStarted) {
199
+ parts.push(current);
200
+ }
201
+
202
+ return parts;
203
+ }
204
+
205
+ function extractModelOptionFromCommandLine(commandLine) {
206
+ const parts = parseCommandParts(commandLine);
207
+ for (let index = 0; index < parts.length; index += 1) {
208
+ const token = String(parts[index] || "").trim();
209
+ if (!token) {
210
+ continue;
211
+ }
212
+ if (token === "--model") {
213
+ const next = String(parts[index + 1] || "").trim();
214
+ return next || "";
215
+ }
216
+ if (token.startsWith("--model=")) {
217
+ return token.slice("--model=".length).trim();
218
+ }
219
+ }
220
+ return "";
221
+ }
222
+
223
+ function extractAiSessionOptionsFromCommandLine(commandLine) {
224
+ const model = extractModelOptionFromCommandLine(commandLine);
225
+ return model ? { model } : {};
226
+ }
227
+
103
228
  export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
104
229
  const normalizedBackend = normalizeRuntimeBackendName(backend);
105
230
  const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
@@ -142,6 +267,12 @@ export function resolveAiSessionCommandLine(backend, allowCliList, env = process
142
267
  return resolvedCommand;
143
268
  }
144
269
 
270
+ export function resolveAiSessionOptions(backend, allowCliList, env = process.env, sessionBackend = backend) {
271
+ return extractAiSessionOptionsFromCommandLine(
272
+ resolveAiSessionCommandLine(backend, allowCliList, env, sessionBackend),
273
+ );
274
+ }
275
+
145
276
  const DEFAULT_POLL_INTERVAL_MS = parseInt(
146
277
  process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
147
278
  10,
@@ -595,6 +726,7 @@ async function main() {
595
726
  envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
596
727
  runtimeProjectPath,
597
728
  });
729
+ const resolvedDaemonName = resolveDaemonHost(configuredDaemonName);
598
730
 
599
731
  conductor = await ConductorClient.connect({
600
732
  projectPath: runtimeProjectPath,
@@ -623,7 +755,7 @@ async function main() {
623
755
  providedTaskId: process.env.CONDUCTOR_TASK_ID,
624
756
  requestedTitle: requestedTaskTitle,
625
757
  backend: cliArgs.backend,
626
- daemonName: configuredDaemonName,
758
+ daemonName: resolvedDaemonName,
627
759
  projectPath: runtimeProjectPath,
628
760
  });
629
761
  injectResolvedTaskId(taskContext.taskId);
@@ -647,6 +779,7 @@ async function main() {
647
779
  project_id: process.env.CONDUCTOR_PROJECT_ID,
648
780
  project_path: runtimeProjectPath,
649
781
  backend_type: cliArgs.backend,
782
+ daemon_name: resolvedDaemonName,
650
783
  });
651
784
  } catch {
652
785
  // best effort only
@@ -661,14 +794,24 @@ async function main() {
661
794
  process.env,
662
795
  cliArgs.sessionBackend,
663
796
  );
797
+ const resolvedPrePrompt = resolveConfiguredPrePrompt({
798
+ configFilePath: cliArgs.configFile,
799
+ backend: cliArgs.backend,
800
+ sessionBackend: cliArgs.sessionBackend,
801
+ env: process.env,
802
+ });
664
803
 
665
804
  backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
666
805
  initialImages: cliArgs.initialImages,
667
806
  cwd: runtimeProjectPath,
668
807
  resumeSessionId: resolvedResumeSessionId,
669
808
  configFile: cliArgs.configFile,
809
+ ...(cliArgs.sessionOptions || {}),
670
810
  ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
671
811
  logger: { log },
812
+ ...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
813
+ sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
814
+ resumePersistedSession: Boolean(!resolvedResumeSessionId && taskContext.taskId),
672
815
  });
673
816
 
674
817
  log(`Using backend: ${cliArgs.backend}`);
@@ -694,7 +837,7 @@ async function main() {
694
837
  cliArgs: cliArgs.rawBackendArgs,
695
838
  backendName: cliArgs.backend,
696
839
  resumeSessionId: resolvedResumeSessionId,
697
- daemonName: configuredDaemonName,
840
+ daemonName: resolvedDaemonName,
698
841
  });
699
842
  reconnectRunner = runner;
700
843
  if (pendingRemoteStopEvent) {
@@ -1038,6 +1181,12 @@ Environment:
1038
1181
  const sessionBackend =
1039
1182
  configuredBackend?.runtimeBackend ||
1040
1183
  (backend ? await normalizeRuntimeBackendAlias(backend, { configFilePath: configFileFromArgs }) : "");
1184
+ const sessionOptions = resolveAiSessionOptions(
1185
+ backend,
1186
+ allowCliList,
1187
+ process.env,
1188
+ sessionBackend || backend,
1189
+ );
1041
1190
  const shouldRequireBackend =
1042
1191
  !Boolean(conductorArgs.listBackends) &&
1043
1192
  !listBackendsWithoutSeparator &&
@@ -1086,6 +1235,7 @@ Environment:
1086
1235
  hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
1087
1236
  configFile: conductorArgs.configFile,
1088
1237
  sessionBackend,
1238
+ sessionOptions,
1089
1239
  resumeSessionId,
1090
1240
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
1091
1241
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
@@ -1288,7 +1438,7 @@ export async function resolveProjectId(conductor, explicit, opts = {}) {
1288
1438
  return resolveDefaultProjectId(conductor);
1289
1439
  }
1290
1440
 
1291
- function resolveDaemonHost(daemonName) {
1441
+ export function resolveDaemonHost(daemonName) {
1292
1442
  if (typeof daemonName === "string" && daemonName.trim()) {
1293
1443
  return daemonName.trim();
1294
1444
  }
@@ -1708,6 +1858,7 @@ export class BridgeRunner {
1708
1858
  session_file_path:
1709
1859
  typeof sessionFilePath === "string" && sessionFilePath.trim() ? sessionFilePath.trim() : undefined,
1710
1860
  backend_type: this.backendName,
1861
+ daemon_name: this.daemonName,
1711
1862
  });
1712
1863
  this.boundSessionId = normalizedSessionId;
1713
1864
  return true;
@@ -2077,6 +2228,11 @@ export class BridgeRunner {
2077
2228
  session_file_path: payload.session_file_path || runtimeContext?.session_file_path,
2078
2229
  token_usage_percent: runtimeContext?.token_usage_percent,
2079
2230
  context_usage_percent: runtimeContext?.context_usage_percent,
2231
+ tool_name: payload.tool_name ? String(payload.tool_name) : undefined,
2232
+ tool_id: payload.tool_id ? String(payload.tool_id) : undefined,
2233
+ item_id: payload.item_id ? String(payload.item_id) : undefined,
2234
+ turn_started_at: payload.turn_started_at ? String(payload.turn_started_at) : undefined,
2235
+ event_count: typeof payload.event_count === "number" ? payload.event_count : undefined,
2080
2236
  };
2081
2237
  }
2082
2238
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.34",
4
- "gitCommitId": "c3c936c",
3
+ "version": "0.2.36",
4
+ "gitCommitId": "54d9de4",
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.34",
22
- "@love-moon/conductor-sdk": "0.2.34",
21
+ "@love-moon/ai-manager": "0.2.36",
22
+ "@love-moon/ai-sdk": "0.2.36",
23
+ "@love-moon/conductor-sdk": "0.2.36",
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,
@@ -354,6 +355,35 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
354
355
  return { helperPath, updated: true };
355
356
  }
356
357
 
358
+ export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
359
+ const normalizedWorkspacePath =
360
+ typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
361
+ const normalizedWorktreeRoot =
362
+ typeof worktreeRoot === "string" ? worktreeRoot.trim() : "";
363
+ if (!normalizedWorkspacePath || !normalizedWorktreeRoot) {
364
+ return false;
365
+ }
366
+
367
+ const resolvedWorkspacePath = path.resolve(normalizedWorkspacePath);
368
+ const resolvedWorktreeRoot = path.resolve(normalizedWorktreeRoot);
369
+ if (resolvedWorkspacePath === resolvedWorktreeRoot) {
370
+ return false;
371
+ }
372
+
373
+ const expectedParent = path.resolve(resolvedWorkspacePath, ".conductor", "worktrees");
374
+ const relativeToExpectedParent = path.relative(expectedParent, resolvedWorktreeRoot);
375
+ if (
376
+ !relativeToExpectedParent ||
377
+ relativeToExpectedParent === "." ||
378
+ relativeToExpectedParent.startsWith("..") ||
379
+ path.isAbsolute(relativeToExpectedParent)
380
+ ) {
381
+ return false;
382
+ }
383
+
384
+ return true;
385
+ }
386
+
357
387
  function normalizeOptionalString(value) {
358
388
  if (typeof value !== "string") {
359
389
  return null;
@@ -638,6 +668,7 @@ export function startDaemon(config = {}, deps = {}) {
638
668
  const autoUpdateForceLocal = parseBooleanEnv(process.env.CONDUCTOR_AUTO_UPDATE_FORCE_LOCAL);
639
669
  const autoUpdateSupportedInstall =
640
670
  autoUpdateForceLocal || isManagedInstallPathFn(installedPackageRoot);
671
+ const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
641
672
  const lockHandoffToken =
642
673
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
643
674
  normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
@@ -738,6 +769,30 @@ export function startDaemon(config = {}, deps = {}) {
738
769
  return resolvedPath;
739
770
  }
740
771
 
772
+ const PROJECT_SETTINGS_TEMPLATE = [
773
+ "worktree:",
774
+ " sync_branch: false",
775
+ " symlink: []",
776
+ " # Example: symlink paths from the parent workspace into each worktree",
777
+ " # symlink:",
778
+ " # - node_modules",
779
+ " # - .env",
780
+ "",
781
+ ].join("\n");
782
+
783
+ function ensureProjectSettingsTemplate(projectWorkspacePath) {
784
+ const settingsPath = path.join(projectWorkspacePath, ".conductor", "settings.yaml");
785
+ if (existsSyncFn(settingsPath)) {
786
+ return;
787
+ }
788
+ try {
789
+ mkdirSyncFn(path.join(projectWorkspacePath, ".conductor"), { recursive: true });
790
+ writeFileSyncFn(settingsPath, PROJECT_SETTINGS_TEMPLATE, "utf8");
791
+ } catch (_error) {
792
+ // best-effort; do not block project validation if template creation fails
793
+ }
794
+ }
795
+
741
796
  function readProjectWorktreeSettings(projectWorkspacePath) {
742
797
  const settingsCandidates = [
743
798
  path.join(projectWorkspacePath, ".conductor", "settings.yaml"),
@@ -759,6 +814,7 @@ export function startDaemon(config = {}, deps = {}) {
759
814
  : {};
760
815
  return {
761
816
  symlinkPaths: normalizeConfiguredPathList(worktreeSettings.symlink, projectWorkspacePath),
817
+ syncBranch: worktreeSettings.sync_branch === true || worktreeSettings.syncBranch === true,
762
818
  settingsPath,
763
819
  };
764
820
  } catch (error) {
@@ -768,6 +824,7 @@ export function startDaemon(config = {}, deps = {}) {
768
824
 
769
825
  return {
770
826
  symlinkPaths: [],
827
+ syncBranch: false,
771
828
  settingsPath: null,
772
829
  };
773
830
  }
@@ -811,9 +868,10 @@ export function startDaemon(config = {}, deps = {}) {
811
868
 
812
869
  async function runSpawnProcess(command, args, options = {}) {
813
870
  let child;
871
+ const { timeoutMs, ...spawnOptions } = options || {};
814
872
  try {
815
873
  child = spawnFn(command, args, {
816
- ...options,
874
+ ...spawnOptions,
817
875
  stdio: ["ignore", "pipe", "pipe"],
818
876
  });
819
877
  } catch (error) {
@@ -824,12 +882,17 @@ export function startDaemon(config = {}, deps = {}) {
824
882
  let stdout = "";
825
883
  let stderr = "";
826
884
  let settled = false;
885
+ let timeoutHandle = null;
827
886
 
828
887
  const finishResolve = () => {
829
888
  if (settled) {
830
889
  return;
831
890
  }
832
891
  settled = true;
892
+ if (timeoutHandle) {
893
+ clearTimeout(timeoutHandle);
894
+ timeoutHandle = null;
895
+ }
833
896
  resolve({ stdout, stderr });
834
897
  };
835
898
 
@@ -838,9 +901,26 @@ export function startDaemon(config = {}, deps = {}) {
838
901
  return;
839
902
  }
840
903
  settled = true;
904
+ if (timeoutHandle) {
905
+ clearTimeout(timeoutHandle);
906
+ timeoutHandle = null;
907
+ }
841
908
  reject(error instanceof Error ? error : new Error(String(error)));
842
909
  };
843
910
 
911
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
912
+ timeoutHandle = setTimeout(() => {
913
+ try {
914
+ if (child && typeof child.kill === "function") {
915
+ child.kill("SIGTERM");
916
+ }
917
+ } catch {
918
+ // ignore process kill failures; the timeout error is the useful signal
919
+ }
920
+ finishReject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`));
921
+ }, timeoutMs);
922
+ }
923
+
844
924
  if (child.stdout && typeof child.stdout.on === "function") {
845
925
  child.stdout.on("data", (chunk) => {
846
926
  stdout += String(chunk ?? "");
@@ -888,6 +968,48 @@ export function startDaemon(config = {}, deps = {}) {
888
968
  const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
889
969
  const gitMarkerPath = path.join(worktreeRoot, ".git");
890
970
  if (!existsSyncFn(gitMarkerPath)) {
971
+ const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
972
+ if (syncBranch) {
973
+ try {
974
+ const { stdout: remoteStdout } = await runSpawnProcess(
975
+ "git",
976
+ ["-C", worktreeConfig.projectRepoRoot, "remote"],
977
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
978
+ );
979
+ const hasRemote = remoteStdout.trim().length > 0;
980
+ if (hasRemote) {
981
+ await runSpawnProcess(
982
+ "git",
983
+ ["-C", worktreeConfig.projectRepoRoot, "fetch"],
984
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
985
+ );
986
+ const { stdout: branchStdout } = await runSpawnProcess(
987
+ "git",
988
+ ["-C", worktreeConfig.projectRepoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
989
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
990
+ );
991
+ const currentBranch = branchStdout.trim();
992
+ if (currentBranch && currentBranch !== "HEAD") {
993
+ const { stdout: trackingStdout } = await runSpawnProcess(
994
+ "git",
995
+ ["-C", worktreeConfig.projectRepoRoot, "rev-parse", "--abbrev-ref", `${currentBranch}@{upstream}`],
996
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
997
+ ).catch(() => ({ stdout: "" }));
998
+ const upstream = trackingStdout.trim();
999
+ if (upstream) {
1000
+ await runSpawnProcess(
1001
+ "git",
1002
+ ["-C", worktreeConfig.projectRepoRoot, "merge", "--ff-only", upstream],
1003
+ { cwd: worktreeConfig.projectRepoRoot, timeoutMs: WORKTREE_SYNC_TIMEOUT_MS },
1004
+ ).catch(() => {});
1005
+ }
1006
+ }
1007
+ }
1008
+ } catch (_syncError) {
1009
+ // sync_branch is best-effort; proceed with worktree creation even if sync fails
1010
+ }
1011
+ }
1012
+
891
1013
  mkdirSyncFn(path.dirname(worktreeRoot), { recursive: true });
892
1014
  try {
893
1015
  await runSpawnProcess(
@@ -962,6 +1084,10 @@ export function startDaemon(config = {}, deps = {}) {
962
1084
  process.env.CONDUCTOR_DAEMON_FORCE_KILL_WAIT_MS,
963
1085
  2_000,
964
1086
  );
1087
+ const WORKTREE_SYNC_TIMEOUT_MS = parsePositiveInt(
1088
+ process.env.CONDUCTOR_WORKTREE_SYNC_TIMEOUT_MS,
1089
+ 5_000,
1090
+ );
965
1091
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
966
1092
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
967
1093
  1000,
@@ -1073,87 +1199,94 @@ export function startDaemon(config = {}, deps = {}) {
1073
1199
 
1074
1200
  const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
1075
1201
  try {
1076
- if (existsSyncFn(LOCK_FILE)) {
1077
- const lockState = readLockState();
1078
- const pid = lockState?.pid;
1079
- if (pid) {
1080
- const handoffMatched = hasMatchingLockHandoff(lockState);
1081
- try {
1082
- if (handoffMatched) {
1083
- log(`Taking over daemon lock from PID ${pid} via handoff`);
1084
- } else {
1085
- const alive = isProcessAlive(pid);
1086
- if (alive) {
1087
- if (config.FORCE) {
1088
- log(`Force enabled: stopping existing daemon PID ${pid}`);
1089
- let alreadyExited = false;
1090
- try {
1091
- killFn(pid, "SIGTERM");
1092
- } catch (killErr) {
1093
- if (killErr?.code === "ESRCH") {
1094
- alreadyExited = true;
1095
- } else {
1096
- logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
1097
- return exitAndReturn(1);
1202
+ if (skipPidLockCheck) {
1203
+ log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
1204
+ } else {
1205
+ if (existsSyncFn(LOCK_FILE)) {
1206
+ const lockState = readLockState();
1207
+ const pid = lockState?.pid;
1208
+ if (pid) {
1209
+ const handoffMatched = hasMatchingLockHandoff(lockState);
1210
+ try {
1211
+ if (handoffMatched) {
1212
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
1213
+ } else {
1214
+ const alive = isProcessAlive(pid);
1215
+ if (alive) {
1216
+ if (config.FORCE) {
1217
+ log(`Force enabled: stopping existing daemon PID ${pid}`);
1218
+ let alreadyExited = false;
1219
+ try {
1220
+ killFn(pid, "SIGTERM");
1221
+ } catch (killErr) {
1222
+ if (killErr?.code === "ESRCH") {
1223
+ alreadyExited = true;
1224
+ } else {
1225
+ logError(`Failed to stop existing daemon PID ${pid}: ${killErr.message}`);
1226
+ return exitAndReturn(1);
1227
+ }
1098
1228
  }
1099
- }
1100
- try {
1101
- let exited = alreadyExited || waitForProcessExitSync(pid, DAEMON_FORCE_STOP_GRACE_MS);
1102
- if (!exited) {
1103
- log(
1104
- `Existing daemon PID ${pid} did not exit within ${DAEMON_FORCE_STOP_GRACE_MS}ms; sending SIGKILL`,
1105
- );
1106
- try {
1107
- killFn(pid, "SIGKILL");
1108
- } catch (killErr) {
1109
- if (killErr?.code !== "ESRCH") {
1110
- logError(`Failed to force kill existing daemon PID ${pid}: ${killErr.message}`);
1111
- return exitAndReturn(1);
1229
+ try {
1230
+ let exited = alreadyExited || waitForProcessExitSync(pid, DAEMON_FORCE_STOP_GRACE_MS);
1231
+ if (!exited) {
1232
+ log(
1233
+ `Existing daemon PID ${pid} did not exit within ${DAEMON_FORCE_STOP_GRACE_MS}ms; sending SIGKILL`,
1234
+ );
1235
+ try {
1236
+ killFn(pid, "SIGKILL");
1237
+ } catch (killErr) {
1238
+ if (killErr?.code !== "ESRCH") {
1239
+ logError(`Failed to force kill existing daemon PID ${pid}: ${killErr.message}`);
1240
+ return exitAndReturn(1);
1241
+ }
1112
1242
  }
1243
+ exited = waitForProcessExitSync(pid, DAEMON_FORCE_KILL_WAIT_MS);
1113
1244
  }
1114
- exited = waitForProcessExitSync(pid, DAEMON_FORCE_KILL_WAIT_MS);
1115
- }
1116
- if (!exited) {
1117
- logError(`Existing daemon PID ${pid} is still running after force restart; please stop it manually.`);
1245
+ if (!exited) {
1246
+ logError(`Existing daemon PID ${pid} is still running after force restart; please stop it manually.`);
1247
+ return exitAndReturn(1);
1248
+ }
1249
+ } catch (checkErr) {
1250
+ logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
1118
1251
  return exitAndReturn(1);
1119
1252
  }
1120
- } catch (checkErr) {
1121
- logError(`Failed to verify daemon PID ${pid}: ${checkErr.message}`);
1253
+ log("Removing lock file after force stop");
1254
+ if (existsSyncFn(LOCK_FILE)) {
1255
+ unlinkSyncFn(LOCK_FILE);
1256
+ }
1257
+ } else {
1258
+ logError(`Daemon already running with PID ${pid}`);
1122
1259
  return exitAndReturn(1);
1123
1260
  }
1124
- log("Removing lock file after force stop");
1125
- if (existsSyncFn(LOCK_FILE)) {
1126
- unlinkSyncFn(LOCK_FILE);
1127
- }
1128
1261
  } else {
1129
- logError(`Daemon already running with PID ${pid}`);
1130
- return exitAndReturn(1);
1262
+ log("Removing stale lock file");
1263
+ unlinkSyncFn(LOCK_FILE);
1131
1264
  }
1265
+ }
1266
+ } catch (e) {
1267
+ if (handoffMatched) {
1268
+ log(`Taking over daemon lock from PID ${pid} via handoff`);
1132
1269
  } else {
1133
- log("Removing stale lock file");
1134
- unlinkSyncFn(LOCK_FILE);
1270
+ logError(`Daemon already running with PID ${pid} (access denied)`);
1271
+ return exitAndReturn(1);
1135
1272
  }
1136
1273
  }
1137
- } catch (e) {
1138
- if (handoffMatched) {
1139
- log(`Taking over daemon lock from PID ${pid} via handoff`);
1140
- } else {
1141
- logError(`Daemon already running with PID ${pid} (access denied)`);
1142
- return exitAndReturn(1);
1143
- }
1274
+ } else {
1275
+ log("Removing malformed lock file");
1276
+ unlinkSyncFn(LOCK_FILE);
1144
1277
  }
1145
- } else {
1146
- log("Removing malformed lock file");
1147
- unlinkSyncFn(LOCK_FILE);
1148
1278
  }
1279
+ writeFileSyncFn(LOCK_FILE, process.pid.toString());
1149
1280
  }
1150
- writeFileSyncFn(LOCK_FILE, process.pid.toString());
1151
1281
  } catch (err) {
1152
1282
  logError("Failed to acquire lock:", err);
1153
1283
  return exitAndReturn(1);
1154
1284
  }
1155
1285
 
1156
1286
  const cleanupLock = () => {
1287
+ if (skipPidLockCheck) {
1288
+ return;
1289
+ }
1157
1290
  try {
1158
1291
  if (existsSyncFn(LOCK_FILE)) {
1159
1292
  const lockState = readLockState();
@@ -1167,6 +1300,9 @@ export function startDaemon(config = {}, deps = {}) {
1167
1300
  };
1168
1301
 
1169
1302
  const writeLockHandoff = ({ handoffToken, handoffFromPid, handoffExpiresAt }) => {
1303
+ if (skipPidLockCheck) {
1304
+ return;
1305
+ }
1170
1306
  writeFileSyncFn(
1171
1307
  LOCK_FILE,
1172
1308
  JSON.stringify({
@@ -1321,6 +1457,8 @@ export function startDaemon(config = {}, deps = {}) {
1321
1457
  if (advertisedCapabilities.length > 0) {
1322
1458
  extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
1323
1459
  }
1460
+ const aiManagerHandlers = createAiManagerHandlers({ configPath: config.CONFIG_FILE });
1461
+
1324
1462
  const client = createWebSocketClient(sdkConfig, {
1325
1463
  extraHeaders,
1326
1464
  onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
@@ -2874,6 +3012,30 @@ export function startDaemon(config = {}, deps = {}) {
2874
3012
  logError(`Failed to report agent_command_ack(cleanup_task_worktree) for ${taskId}: ${error?.message || error}`);
2875
3013
  });
2876
3014
 
3015
+ if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
3016
+ if (forceCleanup) {
3017
+ const stopStarted = stopActiveTaskProcess(taskId, {
3018
+ reason: "cleanup_task_worktree",
3019
+ suppressExitStatusReport: true,
3020
+ });
3021
+ if (stopStarted) {
3022
+ const stopped = await waitForTaskToStop(taskId);
3023
+ if (!stopped && (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId))) {
3024
+ await reportTaskWorktreeCleanupResult({
3025
+ requestId,
3026
+ taskId,
3027
+ worktreeBranch: worktreeConfig.worktreeBranch,
3028
+ cleaned: false,
3029
+ error: "Task is still active",
3030
+ }).catch((error) => {
3031
+ logError(`Failed to report task_worktree_cleanup_result for ${taskId}: ${error?.message || error}`);
3032
+ });
3033
+ return;
3034
+ }
3035
+ }
3036
+ }
3037
+ }
3038
+
2877
3039
  if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
2878
3040
  await reportTaskWorktreeCleanupResult({
2879
3041
  requestId,
@@ -2891,6 +3053,19 @@ export function startDaemon(config = {}, deps = {}) {
2891
3053
  worktreeConfig.projectWorkspacePath,
2892
3054
  worktreeConfig.worktreeId,
2893
3055
  );
3056
+ if (!isSafeTaskWorktreeRoot(worktreeConfig.projectWorkspacePath, worktreeRoot)) {
3057
+ await reportTaskWorktreeCleanupResult({
3058
+ requestId,
3059
+ taskId,
3060
+ worktreeBranch: worktreeConfig.worktreeBranch,
3061
+ removedPath: worktreeRoot,
3062
+ cleaned: false,
3063
+ error: `Refusing to remove unsafe worktree path: ${worktreeRoot}`,
3064
+ }).catch((error) => {
3065
+ logError(`Failed to report task_worktree_cleanup_result for ${taskId}: ${error?.message || error}`);
3066
+ });
3067
+ return;
3068
+ }
2894
3069
  const worktreeCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
2895
3070
  const statusCwd = existsSyncFn(worktreeCwd) ? worktreeCwd : worktreeRoot;
2896
3071
 
@@ -3259,6 +3434,11 @@ export function startDaemon(config = {}, deps = {}) {
3259
3434
  if (event.type === "validate_project_path") {
3260
3435
  void handleValidateProjectPath(event.payload);
3261
3436
  }
3437
+ if (event.type === "ai_manager_request") {
3438
+ handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
3439
+ logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
3440
+ });
3441
+ }
3262
3442
  }
3263
3443
 
3264
3444
  function markWatchdogHealthy(signal, at = Date.now()) {
@@ -3367,12 +3547,14 @@ export function startDaemon(config = {}, deps = {}) {
3367
3547
  };
3368
3548
  } else {
3369
3549
  const snapshot = await Promise.resolve(resolveProjectSnapshotFn(resolvedPath));
3550
+ const effectiveWorkspace =
3551
+ typeof snapshot?.projectRoot === "string" && snapshot.projectRoot.trim()
3552
+ ? snapshot.projectRoot.trim()
3553
+ : resolvedPath;
3554
+ ensureProjectSettingsTemplate(effectiveWorkspace);
3370
3555
  result = {
3371
3556
  ...result,
3372
- workspacePath:
3373
- typeof snapshot?.projectRoot === "string" && snapshot.projectRoot.trim()
3374
- ? snapshot.projectRoot.trim()
3375
- : resolvedPath,
3557
+ workspacePath: effectiveWorkspace,
3376
3558
  repoRoot:
3377
3559
  typeof snapshot?.repoRoot === "string" && snapshot.repoRoot.trim()
3378
3560
  ? snapshot.repoRoot.trim()
@@ -3420,57 +3602,25 @@ export function startDaemon(config = {}, deps = {}) {
3420
3602
  }
3421
3603
  }
3422
3604
 
3423
- function handleStopTask(payload) {
3424
- const taskId = payload?.task_id;
3425
- if (!taskId) return;
3426
- const requestId = payload?.request_id ? String(payload.request_id) : "";
3427
- if (requestId && !markRequestSeen(requestId)) {
3428
- log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
3429
- sendAgentCommandAck({
3430
- requestId,
3431
- taskId,
3432
- eventType: "stop_task",
3433
- accepted: true,
3434
- }).catch(() => {});
3435
- return;
3436
- }
3437
-
3438
- const sendStopAck = (accepted) => {
3439
- if (!requestId) return;
3440
- client
3441
- .sendJson({
3442
- type: "task_stop_ack",
3443
- payload: {
3444
- task_id: taskId,
3445
- request_id: requestId,
3446
- accepted: Boolean(accepted),
3447
- },
3448
- })
3449
- .catch((err) => {
3450
- logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
3451
- });
3452
- sendAgentCommandAck({
3453
- requestId,
3454
- taskId,
3455
- eventType: "stop_task",
3456
- accepted,
3457
- }).catch((err) => {
3458
- logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
3459
- });
3460
- };
3461
-
3605
+ function stopActiveTaskProcess(
3606
+ taskId,
3607
+ {
3608
+ reason = "",
3609
+ suppressExitStatusReport = false,
3610
+ } = {},
3611
+ ) {
3462
3612
  const processRecord = activeTaskProcesses.get(taskId);
3463
3613
  const ptyRecord = activePtySessions.get(taskId);
3464
3614
  if ((!processRecord || !processRecord.child) && !ptyRecord) {
3465
- log(`Stop requested for task ${taskId}, but no active process found`);
3466
- sendStopAck(false);
3467
- return;
3615
+ return false;
3468
3616
  }
3469
3617
 
3470
- const reason = payload?.reason ? ` (${payload.reason})` : "";
3471
- log(`Stopping task ${taskId}${reason}`);
3618
+ if (suppressExitStatusReport) {
3619
+ suppressedExitStatusReports.add(taskId);
3620
+ }
3472
3621
 
3473
- sendStopAck(true);
3622
+ const reasonSuffix = reason ? ` (${reason})` : "";
3623
+ log(`Stopping task ${taskId}${reasonSuffix}`);
3474
3624
 
3475
3625
  const activeRecord = processRecord || ptyRecord;
3476
3626
  if (activeRecord?.stopForceKillTimer) {
@@ -3528,6 +3678,75 @@ export function startDaemon(config = {}, deps = {}) {
3528
3678
  if (typeof activeRecord.stopForceKillTimer?.unref === "function") {
3529
3679
  activeRecord.stopForceKillTimer.unref();
3530
3680
  }
3681
+
3682
+ return true;
3683
+ }
3684
+
3685
+ async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
3686
+ const deadline = Date.now() + Math.max(timeoutMs, 0);
3687
+
3688
+ while (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
3689
+ const remainingMs = deadline - Date.now();
3690
+ if (remainingMs <= 0) {
3691
+ return false;
3692
+ }
3693
+ await new Promise((resolve) =>
3694
+ setTimeout(resolve, Math.min(DAEMON_FORCE_STOP_POLL_INTERVAL_MS, remainingMs)),
3695
+ );
3696
+ }
3697
+
3698
+ return true;
3699
+ }
3700
+
3701
+ function handleStopTask(payload) {
3702
+ const taskId = payload?.task_id;
3703
+ if (!taskId) return;
3704
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
3705
+ if (requestId && !markRequestSeen(requestId)) {
3706
+ log(`Duplicate stop_task ignored for ${taskId} (request_id=${requestId})`);
3707
+ sendAgentCommandAck({
3708
+ requestId,
3709
+ taskId,
3710
+ eventType: "stop_task",
3711
+ accepted: true,
3712
+ }).catch(() => {});
3713
+ return;
3714
+ }
3715
+
3716
+ const sendStopAck = (accepted) => {
3717
+ if (!requestId) return;
3718
+ client
3719
+ .sendJson({
3720
+ type: "task_stop_ack",
3721
+ payload: {
3722
+ task_id: taskId,
3723
+ request_id: requestId,
3724
+ accepted: Boolean(accepted),
3725
+ },
3726
+ })
3727
+ .catch((err) => {
3728
+ logError(`Failed to report task_stop_ack for ${taskId}: ${err?.message || err}`);
3729
+ });
3730
+ sendAgentCommandAck({
3731
+ requestId,
3732
+ taskId,
3733
+ eventType: "stop_task",
3734
+ accepted,
3735
+ }).catch((err) => {
3736
+ logError(`Failed to report agent_command_ack(stop_task) for ${taskId}: ${err?.message || err}`);
3737
+ });
3738
+ };
3739
+
3740
+ const processRecord = activeTaskProcesses.get(taskId);
3741
+ const ptyRecord = activePtySessions.get(taskId);
3742
+ if ((!processRecord || !processRecord.child) && !ptyRecord) {
3743
+ log(`Stop requested for task ${taskId}, but no active process found`);
3744
+ sendStopAck(false);
3745
+ return;
3746
+ }
3747
+
3748
+ sendStopAck(true);
3749
+ stopActiveTaskProcess(taskId, { reason: payload?.reason });
3531
3750
  }
3532
3751
 
3533
3752
  async function getProjectLocalPath(projectId) {
@@ -17,16 +17,29 @@ const LEGACY_RUNTIME_BACKEND_ALIASES = new Set([
17
17
  const externalRuntimeCatalogPromises = new Map();
18
18
  let externalRuntimeImportNonce = 0;
19
19
 
20
- function normalizeProviderPathEnv(value) {
21
- return String(value || "").trim();
20
+ function appendProviderModulePaths(parts, value) {
21
+ if (Array.isArray(value)) {
22
+ for (const entry of value) {
23
+ appendProviderModulePaths(parts, entry);
24
+ }
25
+ return;
26
+ }
27
+ const raw = String(value || "").trim();
28
+ if (!raw) {
29
+ return;
30
+ }
31
+ for (const item of raw.split(process.platform === "win32" ? ";" : ":")) {
32
+ const normalized = item.trim();
33
+ if (normalized) {
34
+ parts.push(normalized);
35
+ }
36
+ }
22
37
  }
23
38
 
24
39
  function listProviderModulePaths(providerPathEnv) {
25
- const raw = normalizeProviderPathEnv(providerPathEnv);
26
- if (!raw) {
27
- return [];
28
- }
29
- return [...new Set(raw.split(process.platform === "win32" ? ";" : ":").map((item) => item.trim()).filter(Boolean))];
40
+ const parts = [];
41
+ appendProviderModulePaths(parts, providerPathEnv);
42
+ return [...new Set(parts)];
30
43
  }
31
44
 
32
45
  function normalizeRuntimeBackendName(backend) {
@@ -221,18 +234,17 @@ function readConfigEnvValue(configFilePath, key) {
221
234
  if (!parsed || typeof parsed !== "object") {
222
235
  return "";
223
236
  }
224
- const value = parsed?.envs?.[key];
225
- return typeof value === "string" ? value.trim() : "";
237
+ return parsed?.envs?.[key];
226
238
  } catch {
227
- return "";
239
+ return undefined;
228
240
  }
229
241
  }
230
242
 
231
- function resolveProviderPathEnv(options = {}) {
232
- return (
233
- normalizeProviderPathEnv(process.env.AISDK_PROVIDER_PATH) ||
234
- normalizeProviderPathEnv(readConfigEnvValue(options.configFilePath, "AISDK_PROVIDER_PATH"))
235
- );
243
+ function resolveProviderModulePaths(options = {}) {
244
+ return [
245
+ ...listProviderModulePaths(process.env.AISDK_PROVIDER_PATH),
246
+ ...listProviderModulePaths(readConfigEnvValue(options.configFilePath, "AISDK_PROVIDER_PATH")),
247
+ ].filter((value, index, array) => array.indexOf(value) === index);
236
248
  }
237
249
 
238
250
  function createEmptyExternalCatalog() {
@@ -342,15 +354,16 @@ async function loadExternalRuntimeCatalog(providerPathEnv) {
342
354
  }
343
355
 
344
356
  async function getExternalRuntimeCatalog(options = {}) {
345
- const providerPathEnv = resolveProviderPathEnv(options);
346
- if (!externalRuntimeCatalogPromises.has(providerPathEnv)) {
347
- const loadPromise = loadExternalRuntimeCatalog(providerPathEnv).catch((error) => {
348
- externalRuntimeCatalogPromises.delete(providerPathEnv);
357
+ const modulePaths = resolveProviderModulePaths(options);
358
+ const cacheKey = modulePaths.join("\0");
359
+ if (!externalRuntimeCatalogPromises.has(cacheKey)) {
360
+ const loadPromise = loadExternalRuntimeCatalog(modulePaths).catch((error) => {
361
+ externalRuntimeCatalogPromises.delete(cacheKey);
349
362
  throw error;
350
363
  });
351
- externalRuntimeCatalogPromises.set(providerPathEnv, loadPromise);
364
+ externalRuntimeCatalogPromises.set(cacheKey, loadPromise);
352
365
  }
353
- return externalRuntimeCatalogPromises.get(providerPathEnv);
366
+ return externalRuntimeCatalogPromises.get(cacheKey);
354
367
  }
355
368
 
356
369
  export async function normalizeRuntimeBackendAlias(backend, options = {}) {