@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 43c4f87: Runtime health preflight and agent schedule access control. The daemon now
|
|
8
|
+
advertises positive backend runtime health (`x-conductor-runtime-health`) so the
|
|
9
|
+
backend can reject task creation with `503 runtime_unavailable` before any
|
|
10
|
+
timeline activity, and adds `disable_built_in_cli_list` to opt out of built-in
|
|
11
|
+
SDK backends. The SDK attributes agent-originated scheduled-message calls with
|
|
12
|
+
`X-Conductor-Actor: agent` so per-task `agent_schedule_access`
|
|
13
|
+
(full/read_only/blocked) can govern `conductor task schedule` from agents while
|
|
14
|
+
human/UI calls stay unrestricted.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- 43c4f87: Fix `conductor fire` and `conductor diagnose` with an explicit `--config-file`
|
|
19
|
+
resolving `agent_token`/`backend_url` from daemon-injected `CONDUCTOR_*` env
|
|
20
|
+
instead of the file. Fire could dial the file's websocket with the inherited
|
|
21
|
+
token and loop on `4002 invalid-token`; diagnose queried the wrong backend and
|
|
22
|
+
404'd. An explicit config file now wins over inherited env, matching the
|
|
23
|
+
daemon's behavior.
|
|
24
|
+
- 43c4f87: Fix `conductor update` installing into the wrong npm prefix.
|
|
25
|
+
- Updated dependencies [43c4f87]
|
|
26
|
+
- Updated dependencies [43c4f87]
|
|
27
|
+
- @love-moon/ai-sdk@0.10.0
|
|
28
|
+
- @love-moon/conductor-sdk@0.10.0
|
|
29
|
+
|
|
30
|
+
## 0.9.0
|
|
31
|
+
|
|
32
|
+
### Minor Changes
|
|
33
|
+
|
|
34
|
+
- 3a499cc: Add `conductor remote-exec` for running a single command on another daemon's
|
|
35
|
+
host, over a new `remote_exec_request`/`remote_exec_response` daemon protocol
|
|
36
|
+
pair gated by a `remote_exec` capability. Supports `--workspace`, `--env`,
|
|
37
|
+
`--timeout` with automatic polling for long commands, `--kill-on-timeout`, and
|
|
38
|
+
ssh-style exit codes. Hosts can decline with `remote_exec: false` in the config.
|
|
39
|
+
- a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [a15b55d]
|
|
44
|
+
- @love-moon/ai-sdk@0.9.0
|
|
45
|
+
- @love-moon/conductor-sdk@0.9.0
|
|
46
|
+
|
|
3
47
|
## 0.8.0
|
|
4
48
|
|
|
5
49
|
### Minor Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import readline from "node:readline/promises";
|
|
8
8
|
import { execFileSync, execSync } from "node:child_process";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import yargs from "yargs/yargs";
|
|
10
11
|
import { hideBin } from "yargs/helpers";
|
|
11
12
|
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
@@ -43,6 +44,11 @@ const DEFAULT_CLIs = {
|
|
|
43
44
|
execArgs: "",
|
|
44
45
|
description: "GitHub Copilot (built in via SDK)"
|
|
45
46
|
},
|
|
47
|
+
dsh: {
|
|
48
|
+
command: "dsh",
|
|
49
|
+
execArgs: "",
|
|
50
|
+
description: "DeepSeek Harness agent (built in via SDK; needs DEEPSEEK_API_KEY)"
|
|
51
|
+
},
|
|
46
52
|
// chat-web is the runtime backend (an in-process Chromium driver, not a CLI
|
|
47
53
|
// binary). It has multiple sub-providers selected via --model. Each user-
|
|
48
54
|
// facing alias below resolves to the chat-web runtime; advertising the bare
|
|
@@ -102,6 +108,20 @@ function isBuiltInChatWebAvailable() {
|
|
|
102
108
|
);
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
function isBuiltInDshAvailable() {
|
|
112
|
+
// The dsh runtime ships as pinned dependencies of @love-moon/ai-sdk (not of
|
|
113
|
+
// the CLI itself), so resolve it THROUGH ai-sdk's resolution context — a
|
|
114
|
+
// direct resolve from cli/bin fails under pnpm's isolated node_modules.
|
|
115
|
+
try {
|
|
116
|
+
const require = createRequire(import.meta.url);
|
|
117
|
+
const aiSdkEntry = require.resolve("@love-moon/ai-sdk");
|
|
118
|
+
createRequire(aiSdkEntry).resolve("@deepseek-ai/dsh-sdk-jsonrpc-demo/bin");
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
106
126
|
const fullCommand = info.execArgs
|
|
107
127
|
? `${info.command} ${info.execArgs}`
|
|
@@ -299,6 +319,16 @@ async function main() {
|
|
|
299
319
|
});
|
|
300
320
|
}
|
|
301
321
|
|
|
322
|
+
lines.push(
|
|
323
|
+
"",
|
|
324
|
+
"# Uncomment to disable built-in SDK backends that are otherwise advertised",
|
|
325
|
+
"# automatically (copilot, dsh). Listing a name here hides it from the",
|
|
326
|
+
"# daemon's supported backends without removing the shipped dependency.",
|
|
327
|
+
"# disable_built_in_cli_list:",
|
|
328
|
+
"# - dsh",
|
|
329
|
+
"# - copilot"
|
|
330
|
+
);
|
|
331
|
+
|
|
302
332
|
lines.push(
|
|
303
333
|
"",
|
|
304
334
|
"# Uncomment to use custom envs, such as proxy.",
|
|
@@ -335,6 +365,10 @@ function detectInstalledCLIs() {
|
|
|
335
365
|
detected.push(key);
|
|
336
366
|
continue;
|
|
337
367
|
}
|
|
368
|
+
if (runtimeBackend === "dsh" && isBuiltInDshAvailable()) {
|
|
369
|
+
detected.push(key);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
338
372
|
if (isCommandAvailable(info.command)) {
|
|
339
373
|
detected.push(key);
|
|
340
374
|
}
|
|
@@ -6,6 +6,8 @@ import { hideBin } from "yargs/helpers";
|
|
|
6
6
|
|
|
7
7
|
import { loadConfig } from "@love-moon/conductor-sdk";
|
|
8
8
|
|
|
9
|
+
import { envForExplicitConfigFile } from "../src/config-env.js";
|
|
10
|
+
|
|
9
11
|
const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor diagnose";
|
|
10
12
|
const DEFAULT_TIMEOUT_MS = 8000;
|
|
11
13
|
|
|
@@ -45,7 +47,7 @@ main().catch((error) => {
|
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
async function main() {
|
|
48
|
-
const config = loadConfig(args.configFile);
|
|
50
|
+
const config = loadConfig(args.configFile, { env: envForExplicitConfigFile(args.configFile) });
|
|
49
51
|
const timeoutMs = normalizePositiveInt(args.timeoutMs, DEFAULT_TIMEOUT_MS);
|
|
50
52
|
const baseUrl = String(config.backendUrl || "").replace(/\/+$/, "");
|
|
51
53
|
const endpoint = `${baseUrl}/api/diagnostics/tasks/${encodeURIComponent(taskId)}`;
|
package/bin/conductor-fire.js
CHANGED
|
@@ -583,6 +583,17 @@ export function createPendingRemoteInterruptQueue() {
|
|
|
583
583
|
async function main() {
|
|
584
584
|
syncPwdEnvWithProcessCwdForDaemonLaunch();
|
|
585
585
|
const cliArgs = await parseCliArgs();
|
|
586
|
+
if (cliArgs.configFile) {
|
|
587
|
+
// An explicit --config-file must win over CONDUCTOR_* backend/token env
|
|
588
|
+
// injected by an owning daemon or session (same convention as
|
|
589
|
+
// envForExplicitConfigFile); otherwise fire dials the file's websocket
|
|
590
|
+
// with the inherited token and loops on 4002 invalid-token.
|
|
591
|
+
delete process.env.CONDUCTOR_AGENT_TOKEN;
|
|
592
|
+
delete process.env.CONDUCTOR_BACKEND_URL;
|
|
593
|
+
delete process.env.CONDUCTOR_WS_URL;
|
|
594
|
+
delete process.env.CONDUCTOR_BACKEND_WS_URL;
|
|
595
|
+
process.env.CONDUCTOR_CONFIG = cliArgs.configFile;
|
|
596
|
+
}
|
|
586
597
|
let runtimeProjectPath = process.cwd();
|
|
587
598
|
let backendSession = null;
|
|
588
599
|
|
|
@@ -798,7 +809,7 @@ async function main() {
|
|
|
798
809
|
extraEnv: env,
|
|
799
810
|
extraHeaders: buildConductorConnectHeaders(pkgJson.version, {
|
|
800
811
|
backends: [cliArgs.backend],
|
|
801
|
-
capabilities: ["refresh_session_inplace"],
|
|
812
|
+
capabilities: ["refresh_session_inplace", "task_attachments_v1"],
|
|
802
813
|
}),
|
|
803
814
|
configFile: cliArgs.configFile,
|
|
804
815
|
onConnected: (event) => {
|
|
@@ -3069,7 +3080,13 @@ export class BridgeRunner {
|
|
|
3069
3080
|
}
|
|
3070
3081
|
|
|
3071
3082
|
async respondToMessage(message) {
|
|
3072
|
-
const
|
|
3083
|
+
const localAttachments = Array.isArray(message.attachments)
|
|
3084
|
+
? message.attachments.filter((attachment) =>
|
|
3085
|
+
attachment && typeof attachment === "object" && typeof (attachment.path || attachment.localPath) === "string"
|
|
3086
|
+
)
|
|
3087
|
+
: [];
|
|
3088
|
+
const rawContent = String(message.content || "").trim();
|
|
3089
|
+
const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
|
|
3073
3090
|
if (!content) {
|
|
3074
3091
|
this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
|
|
3075
3092
|
return;
|
|
@@ -3091,6 +3108,22 @@ export class BridgeRunner {
|
|
|
3091
3108
|
String(message.role || "").toLowerCase() === "user" &&
|
|
3092
3109
|
content === this.pendingInitialPrompt;
|
|
3093
3110
|
const useInitialImages = isQueuedInitialPromptMessage && this.includeInitialImages;
|
|
3111
|
+
const nativeImageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
3112
|
+
const media = localAttachments
|
|
3113
|
+
.filter((attachment) => attachment.kind === "image" && nativeImageMimes.has(String(attachment.mimeType || "").toLowerCase()))
|
|
3114
|
+
.map((attachment) => ({
|
|
3115
|
+
kind: "image",
|
|
3116
|
+
path: attachment.path || attachment.localPath,
|
|
3117
|
+
mimeType: attachment.mimeType,
|
|
3118
|
+
name: attachment.name,
|
|
3119
|
+
}));
|
|
3120
|
+
const contextFiles = localAttachments
|
|
3121
|
+
.filter((attachment) => !media.some((image) => image.path === (attachment.path || attachment.localPath)))
|
|
3122
|
+
.map((attachment) => ({
|
|
3123
|
+
path: attachment.path || attachment.localPath,
|
|
3124
|
+
mimeType: attachment.mimeType,
|
|
3125
|
+
name: attachment.name,
|
|
3126
|
+
}));
|
|
3094
3127
|
if (
|
|
3095
3128
|
this.useSessionFileReplyStream &&
|
|
3096
3129
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
@@ -3140,6 +3173,8 @@ export class BridgeRunner {
|
|
|
3140
3173
|
|
|
3141
3174
|
const turnPromise = this.dispatchBackendTurn(content, {
|
|
3142
3175
|
useInitialImages,
|
|
3176
|
+
media,
|
|
3177
|
+
contextFiles,
|
|
3143
3178
|
onProgress: (payload) => {
|
|
3144
3179
|
void this.reportRuntimeStatus(payload, replyTo);
|
|
3145
3180
|
},
|
|
@@ -3330,8 +3365,12 @@ export class BridgeRunner {
|
|
|
3330
3365
|
const goalCapable = Boolean(
|
|
3331
3366
|
snapshot && snapshot.capabilities && snapshot.capabilities.goal === true,
|
|
3332
3367
|
);
|
|
3368
|
+
const hasAttachmentInputs =
|
|
3369
|
+
(Array.isArray(options.media) && options.media.length > 0) ||
|
|
3370
|
+
(Array.isArray(options.contextFiles) && options.contextFiles.length > 0);
|
|
3333
3371
|
const willRunGoal =
|
|
3334
3372
|
goalDirective != null &&
|
|
3373
|
+
!hasAttachmentInputs &&
|
|
3335
3374
|
goalCapable &&
|
|
3336
3375
|
typeof this.backendSession?.runGoal === "function";
|
|
3337
3376
|
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor remote-exec — run one command on another daemon's host.
|
|
5
|
+
*
|
|
6
|
+
* conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
7
|
+
* conductor remote-exec --target ubuntu -- bash -lc "pnpm build 2>&1 | tail -20"
|
|
8
|
+
*
|
|
9
|
+
* The command is sent as argv and spawned without a shell on the target, so
|
|
10
|
+
* quoting is not re-interpreted remotely. Pass `-- bash -lc "..."` when pipes
|
|
11
|
+
* or globs are actually wanted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
import { ConductorConfig, loadConfig } from "@love-moon/conductor-sdk";
|
|
20
|
+
import { envForExplicitConfigFile } from "../src/config-env.js";
|
|
21
|
+
import { resolveConductorConfigPath } from "../src/conductor-paths.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Following ssh: the remote command's exit code is passed through verbatim
|
|
25
|
+
* (0-254) and 255 is reserved for this CLI's own failures. Reusing 1/2/4 for
|
|
26
|
+
* local errors would make `grep` finding nothing (1) or `ls` on a missing path
|
|
27
|
+
* (2) indistinguishable from a network error or a usage mistake.
|
|
28
|
+
*/
|
|
29
|
+
const EXIT = { OK: 0, CLI_ERROR: 255 };
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
31
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
32
|
+
/** How long a single request may block server-side before we switch to polling. */
|
|
33
|
+
const POST_WAIT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
const VALUE_FLAGS = new Map([
|
|
36
|
+
["--target", "target"],
|
|
37
|
+
["-t", "target"],
|
|
38
|
+
["--workspace", "workspace"],
|
|
39
|
+
["-w", "workspace"],
|
|
40
|
+
["--timeout", "timeout"],
|
|
41
|
+
["--config-file", "configFile"],
|
|
42
|
+
["--env", "env"],
|
|
43
|
+
["-e", "env"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const BOOL_FLAGS = new Map([
|
|
47
|
+
["--json", "json"],
|
|
48
|
+
["--kill-on-timeout", "killOnTimeout"],
|
|
49
|
+
["--help", "help"],
|
|
50
|
+
["-h", "help"],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const isMainModule = (() => {
|
|
54
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
55
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
56
|
+
return entryFile === currentFile;
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Split argv into flags and the remote command.
|
|
61
|
+
*
|
|
62
|
+
* Both `... --workspace /p ls .` and `... --workspace /p -- ls .` are accepted:
|
|
63
|
+
* the first token that is not a recognized flag starts the remote command, and
|
|
64
|
+
* everything after it is passed through verbatim.
|
|
65
|
+
*/
|
|
66
|
+
export function parseArgs(argv) {
|
|
67
|
+
const options = { env: {}, json: false, help: false };
|
|
68
|
+
const command = [];
|
|
69
|
+
|
|
70
|
+
let index = 0;
|
|
71
|
+
while (index < argv.length) {
|
|
72
|
+
const token = argv[index];
|
|
73
|
+
|
|
74
|
+
if (token === "--") {
|
|
75
|
+
command.push(...argv.slice(index + 1));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let name = token;
|
|
80
|
+
let inlineValue = null;
|
|
81
|
+
if (token.startsWith("--") && token.includes("=")) {
|
|
82
|
+
const splitAt = token.indexOf("=");
|
|
83
|
+
name = token.slice(0, splitAt);
|
|
84
|
+
inlineValue = token.slice(splitAt + 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (BOOL_FLAGS.has(name) && inlineValue === null) {
|
|
88
|
+
options[BOOL_FLAGS.get(name)] = true;
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (VALUE_FLAGS.has(name)) {
|
|
94
|
+
const key = VALUE_FLAGS.get(name);
|
|
95
|
+
const value = inlineValue !== null ? inlineValue : argv[index + 1];
|
|
96
|
+
if (value === undefined) {
|
|
97
|
+
throw new UsageError(`${name} requires a value`);
|
|
98
|
+
}
|
|
99
|
+
if (key === "env") {
|
|
100
|
+
const splitAt = value.indexOf("=");
|
|
101
|
+
if (splitAt <= 0) {
|
|
102
|
+
throw new UsageError(`--env expects KEY=VALUE, got: ${value}`);
|
|
103
|
+
}
|
|
104
|
+
options.env[value.slice(0, splitAt)] = value.slice(splitAt + 1);
|
|
105
|
+
} else {
|
|
106
|
+
options[key] = value;
|
|
107
|
+
}
|
|
108
|
+
index += inlineValue !== null ? 1 : 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
command.push(...argv.slice(index));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { options, command };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class UsageError extends Error {}
|
|
120
|
+
|
|
121
|
+
/** Accepts `500ms`, `30s`, `2m`, or a bare number of seconds. */
|
|
122
|
+
export function parseTimeoutMs(value) {
|
|
123
|
+
if (value === undefined || value === null || value === "") {
|
|
124
|
+
return DEFAULT_TIMEOUT_MS;
|
|
125
|
+
}
|
|
126
|
+
const raw = String(value).trim().toLowerCase();
|
|
127
|
+
const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
128
|
+
if (!match) {
|
|
129
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
130
|
+
}
|
|
131
|
+
const amount = Number.parseFloat(match[1]);
|
|
132
|
+
const unit = match[2] || "s";
|
|
133
|
+
const multiplier = unit === "ms" ? 1 : unit === "m" ? 60_000 : 1_000;
|
|
134
|
+
const ms = Math.round(amount * multiplier);
|
|
135
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
136
|
+
throw new UsageError(`invalid --timeout value: ${value}`);
|
|
137
|
+
}
|
|
138
|
+
return ms;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadCliConfig(configFile, env = process.env) {
|
|
142
|
+
const configPath = resolveConductorConfigPath(configFile, env);
|
|
143
|
+
const configEnv = envForExplicitConfigFile(configFile, env);
|
|
144
|
+
if (fs.existsSync(configPath)) {
|
|
145
|
+
return loadConfig(configPath, { env: configEnv });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const agentToken = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
149
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
150
|
+
if (agentToken && backendUrl) {
|
|
151
|
+
return new ConductorConfig({ agentToken, backendUrl });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return loadConfig(configPath, { env: configEnv });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function callApi(config, method, pathname, body, fetchImpl) {
|
|
158
|
+
const url = new URL(pathname, config.backendUrl);
|
|
159
|
+
const response = await fetchImpl(url.toString(), {
|
|
160
|
+
method,
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
163
|
+
Accept: "application/json",
|
|
164
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
165
|
+
},
|
|
166
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
let payload = null;
|
|
171
|
+
try {
|
|
172
|
+
payload = text ? JSON.parse(text) : null;
|
|
173
|
+
} catch {
|
|
174
|
+
payload = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const message = payload?.error || text.trim() || `HTTP ${response.status}`;
|
|
179
|
+
const error = new Error(message);
|
|
180
|
+
error.status = response.status;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
return payload;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
|
+
|
|
188
|
+
export function showHelp(consoleImpl = console) {
|
|
189
|
+
consoleImpl.log(`conductor remote-exec - run a command on another daemon's host
|
|
190
|
+
|
|
191
|
+
Usage:
|
|
192
|
+
conductor remote-exec --target <daemon> [options] <command> [args...]
|
|
193
|
+
conductor remote-exec --target <daemon> [options] -- <command> [args...]
|
|
194
|
+
|
|
195
|
+
Options:
|
|
196
|
+
-t, --target <daemon> Daemon name to run on (required)
|
|
197
|
+
-w, --workspace <path> Working directory on the target (default: target's home)
|
|
198
|
+
--timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
|
|
199
|
+
-e, --env KEY=VALUE Extra environment variable (repeatable)
|
|
200
|
+
--json Print the raw run result as JSON
|
|
201
|
+
--kill-on-timeout Stop the remote command when --timeout is reached
|
|
202
|
+
(default: it keeps running on the target)
|
|
203
|
+
--config-file <p> Conductor config file to authenticate with
|
|
204
|
+
-h, --help Show this help
|
|
205
|
+
|
|
206
|
+
Notes:
|
|
207
|
+
The command is spawned without a shell. For pipes, globs or redirection use:
|
|
208
|
+
conductor remote-exec -t ubuntu -- bash -lc "ls | wc -l"
|
|
209
|
+
|
|
210
|
+
Exit codes follow ssh: the remote command's own code is passed through, and
|
|
211
|
+
255 means this CLI failed (bad usage, daemon offline, network error).
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
215
|
+
conductor remote-exec -t ubuntu -w /srv/app -- git log --oneline -5
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function runRemoteExec(argv, deps = {}) {
|
|
220
|
+
const consoleImpl = deps.console || console;
|
|
221
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
222
|
+
const env = deps.env || process.env;
|
|
223
|
+
const sleep = deps.sleep || delay;
|
|
224
|
+
const now = deps.now || (() => Date.now());
|
|
225
|
+
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = parseArgs(argv);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
231
|
+
return EXIT.CLI_ERROR;
|
|
232
|
+
}
|
|
233
|
+
const { options, command } = parsed;
|
|
234
|
+
|
|
235
|
+
if (options.help) {
|
|
236
|
+
showHelp(consoleImpl);
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const target = typeof options.target === "string" ? options.target.trim() : "";
|
|
241
|
+
if (!target) {
|
|
242
|
+
consoleImpl.error("Error: --target <daemon> is required");
|
|
243
|
+
showHelp(consoleImpl);
|
|
244
|
+
return EXIT.CLI_ERROR;
|
|
245
|
+
}
|
|
246
|
+
if (command.length === 0) {
|
|
247
|
+
consoleImpl.error("Error: no command given");
|
|
248
|
+
showHelp(consoleImpl);
|
|
249
|
+
return EXIT.CLI_ERROR;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let timeoutMs;
|
|
253
|
+
try {
|
|
254
|
+
timeoutMs = parseTimeoutMs(options.timeout);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
257
|
+
return EXIT.CLI_ERROR;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let config;
|
|
261
|
+
try {
|
|
262
|
+
config = deps.config || loadCliConfig(options.configFile, env);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
265
|
+
return EXIT.CLI_ERROR;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
269
|
+
const deadline = now() + timeoutMs;
|
|
270
|
+
|
|
271
|
+
let run;
|
|
272
|
+
try {
|
|
273
|
+
run = await callApi(config, "POST", basePath, {
|
|
274
|
+
command: command[0],
|
|
275
|
+
args: command.slice(1),
|
|
276
|
+
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
277
|
+
...(Object.keys(options.env).length > 0 ? { env: options.env } : {}),
|
|
278
|
+
// Deliberately short, and independent of `--timeout`: the overall deadline
|
|
279
|
+
// is owned by the poll loop below. Handing the daemon the full deadline
|
|
280
|
+
// would make one HTTP request block for it, and would leave the loop
|
|
281
|
+
// unreachable because the POST alone would consume the whole budget.
|
|
282
|
+
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
283
|
+
}, fetchImpl);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
286
|
+
return EXIT.CLI_ERROR;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let pollError = null;
|
|
290
|
+
while (run?.status === "running" && now() < deadline) {
|
|
291
|
+
if (!run.runId) {
|
|
292
|
+
pollError = new Error("daemon reported a running command but returned no runId");
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
await sleep(POLL_INTERVAL_MS);
|
|
296
|
+
try {
|
|
297
|
+
run = await callApi(
|
|
298
|
+
config,
|
|
299
|
+
"GET",
|
|
300
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
301
|
+
null,
|
|
302
|
+
fetchImpl,
|
|
303
|
+
);
|
|
304
|
+
pollError = null;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// A saturated or briefly unreachable daemon can fail one status poll.
|
|
307
|
+
// Keep waiting until the caller's own deadline rather than aborting a
|
|
308
|
+
// long-running command that is still perfectly healthy.
|
|
309
|
+
pollError = error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (run?.status === "running" && options.killOnTimeout && run.runId) {
|
|
314
|
+
try {
|
|
315
|
+
run = await callApi(
|
|
316
|
+
config,
|
|
317
|
+
"DELETE",
|
|
318
|
+
`${basePath}/runs/${encodeURIComponent(run.runId)}`,
|
|
319
|
+
null,
|
|
320
|
+
fetchImpl,
|
|
321
|
+
);
|
|
322
|
+
consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (options.json) {
|
|
329
|
+
consoleImpl.log(JSON.stringify(run, null, 2));
|
|
330
|
+
} else {
|
|
331
|
+
if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
|
|
332
|
+
if (run?.stderrTail) process.stderr.write(run.stderrTail);
|
|
333
|
+
if (run?.truncated) {
|
|
334
|
+
consoleImpl.error(`[conductor] output truncated; showing the tail only`);
|
|
335
|
+
}
|
|
336
|
+
if (run?.error) {
|
|
337
|
+
consoleImpl.error(`Error: ${run.error}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (run?.status === "running") {
|
|
342
|
+
if (pollError) {
|
|
343
|
+
consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
|
|
344
|
+
}
|
|
345
|
+
consoleImpl.error(
|
|
346
|
+
`[conductor] still running on ${target} after ${timeoutMs}ms; ` +
|
|
347
|
+
`it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
|
|
348
|
+
`stop it with DELETE on the same path, or use --kill-on-timeout`,
|
|
349
|
+
);
|
|
350
|
+
return EXIT.CLI_ERROR;
|
|
351
|
+
}
|
|
352
|
+
if (run?.status === "cancelled") {
|
|
353
|
+
return EXIT.CLI_ERROR;
|
|
354
|
+
}
|
|
355
|
+
if (typeof run?.exitCode === "number") {
|
|
356
|
+
return run.exitCode;
|
|
357
|
+
}
|
|
358
|
+
return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (isMainModule) {
|
|
362
|
+
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
|
363
|
+
// and exiting outright truncates them. Let the loop drain and end naturally.
|
|
364
|
+
process.exitCode = await runRemoteExec(process.argv.slice(2));
|
|
365
|
+
}
|
package/bin/conductor-update.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
fetchLatestVersion,
|
|
18
18
|
isNewerVersion,
|
|
19
19
|
detectPackageManager,
|
|
20
|
+
resolveGlobalInstallPrefix,
|
|
20
21
|
} from "../src/version-check.js";
|
|
21
22
|
import {
|
|
22
23
|
buildPnpmAllowBuildArgs,
|
|
@@ -163,6 +164,18 @@ async function performUpdate() {
|
|
|
163
164
|
console.log(` Using package manager: ${colorize(packageManager, "cyan")}`);
|
|
164
165
|
console.log("");
|
|
165
166
|
|
|
167
|
+
// `npm install -g` targets whichever npm wins the PATH lookup, which is not necessarily the
|
|
168
|
+
// one that installed us -- a Conductor-managed Node, an nvm shim and a system npm all resolve
|
|
169
|
+
// to different prefixes. When they disagree the update lands in a tree nobody runs, so
|
|
170
|
+
// `conductor --version` keeps reporting the old build and the next update repeats the cycle.
|
|
171
|
+
// Pin every child command to the prefix the running package actually lives in.
|
|
172
|
+
const installPrefix = packageManager === "npm" ? resolveGlobalInstallPrefix(PKG_ROOT) : null;
|
|
173
|
+
if (installPrefix) {
|
|
174
|
+
process.env.npm_config_prefix = installPrefix;
|
|
175
|
+
console.log(` Install prefix: ${colorize(installPrefix, "cyan")}`);
|
|
176
|
+
console.log("");
|
|
177
|
+
}
|
|
178
|
+
|
|
166
179
|
if (packageManager === "pnpm") {
|
|
167
180
|
console.log(" Preparing pnpm native dependency allowlist...");
|
|
168
181
|
await ensurePnpmOnlyBuiltDependencies({
|
package/bin/conductor.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* project - Manage Conductor projects (list/show/create/...)
|
|
16
16
|
* issue - Manage issues (list/show/create/update/start/done)
|
|
17
17
|
* task - Manage tasks (create/list/show/send/messages/schedule)
|
|
18
|
+
* remote-exec - Run a command on another daemon's host
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -50,6 +51,7 @@ export function runConductorCli(args = argv, deps = {}) {
|
|
|
50
51
|
"project",
|
|
51
52
|
"issue",
|
|
52
53
|
"task",
|
|
54
|
+
"remote-exec",
|
|
53
55
|
];
|
|
54
56
|
|
|
55
57
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
@@ -135,6 +137,7 @@ Subcommands:
|
|
|
135
137
|
project Manage Conductor projects (list/show/create/...)
|
|
136
138
|
issue Manage issues (list/show/create/update/start/done)
|
|
137
139
|
task Manage tasks (create/list/show/send/messages/schedule)
|
|
140
|
+
remote-exec Run a command on another daemon's host
|
|
138
141
|
|
|
139
142
|
Options:
|
|
140
143
|
-h, --help Show this help message
|
|
@@ -159,6 +162,7 @@ Examples:
|
|
|
159
162
|
conductor task create --title "Refactor module" --prompt "Extract the parser" --backend codex
|
|
160
163
|
conductor task send <task-id> "please add a unit test"
|
|
161
164
|
conductor task schedule create <task-id> "follow up" --delay 10m
|
|
165
|
+
conductor remote-exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
|
|
162
166
|
|
|
163
167
|
For subcommand-specific help:
|
|
164
168
|
conductor fire --help
|
|
@@ -172,6 +176,7 @@ For subcommand-specific help:
|
|
|
172
176
|
conductor project --help
|
|
173
177
|
conductor issue --help
|
|
174
178
|
conductor task --help
|
|
179
|
+
conductor remote-exec --help
|
|
175
180
|
|
|
176
181
|
Version: ${pkgJson.version}
|
|
177
182
|
`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"gitCommitId": "bc7b3a5",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test test/*.test.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@love-moon/ai-sdk": "0.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.
|
|
27
|
+
"@love-moon/ai-sdk": "0.10.0",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.10.0",
|
|
29
29
|
"@github/copilot-sdk": "^0.3.0",
|
|
30
30
|
"chrome-launcher": "^1.2.1",
|
|
31
31
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@roamhq/wrtc": "^0.10.0",
|
|
41
|
-
"@love-moon/chat-web": "0.
|
|
41
|
+
"@love-moon/chat-web": "0.10.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|