@love-moon/conductor-cli 0.8.0 → 0.10.0

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.
@@ -5,7 +5,7 @@
5
5
  import { AiManager } from "@love-moon/ai-sdk";
6
6
 
7
7
  const VALID_ACTIONS = new Set(["status", "quota", "list_accounts", "switch_account"]);
8
- const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot"];
8
+ const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot", "dsh"];
9
9
 
10
10
  /**
11
11
  * @param {object} opts
@@ -23,7 +23,7 @@ export function createAiManagerHandlers(opts = {}) {
23
23
  manager.getCurrentCodexAccount().catch(() => null),
24
24
  ]);
25
25
  const network = {};
26
- const tools = ["codex", "claude", "kimi", "copilot"];
26
+ const tools = [...BASE_QUOTA_TOOLS];
27
27
  await Promise.all(
28
28
  tools.map(async (tool) => {
29
29
  if (install[tool]?.installed) {
@@ -70,6 +70,9 @@ export function createAiManagerHandlers(opts = {}) {
70
70
  addJob("copilot", () => manager.getCopilotQuota({
71
71
  forceRefresh,
72
72
  }));
73
+ addJob("dsh", () => manager.getDshQuota({
74
+ forceRefresh,
75
+ }));
73
76
  const externalBackends = pickExternalQuotaBackends(args);
74
77
  for (const backend of externalBackends) {
75
78
  if (!tools.has(backend)) {
package/src/daemon.js CHANGED
@@ -30,6 +30,11 @@ import {
30
30
  createCustomCommandHandlers,
31
31
  handleCustomCommandsRequest,
32
32
  } from "./custom-command-handlers.js";
33
+ import {
34
+ REMOTE_EXEC_CAPABILITY,
35
+ createRemoteExecHandlers,
36
+ handleRemoteExecRequest,
37
+ } from "./remote-exec-handlers.js";
33
38
  import { resolveResumeContext } from "./fire/resume.js";
34
39
  import {
35
40
  filterRuntimeSupportedAllowCliList,
@@ -213,6 +218,32 @@ function getFireTmuxModeEnabled(userConfig) {
213
218
  return false;
214
219
  }
215
220
 
221
+ // Whether this host will accept `remote-exec`. Unlike `pty_task` (gated on a
222
+ // node-pty probe) and `custom_commands` (opt-in per script), remote exec would
223
+ // otherwise be unconditional, leaving a shared CI box or a root-owned daemon no
224
+ // way to decline. Defaults to enabled to match the other daemon capabilities.
225
+ //
226
+ // Resolution order:
227
+ // 1. CONDUCTOR_REMOTE_EXEC env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
228
+ // 2. remote_exec boolean in the resolved Conductor config.yaml
229
+ // 3. Default: true
230
+ function getRemoteExecEnabled(userConfig) {
231
+ const rawEnv = process.env.CONDUCTOR_REMOTE_EXEC;
232
+ if (typeof rawEnv === "string" && rawEnv.trim()) {
233
+ const normalized = rawEnv.trim().toLowerCase();
234
+ if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
235
+ return true;
236
+ }
237
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
238
+ return false;
239
+ }
240
+ }
241
+ if (userConfig && typeof userConfig === "object" && userConfig.remote_exec === false) {
242
+ return false;
243
+ }
244
+ return true;
245
+ }
246
+
216
247
  function normalizePlanLimitType(limitType) {
217
248
  if (typeof limitType !== "string") {
218
249
  return null;
@@ -322,6 +353,57 @@ function serializeRuntimeBackendMap(runtimeBackendMap) {
322
353
  .join(",");
323
354
  }
324
355
 
356
+ // Backends whose install/auth health can be probed via the AI manager. Others
357
+ // (external providers) are left out of the advertised health map so the web
358
+ // runtime preflight fails open for them.
359
+ const RUNTIME_HEALTH_TOOLS = new Set(["codex", "claude", "copilot", "kimi", "dsh"]);
360
+
361
+ function runtimeHealthToolForBackend(backend, runtimeBackendMap) {
362
+ const normalized = String(backend || "").trim().toLowerCase();
363
+ if (RUNTIME_HEALTH_TOOLS.has(normalized)) {
364
+ return normalized;
365
+ }
366
+ const runtime = String(runtimeBackendMap?.[normalized] || "").trim().toLowerCase();
367
+ return RUNTIME_HEALTH_TOOLS.has(runtime) ? runtime : null;
368
+ }
369
+
370
+ function serializeRuntimeHealth(runtimeHealth) {
371
+ if (!runtimeHealth || typeof runtimeHealth !== "object") {
372
+ return "";
373
+ }
374
+ return Object.entries(runtimeHealth)
375
+ .filter(([backend, state]) => backend && state)
376
+ .sort(([left], [right]) => left.localeCompare(right))
377
+ .map(([backend, state]) => `${backend}=${state}`)
378
+ .join(",");
379
+ }
380
+
381
+ // Best-effort per-backend runtime health for the web runtime preflight. Only a
382
+ // cheap install probe (which/--version) runs, once at startup. We advertise
383
+ // only a POSITIVE `ready` confirmation: a supported backend whose default-name
384
+ // `which` probe fails is very likely resolved via a custom or absolute path
385
+ // (allow_cli_list, or a systemd PATH that differs from the login shell) and is
386
+ // still runnable, so reporting it as "missing" would be an unreliable false
387
+ // negative that could wrongly block task creation. Omitting it lets the web
388
+ // preflight fail open for that backend.
389
+ async function computeAdvertisedRuntimeHealth(manager, supportedBackends, runtimeBackendMap) {
390
+ if (!manager || typeof manager.checkInstallAll !== "function") {
391
+ return {};
392
+ }
393
+ const install = await manager.checkInstallAll();
394
+ const runtimeHealth = {};
395
+ for (const backend of supportedBackends || []) {
396
+ const tool = runtimeHealthToolForBackend(backend, runtimeBackendMap);
397
+ if (!tool) {
398
+ continue;
399
+ }
400
+ if (install?.[tool]?.installed) {
401
+ runtimeHealth[String(backend).trim().toLowerCase()] = "ready";
402
+ }
403
+ }
404
+ return runtimeHealth;
405
+ }
406
+
325
407
  async function defaultCreatePty(command, args, options) {
326
408
  if (!nodePtySpawnPromise) {
327
409
  const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
@@ -799,6 +881,7 @@ export function startDaemon(config = {}, deps = {}) {
799
881
  // warning and silently fall back to direct spawn rather than failing every
800
882
  // create_task with ENOENT.
801
883
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
884
+ const remoteExecEnabled = getRemoteExecEnabled(userConfig);
802
885
 
803
886
  // Get allow_cli_list from config
804
887
  const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
@@ -2196,11 +2279,19 @@ export function startDaemon(config = {}, deps = {}) {
2196
2279
 
2197
2280
  if (linkStat) {
2198
2281
  if (!linkStat.isSymbolicLink()) {
2199
- throw new Error(
2200
- `worktree symlink destination already exists and is not a symlink: ${linkPath}. ` +
2201
- `Refusing to replace it because it may hold real data — remove it manually, ` +
2202
- `or drop "${configuredPath}" from worktree.symlink in .conductor/settings.yaml.`,
2282
+ // A real file/dir at the destination means something materialised
2283
+ // data inside the worktree (e.g. `pnpm install` replaced the
2284
+ // node_modules link with a real directory). It may hold real data,
2285
+ // so never clobber it — but throwing here made every task in this
2286
+ // worktree permanently un-restartable. Keep the local copy and skip
2287
+ // the link; remove the path manually (or drop the entry from
2288
+ // worktree.symlink) to restore sharing with the project workspace.
2289
+ logError(
2290
+ `[worktree] skipping symlink for ${configuredPath}: destination already exists and ` +
2291
+ `is not a symlink: ${linkPath}. Keeping the local copy — remove it manually to ` +
2292
+ `restore sharing via worktree.symlink in .conductor/settings.yaml.`,
2203
2293
  );
2294
+ continue;
2204
2295
  }
2205
2296
  // Compare the link's TARGET, not whether that target resolves. A link
2206
2297
  // that already points at the right place is correct even when the
@@ -2934,8 +3025,14 @@ export function startDaemon(config = {}, deps = {}) {
2934
3025
  "project_agents_registry",
2935
3026
  "restart_daemon",
2936
3027
  "refresh_session_inplace",
3028
+ "task_attachments_v1",
2937
3029
  CUSTOM_COMMANDS_CAPABILITY,
2938
3030
  ];
3031
+ if (remoteExecEnabled) {
3032
+ advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
3033
+ } else {
3034
+ log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3035
+ }
2939
3036
  if (ptyTaskCapabilityEnabled) {
2940
3037
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
2941
3038
  }
@@ -2944,6 +3041,9 @@ export function startDaemon(config = {}, deps = {}) {
2944
3041
  }
2945
3042
  const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
2946
3043
  const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
3044
+ const remoteExecHandlers = remoteExecEnabled
3045
+ ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3046
+ : null;
2947
3047
 
2948
3048
  const client = createWebSocketClient(sdkConfig, {
2949
3049
  extraHeaders,
@@ -3036,6 +3136,28 @@ export function startDaemon(config = {}, deps = {}) {
3036
3136
  if (typeof client?.setExtraHeaders === "function") {
3037
3137
  client.setExtraHeaders(extraHeaders);
3038
3138
  }
3139
+
3140
+ // Advertise best-effort runtime health so the backend can reject a task
3141
+ // whose backend is configured but cannot start (CLI missing/not signed in)
3142
+ // before creating any timeline activity. Fully additive and guarded: any
3143
+ // failure just omits the header and the preflight fails open.
3144
+ try {
3145
+ const runtimeHealth = await computeAdvertisedRuntimeHealth(
3146
+ aiManagerHandlers?.manager,
3147
+ SUPPORTED_BACKENDS,
3148
+ SUPPORTED_BACKEND_RUNTIME_MAP,
3149
+ );
3150
+ const serializedRuntimeHealth = serializeRuntimeHealth(runtimeHealth);
3151
+ if (serializedRuntimeHealth) {
3152
+ extraHeaders["x-conductor-runtime-health"] = serializedRuntimeHealth;
3153
+ if (typeof client?.setExtraHeaders === "function") {
3154
+ client.setExtraHeaders(extraHeaders);
3155
+ }
3156
+ }
3157
+ } catch (error) {
3158
+ logError(`Failed to probe runtime health: ${error?.message || error}`);
3159
+ }
3160
+
3039
3161
  if (daemonShuttingDown) {
3040
3162
  return;
3041
3163
  }
@@ -5171,6 +5293,40 @@ export function startDaemon(config = {}, deps = {}) {
5171
5293
  logError(`Unhandled custom_commands_request failure: ${error?.message || error}`);
5172
5294
  });
5173
5295
  }
5296
+ if (event.type === "remote_exec_request") {
5297
+ // Remote exec is the only execution path that leaves no Task row and no
5298
+ // per-run log file, so record it here — otherwise a run is invisible
5299
+ // once the daemon restarts. argv is deliberately omitted: it routinely
5300
+ // carries secrets and this log is collected by `collect_logs`.
5301
+ const execArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
5302
+ log(
5303
+ `[remote-exec] ${event?.payload?.action || "?"} command=${execArgs.command || ""} ` +
5304
+ `argc=${Array.isArray(execArgs.args) ? execArgs.args.length : 0} cwd=${execArgs.workspace || "<default>"}`,
5305
+ );
5306
+ // Fail fast rather than letting the caller wait out its full timeout —
5307
+ // it has no other way to learn that this daemon will never answer.
5308
+ const rejectReason = !remoteExecHandlers
5309
+ ? "remote exec is disabled on this daemon (remote_exec: false)"
5310
+ : daemonShuttingDown
5311
+ ? "daemon is shutting down"
5312
+ : "";
5313
+ if (rejectReason) {
5314
+ void client
5315
+ .sendJson({
5316
+ type: "remote_exec_response",
5317
+ payload: {
5318
+ request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
5319
+ action: event?.payload?.action ? String(event.payload.action) : "",
5320
+ error: rejectReason,
5321
+ },
5322
+ })
5323
+ .catch(() => {});
5324
+ return;
5325
+ }
5326
+ handleRemoteExecRequest(client, remoteExecHandlers, event.payload).catch((error) => {
5327
+ logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
5328
+ });
5329
+ }
5174
5330
  if (event.type === "restart_daemon") {
5175
5331
  void handleRestartDaemon(event.payload).catch((error) => {
5176
5332
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
@@ -6218,6 +6374,10 @@ export function startDaemon(config = {}, deps = {}) {
6218
6374
  PWD: taskDir,
6219
6375
  CONDUCTOR_PROJECT_ID: projectId,
6220
6376
  CONDUCTOR_TASK_ID: taskId,
6377
+ // Fire derives its own stable host identity from the owning daemon plus
6378
+ // the task. Passing the resolved name keeps that identity meaningful
6379
+ // even when the daemon was named through the config file.
6380
+ CONDUCTOR_DAEMON_NAME: AGENT_NAME,
6221
6381
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
6222
6382
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
6223
6383
  };
@@ -6732,7 +6892,11 @@ export function startDaemon(config = {}, deps = {}) {
6732
6892
  resolvedResumeCwd = await resolveRestartCwd({
6733
6893
  taskId: normalizedTargetTaskId,
6734
6894
  projectId: normalizedProjectId,
6735
- backendType: effectiveBackend,
6895
+ // A fork starts a fresh target-backend session, but its workspace
6896
+ // still belongs to the source task. Resolve the source session in
6897
+ // its own provider namespace; target + source session id is not a
6898
+ // meaningful pair for cross-backend handoff.
6899
+ backendType: sourceBackendType,
6736
6900
  launchConfig: targetLaunchConfig,
6737
6901
  sessionId: normalizedSourceSessionId,
6738
6902
  sourceSessionFilePath: sourceSessionFilePath ? String(sourceSessionFilePath) : "",
@@ -6920,6 +7084,7 @@ export function startDaemon(config = {}, deps = {}) {
6920
7084
  PWD: taskDir,
6921
7085
  CONDUCTOR_PROJECT_ID: normalizedProjectId,
6922
7086
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
7087
+ CONDUCTOR_DAEMON_NAME: AGENT_NAME,
6923
7088
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
6924
7089
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
6925
7090
  };