@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 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
@@ -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)}`;
@@ -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 content = String(message.content || "").trim();
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
+ }
@@ -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.8.0",
4
- "gitCommitId": "04d4f62",
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.8.0",
28
- "@love-moon/conductor-sdk": "0.8.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.8.0"
41
+ "@love-moon/chat-web": "0.10.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [