@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.
- package/CHANGELOG.md +44 -0
- package/bin/conductor-config.js +34 -0
- package/bin/conductor-diagnose.js +3 -1
- package/bin/conductor-fire.js +41 -2
- package/bin/conductor-remote-exec.js +365 -0
- package/bin/conductor-update.js +13 -0
- package/bin/conductor.js +5 -0
- package/package.json +5 -5
- package/src/ai-manager-handlers.js +5 -2
- package/src/daemon.js +170 -5
- package/src/remote-exec-handlers.js +421 -0
- package/src/runtime-backends.js +69 -7
- package/src/version-check.js +23 -0
|
@@ -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 = [
|
|
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
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
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
|
};
|