@love-moon/conductor-cli 0.2.31 → 0.2.33

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.
@@ -19,7 +19,7 @@ import yargs from "yargs/yargs";
19
19
  import { hideBin } from "yargs/helpers";
20
20
  import yaml from "js-yaml";
21
21
  import { createAiSession } from "@love-moon/ai-sdk";
22
- import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
22
+ import { ConductorClient, ProjectContext, loadConfig } from "@love-moon/conductor-sdk";
23
23
  import {
24
24
  buildResumeArgsForBackend as buildCliResumeArgsForBackend,
25
25
  resolveResumeContext as resolveCliResumeContext,
@@ -28,8 +28,9 @@ import {
28
28
  } from "../src/fire/resume.js";
29
29
  import {
30
30
  filterRuntimeSupportedAllowCliList,
31
- RUNTIME_SUPPORTED_BACKENDS,
32
- listRuntimeSupportedBackends,
31
+ isBuiltInRuntimeBackend,
32
+ listAdvertisedBackends,
33
+ resolveConfiguredRuntimeBackend,
33
34
  normalizeRuntimeBackendAlias,
34
35
  normalizeRuntimeBackendName,
35
36
  } from "../src/runtime-backends.js";
@@ -66,6 +67,20 @@ export function buildConductorConnectHeaders(version = pkgJson.version) {
66
67
  };
67
68
  }
68
69
 
70
+ export function shouldRunReconnectRecovery({
71
+ isReconnect,
72
+ fireShuttingDown = false,
73
+ runner = null,
74
+ } = {}) {
75
+ if (!isReconnect || fireShuttingDown) {
76
+ return false;
77
+ }
78
+ if (!runner || typeof runner.shouldSuppressReconnectRecovery !== "function") {
79
+ return true;
80
+ }
81
+ return !runner.shouldSuppressReconnectRecovery();
82
+ }
83
+
69
84
  // Load allow_cli_list from config file (no defaults - must be configured)
70
85
  async function loadAllowCliList(configFilePath) {
71
86
  const home = os.homedir();
@@ -85,37 +100,46 @@ async function loadAllowCliList(configFilePath) {
85
100
  return {};
86
101
  }
87
102
 
88
- export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env) {
103
+ export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
89
104
  const normalizedBackend = normalizeRuntimeBackendName(backend);
105
+ const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
90
106
  const envKeyByBackend = {
107
+ codex: "CONDUCTOR_CODEX_APP_SERVER_COMMAND",
91
108
  opencode: "CONDUCTOR_OPENCODE_COMMAND",
92
109
  kimi: "CONDUCTOR_KIMI_COMMAND",
93
110
  };
94
- const envKey = envKeyByBackend[normalizedBackend];
95
- if (!envKey) {
96
- return "";
97
- }
111
+ const envKey = envKeyByBackend[normalizedSessionBackend];
98
112
 
99
- const preferredEnvCommand = typeof env?.[envKey] === "string" ? env[envKey].trim() : "";
113
+ const preferredEnvCommand = envKey && typeof env?.[envKey] === "string" ? env[envKey].trim() : "";
100
114
  if (preferredEnvCommand) {
101
115
  return preferredEnvCommand;
102
116
  }
103
117
 
104
118
  const configuredCommand =
105
- allowCliList && typeof allowCliList === "object" && typeof allowCliList[normalizedBackend] === "string"
106
- ? allowCliList[normalizedBackend].trim()
119
+ allowCliList && typeof allowCliList === "object"
120
+ ? typeof allowCliList[normalizedBackend] === "string"
121
+ ? allowCliList[normalizedBackend].trim()
122
+ : typeof allowCliList[normalizedSessionBackend] === "string"
123
+ ? allowCliList[normalizedSessionBackend].trim()
124
+ : ""
107
125
  : "";
108
- if (configuredCommand) {
109
- return configuredCommand;
110
- }
111
126
 
112
127
  const daemonCommand =
113
128
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
114
- if (daemonCommand) {
115
- return daemonCommand;
129
+
130
+ const resolvedCommand = configuredCommand || daemonCommand;
131
+ if (!resolvedCommand) {
132
+ return "";
116
133
  }
117
134
 
118
- return "";
135
+ if (normalizedSessionBackend === "codex") {
136
+ if (/\bapp-server\b/.test(resolvedCommand)) {
137
+ return resolvedCommand;
138
+ }
139
+ return `${resolvedCommand} app-server --listen stdio://`;
140
+ }
141
+
142
+ return resolvedCommand;
119
143
  }
120
144
 
121
145
  const DEFAULT_POLL_INTERVAL_MS = parseInt(
@@ -429,12 +453,15 @@ async function main() {
429
453
  }
430
454
 
431
455
  const allowCliList = await loadAllowCliList(cliArgs.configFile);
432
- const supportedBackends = Object.keys(allowCliList);
433
- const discoveredBackends = await listRuntimeSupportedBackends({ configFilePath: cliArgs.configFile });
434
- const externalBackends = discoveredBackends.filter((backend) => !RUNTIME_SUPPORTED_BACKENDS.includes(backend));
456
+ const { supportedBackends, externalBackends, discoveryError } = await listAdvertisedBackends(allowCliList, {
457
+ configFilePath: cliArgs.configFile,
458
+ });
435
459
 
436
460
  if (cliArgs.listBackends) {
437
461
  if (supportedBackends.length === 0 && externalBackends.length === 0) {
462
+ if (discoveryError) {
463
+ throw discoveryError;
464
+ }
438
465
  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`);
439
466
  } else {
440
467
  if (supportedBackends.length > 0) {
@@ -457,7 +484,7 @@ async function main() {
457
484
  let resumeContext = null;
458
485
  if (cliArgs.resumeSessionId) {
459
486
  const bootstrap = await bootstrapResumeContextForFire({
460
- backend: cliArgs.backend,
487
+ backend: cliArgs.sessionBackend || cliArgs.backend,
461
488
  configFile: cliArgs.configFile,
462
489
  resumeSessionId: cliArgs.resumeSessionId,
463
490
  });
@@ -485,7 +512,13 @@ async function main() {
485
512
  fireWatchdog.start();
486
513
 
487
514
  const scheduleReconnectRecovery = ({ isReconnect }) => {
488
- if (!isReconnect) {
515
+ if (
516
+ !shouldRunReconnectRecovery({
517
+ isReconnect,
518
+ fireShuttingDown,
519
+ runner: reconnectRunner,
520
+ })
521
+ ) {
489
522
  return;
490
523
  }
491
524
  log("Conductor connection restored");
@@ -591,6 +624,7 @@ async function main() {
591
624
  requestedTitle: requestedTaskTitle,
592
625
  backend: cliArgs.backend,
593
626
  daemonName: configuredDaemonName,
627
+ projectPath: runtimeProjectPath,
594
628
  });
595
629
  injectResolvedTaskId(taskContext.taskId);
596
630
  injectResolvedTaskId(taskContext.taskId, env);
@@ -621,9 +655,14 @@ async function main() {
621
655
 
622
656
  const resolvedResumeSessionId = cliArgs.resumeSessionId;
623
657
 
624
- const sessionCommandLine = resolveAiSessionCommandLine(cliArgs.backend, allowCliList, process.env);
658
+ const sessionCommandLine = resolveAiSessionCommandLine(
659
+ cliArgs.backend,
660
+ allowCliList,
661
+ process.env,
662
+ cliArgs.sessionBackend,
663
+ );
625
664
 
626
- backendSession = createAiSession(cliArgs.backend, {
665
+ backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
627
666
  initialImages: cliArgs.initialImages,
628
667
  cwd: runtimeProjectPath,
629
668
  resumeSessionId: resolvedResumeSessionId,
@@ -708,8 +747,8 @@ async function main() {
708
747
 
709
748
  let runnerError = null;
710
749
  try {
711
- if (!resolvedResumeSessionId && String(cliArgs.backend).trim().toLowerCase() === "codex") {
712
- await withFreshSessionBootstrapLock(cliArgs.backend, runtimeProjectPath, async () => {
750
+ if (!resolvedResumeSessionId && String(cliArgs.sessionBackend || cliArgs.backend).trim().toLowerCase() === "codex") {
751
+ await withFreshSessionBootstrapLock(cliArgs.sessionBackend || cliArgs.backend, runtimeProjectPath, async () => {
713
752
  await runner.announceBackendSession();
714
753
  });
715
754
  }
@@ -742,7 +781,13 @@ async function main() {
742
781
  summary: "conductor fire exited",
743
782
  };
744
783
  try {
745
- await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
784
+ const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
785
+ if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
786
+ await conductor.flushPendingUpstreamEvents({
787
+ timeoutMs: 5_000,
788
+ retryIntervalMs: 250,
789
+ });
790
+ }
746
791
  } catch (error) {
747
792
  log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
748
793
  }
@@ -864,9 +909,9 @@ export async function parseCliArgs(argvInput = process.argv) {
864
909
 
865
910
  const configFileFromArgs = extractConfigFileFromArgv(argv);
866
911
  const allowCliList = await loadAllowCliList(configFileFromArgs);
867
- const supportedBackends = Object.keys(allowCliList);
868
- const discoveredBackends = await listRuntimeSupportedBackends({ configFilePath: configFileFromArgs });
869
- const externalBackends = discoveredBackends.filter((backend) => !RUNTIME_SUPPORTED_BACKENDS.includes(backend));
912
+ const { supportedBackends, externalBackends, discoveryError } = await listAdvertisedBackends(allowCliList, {
913
+ configFilePath: configFileFromArgs,
914
+ });
870
915
 
871
916
  const conductorArgs = yargs(conductorArgv)
872
917
  .scriptName(CLI_NAME)
@@ -968,21 +1013,36 @@ Environment:
968
1013
  })
969
1014
  .parse();
970
1015
 
971
- const backend = conductorArgs.backend
972
- ? await normalizeRuntimeBackendAlias(conductorArgs.backend, { configFilePath: configFileFromArgs })
1016
+ const requestedBackend = conductorArgs.backend
1017
+ ? normalizeRuntimeBackendName(conductorArgs.backend)
973
1018
  : supportedBackends[0] || externalBackends[0];
1019
+ const configuredBackend = await resolveConfiguredRuntimeBackend(requestedBackend, allowCliList, {
1020
+ configFilePath: configFileFromArgs,
1021
+ });
1022
+ const backend = configuredBackend?.requestedBackend || requestedBackend;
1023
+ const sessionBackend =
1024
+ configuredBackend?.runtimeBackend ||
1025
+ (backend ? await normalizeRuntimeBackendAlias(backend, { configFilePath: configFileFromArgs }) : "");
974
1026
  const shouldRequireBackend =
975
1027
  !Boolean(conductorArgs.listBackends) &&
976
1028
  !listBackendsWithoutSeparator &&
977
1029
  !Boolean(conductorArgs.version) &&
978
1030
  !versionWithoutSeparator;
979
- const runtimeSupportedBackends = new Set(discoveredBackends);
980
- if (backend && !runtimeSupportedBackends.has(backend) && shouldRequireBackend) {
1031
+ const runtimeSupportedBackends = new Set(supportedBackends);
1032
+ const advertisedExternalBackends = new Set(externalBackends);
1033
+ const hasConfiguredEntry = Boolean(configuredBackend?.commandLine);
1034
+ const isAllowedExternalBackend =
1035
+ !isBuiltInRuntimeBackend(sessionBackend) &&
1036
+ advertisedExternalBackends.has(sessionBackend);
1037
+ if (backend && shouldRequireBackend && !hasConfiguredEntry && !isAllowedExternalBackend) {
981
1038
  throw new Error(
982
1039
  `Unsupported backend "${backend}". Supported backends: ${[...runtimeSupportedBackends].join(", ") || "none configured"}.`,
983
1040
  );
984
1041
  }
985
1042
  if (!backend && shouldRequireBackend) {
1043
+ if (discoveryError) {
1044
+ throw discoveryError;
1045
+ }
986
1046
  throw new Error("No supported backends configured. Add allow_cli_list entries or set AISDK_PROVIDER_PATH for external providers.");
987
1047
  }
988
1048
 
@@ -1010,6 +1070,7 @@ Environment:
1010
1070
  taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
1011
1071
  hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
1012
1072
  configFile: conductorArgs.configFile,
1073
+ sessionBackend,
1013
1074
  resumeSessionId,
1014
1075
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
1015
1076
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
@@ -1126,7 +1187,10 @@ async function ensureTaskContext(conductor, opts) {
1126
1187
  };
1127
1188
  }
1128
1189
 
1129
- const projectId = await resolveProjectId(conductor, opts.requestedProjectId);
1190
+ const projectId = await resolveProjectId(conductor, opts.requestedProjectId, {
1191
+ daemonName: opts.daemonName,
1192
+ projectPath: opts.projectPath,
1193
+ });
1130
1194
  const payload = {
1131
1195
  project_id: projectId,
1132
1196
  task_title: deriveTaskTitle(opts.initialPrompt, opts.requestedTitle, opts.backend),
@@ -1141,15 +1205,6 @@ async function ensureTaskContext(conductor, opts) {
1141
1205
 
1142
1206
  const session = await conductor.createTaskSession(payload);
1143
1207
 
1144
- // Auto-bind current path to project if not already bound
1145
- try {
1146
- await conductor.bindProjectPath(projectId);
1147
- log(`Bound current path to project ${projectId}`);
1148
- } catch (error) {
1149
- // Ignore binding errors - it's not critical
1150
- log(`Note: Could not bind path to project: ${error.message}`);
1151
- }
1152
-
1153
1208
  return {
1154
1209
  taskId: session.task_id,
1155
1210
  appUrl: session.app_url || null,
@@ -1158,51 +1213,155 @@ async function ensureTaskContext(conductor, opts) {
1158
1213
  };
1159
1214
  }
1160
1215
 
1161
- async function resolveProjectId(conductor, explicit) {
1216
+ export async function resolveProjectId(conductor, explicit, opts = {}) {
1162
1217
  if (explicit) {
1163
1218
  return explicit;
1164
1219
  }
1165
1220
 
1166
- // First, try to match project by current path
1221
+ const daemonHost = resolveDaemonHost(opts.daemonName);
1222
+ const projectPath = typeof opts.projectPath === "string" && opts.projectPath.trim() ? opts.projectPath.trim() : process.cwd();
1223
+
1224
+ if (!daemonHost) {
1225
+ return resolveDefaultProjectId(conductor);
1226
+ }
1227
+
1228
+ const exists = await isExistingDirectory(projectPath);
1229
+ if (!exists) {
1230
+ throw new Error(`Workspace path does not exist: ${projectPath}`);
1231
+ }
1232
+
1233
+ const snapshot = resolveWorkspaceSnapshot(projectPath);
1234
+ const projectName = deriveProjectName(snapshot);
1235
+
1167
1236
  try {
1168
- const matchResult = await conductor.matchProjectByPath();
1237
+ const matchResult = await conductor.matchProjectByPath({
1238
+ daemon_host: daemonHost,
1239
+ project_path: snapshot.projectRoot,
1240
+ });
1169
1241
  if (matchResult?.project_id) {
1170
1242
  log(`Matched project ${matchResult.project_name || matchResult.project_id} by path ${matchResult.matched_path}`);
1171
- return matchResult.project_id;
1243
+ let resolvedProjectId = matchResult.project_id;
1244
+ try {
1245
+ const bindResult = await conductor.bindProjectPath(matchResult.project_id, {
1246
+ daemon_host: daemonHost,
1247
+ project_path: snapshot.projectRoot,
1248
+ });
1249
+ if (typeof bindResult?.project_id === "string" && bindResult.project_id.trim()) {
1250
+ resolvedProjectId = bindResult.project_id.trim();
1251
+ }
1252
+ } catch (error) {
1253
+ log(`Unable to backfill bound workspace path: ${error.message}`);
1254
+ try {
1255
+ const rebound = await conductor.matchProjectByPath({
1256
+ daemon_host: daemonHost,
1257
+ project_path: snapshot.projectRoot,
1258
+ });
1259
+ if (rebound?.project_id) {
1260
+ resolvedProjectId = rebound.project_id;
1261
+ }
1262
+ } catch {
1263
+ // ignore retry match failures
1264
+ }
1265
+ }
1266
+ return resolvedProjectId;
1172
1267
  }
1173
1268
  } catch (error) {
1174
1269
  log(`Unable to match project by path: ${error.message}`);
1175
1270
  }
1176
1271
 
1177
1272
  try {
1178
- const record = await conductor.getLocalProjectRecord();
1179
- if (record?.project_id) {
1180
- try {
1181
- const listing = await conductor.listProjects();
1182
- const exists = Array.isArray(listing?.projects)
1183
- ? listing.projects.some((project) => String(project?.id || "") === String(record.project_id))
1184
- : false;
1185
- if (exists) {
1186
- return record.project_id;
1187
- }
1188
- log(`Local session project ${record.project_id} no longer exists; falling back to server project list`);
1189
- } catch (verifyError) {
1190
- log(`Unable to verify local project record; using cached project id: ${verifyError.message}`);
1191
- return record.project_id;
1192
- }
1273
+ const created = await conductor.createProject({
1274
+ name: projectName,
1275
+ bindingConfirmed: true,
1276
+ daemonHost,
1277
+ workspacePath: snapshot.projectRoot,
1278
+ repoRoot: snapshot.repoRoot,
1279
+ worktreeBranch: snapshot.worktreeBranch,
1280
+ lastCommit: snapshot.lastCommit,
1281
+ fileCount: snapshot.fileCount,
1282
+ });
1283
+ if (created?.id) {
1284
+ log(`Created bound project ${created.name || created.id} for ${daemonHost}:${snapshot.projectRoot}`);
1285
+ return created.id;
1193
1286
  }
1287
+ throw new Error("create_project returned no id");
1194
1288
  } catch (error) {
1195
- log(`Unable to resolve project via local session: ${error.message}`);
1289
+ log(`Unable to create bound project: ${error.message}`);
1290
+ }
1291
+
1292
+ try {
1293
+ const retryMatch = await conductor.matchProjectByPath({
1294
+ daemon_host: daemonHost,
1295
+ project_path: snapshot.projectRoot,
1296
+ });
1297
+ if (retryMatch?.project_id) {
1298
+ return retryMatch.project_id;
1299
+ }
1300
+ } catch {
1301
+ // ignore retry match failures
1302
+ }
1303
+
1304
+ log(`Unable to resolve bound project for ${daemonHost}:${snapshot.projectRoot}, falling back to default`);
1305
+ return resolveDefaultProjectId(conductor);
1306
+ }
1307
+
1308
+ function resolveDaemonHost(daemonName) {
1309
+ if (typeof daemonName === "string" && daemonName.trim()) {
1310
+ return daemonName.trim();
1311
+ }
1312
+ const fromEnv = typeof process.env.CONDUCTOR_DAEMON_NAME === "string" ? process.env.CONDUCTOR_DAEMON_NAME.trim() : "";
1313
+ if (fromEnv) {
1314
+ return fromEnv;
1196
1315
  }
1316
+ const fromAgent = typeof process.env.CONDUCTOR_AGENT_NAME === "string" ? process.env.CONDUCTOR_AGENT_NAME.trim() : "";
1317
+ if (fromAgent) {
1318
+ return fromAgent;
1319
+ }
1320
+ try {
1321
+ return os.hostname();
1322
+ } catch {
1323
+ return "";
1324
+ }
1325
+ }
1326
+
1327
+ function resolveWorkspaceSnapshot(projectPath) {
1328
+ try {
1329
+ const context = new ProjectContext(projectPath);
1330
+ return context.snapshot();
1331
+ } catch {
1332
+ return {
1333
+ projectRoot: path.resolve(projectPath),
1334
+ };
1335
+ }
1336
+ }
1337
+
1338
+ function deriveProjectName(snapshot) {
1339
+ const basePath = snapshot.repoRoot || snapshot.projectRoot;
1340
+ const name = basePath ? path.basename(basePath) : "";
1341
+ const baseName = name || "New Project";
1342
+ const digest = createHash("sha1").update(basePath || baseName).digest("hex").slice(0, 8);
1343
+ return `${baseName}-${digest}`;
1344
+ }
1197
1345
 
1198
- const listing = await conductor.listProjects();
1199
- const first = listing?.projects?.[0];
1200
- if (first?.id) {
1201
- return first.id;
1346
+ async function resolveDefaultProjectId(conductor) {
1347
+ try {
1348
+ const listing = await conductor.listProjects();
1349
+ const defaultProject = Array.isArray(listing?.projects)
1350
+ ? listing.projects.find((project) => Boolean(project?.isDefault))
1351
+ : null;
1352
+ if (defaultProject?.id) {
1353
+ return defaultProject.id;
1354
+ }
1355
+ } catch {
1356
+ // ignore list failures
1202
1357
  }
1203
- log("No projects available; creating default project...");
1358
+
1359
+ log("No bound daemon available; creating default project...");
1204
1360
  try {
1205
- const created = await conductor.createProject("default", "Auto-created by conductor-fire");
1361
+ const created = await conductor.createProject({
1362
+ name: "Default Project",
1363
+ isDefault: true,
1364
+ });
1206
1365
  if (created?.id) {
1207
1366
  log(`Created default project ${created.id}`);
1208
1367
  return created.id;
@@ -1644,6 +1803,10 @@ export class BridgeRunner {
1644
1803
  this.needsReconnectRecovery = true;
1645
1804
  }
1646
1805
 
1806
+ shouldSuppressReconnectRecovery() {
1807
+ return this.stopped || Boolean(this.remoteStopInfo);
1808
+ }
1809
+
1647
1810
  getRemoteStopSummary() {
1648
1811
  if (!this.remoteStopInfo) {
1649
1812
  return null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.31",
4
- "gitCommitId": "7e0bd83",
3
+ "version": "0.2.33",
4
+ "gitCommitId": "db7f9bf",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -18,8 +18,8 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@love-moon/ai-bridge": "0.1.4",
21
- "@love-moon/ai-sdk": "0.2.31",
22
- "@love-moon/conductor-sdk": "0.2.31",
21
+ "@love-moon/ai-sdk": "0.2.33",
22
+ "@love-moon/conductor-sdk": "0.2.33",
23
23
  "chrome-launcher": "^1.2.1",
24
24
  "chrome-remote-interface": "^0.33.0",
25
25
  "dotenv": "^16.4.5",