@love-moon/conductor-cli 0.2.32 → 0.2.34

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";
@@ -99,37 +100,46 @@ async function loadAllowCliList(configFilePath) {
99
100
  return {};
100
101
  }
101
102
 
102
- export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env) {
103
+ export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
103
104
  const normalizedBackend = normalizeRuntimeBackendName(backend);
105
+ const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
104
106
  const envKeyByBackend = {
107
+ codex: "CONDUCTOR_CODEX_APP_SERVER_COMMAND",
105
108
  opencode: "CONDUCTOR_OPENCODE_COMMAND",
106
109
  kimi: "CONDUCTOR_KIMI_COMMAND",
107
110
  };
108
- const envKey = envKeyByBackend[normalizedBackend];
109
- if (!envKey) {
110
- return "";
111
- }
111
+ const envKey = envKeyByBackend[normalizedSessionBackend];
112
112
 
113
- const preferredEnvCommand = typeof env?.[envKey] === "string" ? env[envKey].trim() : "";
113
+ const preferredEnvCommand = envKey && typeof env?.[envKey] === "string" ? env[envKey].trim() : "";
114
114
  if (preferredEnvCommand) {
115
115
  return preferredEnvCommand;
116
116
  }
117
117
 
118
118
  const configuredCommand =
119
- allowCliList && typeof allowCliList === "object" && typeof allowCliList[normalizedBackend] === "string"
120
- ? 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
+ : ""
121
125
  : "";
122
- if (configuredCommand) {
123
- return configuredCommand;
124
- }
125
126
 
126
127
  const daemonCommand =
127
128
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
128
- if (daemonCommand) {
129
- return daemonCommand;
129
+
130
+ const resolvedCommand = configuredCommand || daemonCommand;
131
+ if (!resolvedCommand) {
132
+ return "";
130
133
  }
131
134
 
132
- 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;
133
143
  }
134
144
 
135
145
  const DEFAULT_POLL_INTERVAL_MS = parseInt(
@@ -443,12 +453,15 @@ async function main() {
443
453
  }
444
454
 
445
455
  const allowCliList = await loadAllowCliList(cliArgs.configFile);
446
- const supportedBackends = Object.keys(allowCliList);
447
- const discoveredBackends = await listRuntimeSupportedBackends({ configFilePath: cliArgs.configFile });
448
- const externalBackends = discoveredBackends.filter((backend) => !RUNTIME_SUPPORTED_BACKENDS.includes(backend));
456
+ const { supportedBackends, externalBackends, discoveryError } = await listAdvertisedBackends(allowCliList, {
457
+ configFilePath: cliArgs.configFile,
458
+ });
449
459
 
450
460
  if (cliArgs.listBackends) {
451
461
  if (supportedBackends.length === 0 && externalBackends.length === 0) {
462
+ if (discoveryError) {
463
+ throw discoveryError;
464
+ }
452
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`);
453
466
  } else {
454
467
  if (supportedBackends.length > 0) {
@@ -471,7 +484,7 @@ async function main() {
471
484
  let resumeContext = null;
472
485
  if (cliArgs.resumeSessionId) {
473
486
  const bootstrap = await bootstrapResumeContextForFire({
474
- backend: cliArgs.backend,
487
+ backend: cliArgs.sessionBackend || cliArgs.backend,
475
488
  configFile: cliArgs.configFile,
476
489
  resumeSessionId: cliArgs.resumeSessionId,
477
490
  });
@@ -611,6 +624,7 @@ async function main() {
611
624
  requestedTitle: requestedTaskTitle,
612
625
  backend: cliArgs.backend,
613
626
  daemonName: configuredDaemonName,
627
+ projectPath: runtimeProjectPath,
614
628
  });
615
629
  injectResolvedTaskId(taskContext.taskId);
616
630
  injectResolvedTaskId(taskContext.taskId, env);
@@ -641,9 +655,14 @@ async function main() {
641
655
 
642
656
  const resolvedResumeSessionId = cliArgs.resumeSessionId;
643
657
 
644
- const sessionCommandLine = resolveAiSessionCommandLine(cliArgs.backend, allowCliList, process.env);
658
+ const sessionCommandLine = resolveAiSessionCommandLine(
659
+ cliArgs.backend,
660
+ allowCliList,
661
+ process.env,
662
+ cliArgs.sessionBackend,
663
+ );
645
664
 
646
- backendSession = createAiSession(cliArgs.backend, {
665
+ backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
647
666
  initialImages: cliArgs.initialImages,
648
667
  cwd: runtimeProjectPath,
649
668
  resumeSessionId: resolvedResumeSessionId,
@@ -728,8 +747,8 @@ async function main() {
728
747
 
729
748
  let runnerError = null;
730
749
  try {
731
- if (!resolvedResumeSessionId && String(cliArgs.backend).trim().toLowerCase() === "codex") {
732
- 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 () => {
733
752
  await runner.announceBackendSession();
734
753
  });
735
754
  }
@@ -741,7 +760,13 @@ async function main() {
741
760
  process.off("SIGINT", onSigint);
742
761
  process.off("SIGTERM", onSigterm);
743
762
  if (!launchedByDaemon) {
763
+ const remoteStopReason = typeof runner.getRemoteStopReason === "function" ? runner.getRemoteStopReason() : null;
744
764
  const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
765
+ // When the task was deleted by the user, the DB record is already gone —
766
+ // attempting to send a final status update would fail with 500 and the
767
+ // SDK durable outbox would retry forever, preventing the process from
768
+ // exiting.
769
+ const taskDeletedByUser = remoteStopReason === "deleted_by_user";
745
770
  const finalStatus = shutdownSignal
746
771
  ? {
747
772
  status: "KILLED",
@@ -761,16 +786,25 @@ async function main() {
761
786
  status: "COMPLETED",
762
787
  summary: "conductor fire exited",
763
788
  };
764
- try {
765
- const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
766
- if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
767
- await conductor.flushPendingUpstreamEvents({
768
- timeoutMs: 5_000,
769
- retryIntervalMs: 250,
770
- });
789
+ if (!taskDeletedByUser) {
790
+ try {
791
+ const statusResult = await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
792
+ if (statusResult?.pending && typeof conductor.flushPendingUpstreamEvents === "function") {
793
+ await conductor.flushPendingUpstreamEvents({
794
+ timeoutMs: 5_000,
795
+ retryIntervalMs: 250,
796
+ });
797
+ }
798
+ } catch (error) {
799
+ log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
800
+ }
801
+ } else {
802
+ log(`Skipping final status report: task was deleted by user`);
803
+ // Also clear any pending durable outbox retries (e.g. task_stop_ack)
804
+ // that would keep failing against the deleted task.
805
+ if (typeof conductor.clearDurableOutboxTimer === "function") {
806
+ conductor.clearDurableOutboxTimer();
771
807
  }
772
- } catch (error) {
773
- log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
774
808
  }
775
809
  }
776
810
  if (shutdownSignal === "SIGINT") {
@@ -890,9 +924,9 @@ export async function parseCliArgs(argvInput = process.argv) {
890
924
 
891
925
  const configFileFromArgs = extractConfigFileFromArgv(argv);
892
926
  const allowCliList = await loadAllowCliList(configFileFromArgs);
893
- const supportedBackends = Object.keys(allowCliList);
894
- const discoveredBackends = await listRuntimeSupportedBackends({ configFilePath: configFileFromArgs });
895
- const externalBackends = discoveredBackends.filter((backend) => !RUNTIME_SUPPORTED_BACKENDS.includes(backend));
927
+ const { supportedBackends, externalBackends, discoveryError } = await listAdvertisedBackends(allowCliList, {
928
+ configFilePath: configFileFromArgs,
929
+ });
896
930
 
897
931
  const conductorArgs = yargs(conductorArgv)
898
932
  .scriptName(CLI_NAME)
@@ -994,21 +1028,36 @@ Environment:
994
1028
  })
995
1029
  .parse();
996
1030
 
997
- const backend = conductorArgs.backend
998
- ? await normalizeRuntimeBackendAlias(conductorArgs.backend, { configFilePath: configFileFromArgs })
1031
+ const requestedBackend = conductorArgs.backend
1032
+ ? normalizeRuntimeBackendName(conductorArgs.backend)
999
1033
  : supportedBackends[0] || externalBackends[0];
1034
+ const configuredBackend = await resolveConfiguredRuntimeBackend(requestedBackend, allowCliList, {
1035
+ configFilePath: configFileFromArgs,
1036
+ });
1037
+ const backend = configuredBackend?.requestedBackend || requestedBackend;
1038
+ const sessionBackend =
1039
+ configuredBackend?.runtimeBackend ||
1040
+ (backend ? await normalizeRuntimeBackendAlias(backend, { configFilePath: configFileFromArgs }) : "");
1000
1041
  const shouldRequireBackend =
1001
1042
  !Boolean(conductorArgs.listBackends) &&
1002
1043
  !listBackendsWithoutSeparator &&
1003
1044
  !Boolean(conductorArgs.version) &&
1004
1045
  !versionWithoutSeparator;
1005
- const runtimeSupportedBackends = new Set(discoveredBackends);
1006
- if (backend && !runtimeSupportedBackends.has(backend) && shouldRequireBackend) {
1046
+ const runtimeSupportedBackends = new Set(supportedBackends);
1047
+ const advertisedExternalBackends = new Set(externalBackends);
1048
+ const hasConfiguredEntry = Boolean(configuredBackend?.commandLine);
1049
+ const isAllowedExternalBackend =
1050
+ !isBuiltInRuntimeBackend(sessionBackend) &&
1051
+ advertisedExternalBackends.has(sessionBackend);
1052
+ if (backend && shouldRequireBackend && !hasConfiguredEntry && !isAllowedExternalBackend) {
1007
1053
  throw new Error(
1008
1054
  `Unsupported backend "${backend}". Supported backends: ${[...runtimeSupportedBackends].join(", ") || "none configured"}.`,
1009
1055
  );
1010
1056
  }
1011
1057
  if (!backend && shouldRequireBackend) {
1058
+ if (discoveryError) {
1059
+ throw discoveryError;
1060
+ }
1012
1061
  throw new Error("No supported backends configured. Add allow_cli_list entries or set AISDK_PROVIDER_PATH for external providers.");
1013
1062
  }
1014
1063
 
@@ -1036,6 +1085,7 @@ Environment:
1036
1085
  taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
1037
1086
  hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
1038
1087
  configFile: conductorArgs.configFile,
1088
+ sessionBackend,
1039
1089
  resumeSessionId,
1040
1090
  showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
1041
1091
  listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
@@ -1152,7 +1202,10 @@ async function ensureTaskContext(conductor, opts) {
1152
1202
  };
1153
1203
  }
1154
1204
 
1155
- const projectId = await resolveProjectId(conductor, opts.requestedProjectId);
1205
+ const projectId = await resolveProjectId(conductor, opts.requestedProjectId, {
1206
+ daemonName: opts.daemonName,
1207
+ projectPath: opts.projectPath,
1208
+ });
1156
1209
  const payload = {
1157
1210
  project_id: projectId,
1158
1211
  task_title: deriveTaskTitle(opts.initialPrompt, opts.requestedTitle, opts.backend),
@@ -1167,15 +1220,6 @@ async function ensureTaskContext(conductor, opts) {
1167
1220
 
1168
1221
  const session = await conductor.createTaskSession(payload);
1169
1222
 
1170
- // Auto-bind current path to project if not already bound
1171
- try {
1172
- await conductor.bindProjectPath(projectId);
1173
- log(`Bound current path to project ${projectId}`);
1174
- } catch (error) {
1175
- // Ignore binding errors - it's not critical
1176
- log(`Note: Could not bind path to project: ${error.message}`);
1177
- }
1178
-
1179
1223
  return {
1180
1224
  taskId: session.task_id,
1181
1225
  appUrl: session.app_url || null,
@@ -1184,51 +1228,123 @@ async function ensureTaskContext(conductor, opts) {
1184
1228
  };
1185
1229
  }
1186
1230
 
1187
- async function resolveProjectId(conductor, explicit) {
1231
+ export async function resolveProjectId(conductor, explicit, opts = {}) {
1188
1232
  if (explicit) {
1189
1233
  return explicit;
1190
1234
  }
1191
1235
 
1192
- // First, try to match project by current path
1236
+ const daemonHost = resolveDaemonHost(opts.daemonName);
1237
+ const projectPath = typeof opts.projectPath === "string" && opts.projectPath.trim() ? opts.projectPath.trim() : process.cwd();
1238
+
1239
+ if (!daemonHost) {
1240
+ return resolveDefaultProjectId(conductor);
1241
+ }
1242
+
1243
+ const exists = await isExistingDirectory(projectPath);
1244
+ if (!exists) {
1245
+ throw new Error(`Workspace path does not exist: ${projectPath}`);
1246
+ }
1247
+
1248
+ const snapshot = resolveWorkspaceSnapshot(projectPath);
1249
+ const projectName = deriveProjectName(snapshot);
1250
+
1193
1251
  try {
1194
- const matchResult = await conductor.matchProjectByPath();
1252
+ const matchResult = await conductor.matchProjectByPath({
1253
+ daemon_host: daemonHost,
1254
+ project_path: snapshot.projectRoot,
1255
+ });
1195
1256
  if (matchResult?.project_id) {
1196
1257
  log(`Matched project ${matchResult.project_name || matchResult.project_id} by path ${matchResult.matched_path}`);
1197
- return matchResult.project_id;
1258
+ let resolvedProjectId = matchResult.project_id;
1259
+ try {
1260
+ const bindResult = await conductor.bindProjectPath(matchResult.project_id, {
1261
+ daemon_host: daemonHost,
1262
+ project_path: snapshot.projectRoot,
1263
+ });
1264
+ if (typeof bindResult?.project_id === "string" && bindResult.project_id.trim()) {
1265
+ resolvedProjectId = bindResult.project_id.trim();
1266
+ }
1267
+ } catch (error) {
1268
+ log(`Unable to backfill bound workspace path: ${error.message}`);
1269
+ try {
1270
+ const rebound = await conductor.matchProjectByPath({
1271
+ daemon_host: daemonHost,
1272
+ project_path: snapshot.projectRoot,
1273
+ });
1274
+ if (rebound?.project_id) {
1275
+ resolvedProjectId = rebound.project_id;
1276
+ }
1277
+ } catch {
1278
+ // ignore retry match failures
1279
+ }
1280
+ }
1281
+ return resolvedProjectId;
1198
1282
  }
1199
1283
  } catch (error) {
1200
1284
  log(`Unable to match project by path: ${error.message}`);
1201
1285
  }
1202
1286
 
1287
+ log(`No matching project found for ${daemonHost}:${snapshot.projectRoot}, falling back to default`);
1288
+ return resolveDefaultProjectId(conductor);
1289
+ }
1290
+
1291
+ function resolveDaemonHost(daemonName) {
1292
+ if (typeof daemonName === "string" && daemonName.trim()) {
1293
+ return daemonName.trim();
1294
+ }
1295
+ const fromEnv = typeof process.env.CONDUCTOR_DAEMON_NAME === "string" ? process.env.CONDUCTOR_DAEMON_NAME.trim() : "";
1296
+ if (fromEnv) {
1297
+ return fromEnv;
1298
+ }
1299
+ const fromAgent = typeof process.env.CONDUCTOR_AGENT_NAME === "string" ? process.env.CONDUCTOR_AGENT_NAME.trim() : "";
1300
+ if (fromAgent) {
1301
+ return fromAgent;
1302
+ }
1203
1303
  try {
1204
- const record = await conductor.getLocalProjectRecord();
1205
- if (record?.project_id) {
1206
- try {
1207
- const listing = await conductor.listProjects();
1208
- const exists = Array.isArray(listing?.projects)
1209
- ? listing.projects.some((project) => String(project?.id || "") === String(record.project_id))
1210
- : false;
1211
- if (exists) {
1212
- return record.project_id;
1213
- }
1214
- log(`Local session project ${record.project_id} no longer exists; falling back to server project list`);
1215
- } catch (verifyError) {
1216
- log(`Unable to verify local project record; using cached project id: ${verifyError.message}`);
1217
- return record.project_id;
1218
- }
1219
- }
1220
- } catch (error) {
1221
- log(`Unable to resolve project via local session: ${error.message}`);
1304
+ return os.hostname();
1305
+ } catch {
1306
+ return "";
1222
1307
  }
1308
+ }
1309
+
1310
+ function resolveWorkspaceSnapshot(projectPath) {
1311
+ try {
1312
+ const context = new ProjectContext(projectPath);
1313
+ return context.snapshot();
1314
+ } catch {
1315
+ return {
1316
+ projectRoot: path.resolve(projectPath),
1317
+ };
1318
+ }
1319
+ }
1223
1320
 
1224
- const listing = await conductor.listProjects();
1225
- const first = listing?.projects?.[0];
1226
- if (first?.id) {
1227
- return first.id;
1321
+ function deriveProjectName(snapshot) {
1322
+ const basePath = snapshot.repoRoot || snapshot.projectRoot;
1323
+ const name = basePath ? path.basename(basePath) : "";
1324
+ const baseName = name || "New Project";
1325
+ const digest = createHash("sha1").update(basePath || baseName).digest("hex").slice(0, 8);
1326
+ return `${baseName}-${digest}`;
1327
+ }
1328
+
1329
+ async function resolveDefaultProjectId(conductor) {
1330
+ try {
1331
+ const listing = await conductor.listProjects();
1332
+ const defaultProject = Array.isArray(listing?.projects)
1333
+ ? listing.projects.find((project) => Boolean(project?.isDefault))
1334
+ : null;
1335
+ if (defaultProject?.id) {
1336
+ return defaultProject.id;
1337
+ }
1338
+ } catch {
1339
+ // ignore list failures
1228
1340
  }
1229
- log("No projects available; creating default project...");
1341
+
1342
+ log("No bound daemon available; creating default project...");
1230
1343
  try {
1231
- const created = await conductor.createProject("default", "Auto-created by conductor-fire");
1344
+ const created = await conductor.createProject({
1345
+ name: "Default Project",
1346
+ isDefault: true,
1347
+ });
1232
1348
  if (created?.id) {
1233
1349
  log(`Created default project ${created.id}`);
1234
1350
  return created.id;
@@ -1674,6 +1790,10 @@ export class BridgeRunner {
1674
1790
  return this.stopped || Boolean(this.remoteStopInfo);
1675
1791
  }
1676
1792
 
1793
+ getRemoteStopReason() {
1794
+ return this.remoteStopInfo?.reason || null;
1795
+ }
1796
+
1677
1797
  getRemoteStopSummary() {
1678
1798
  if (!this.remoteStopInfo) {
1679
1799
  return null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.32",
4
- "gitCommitId": "c749d4b",
3
+ "version": "0.2.34",
4
+ "gitCommitId": "c3c936c",
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.32",
22
- "@love-moon/conductor-sdk": "0.2.32",
21
+ "@love-moon/ai-sdk": "0.2.34",
22
+ "@love-moon/conductor-sdk": "0.2.34",
23
23
  "chrome-launcher": "^1.2.1",
24
24
  "chrome-remote-interface": "^0.33.0",
25
25
  "dotenv": "^16.4.5",