@love-moon/conductor-cli 0.8.0 → 0.9.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,22 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3a499cc: Add `conductor remote-exec` for running a single command on another daemon's
8
+ host, over a new `remote_exec_request`/`remote_exec_response` daemon protocol
9
+ pair gated by a `remote_exec` capability. Supports `--workspace`, `--env`,
10
+ `--timeout` with automatic polling for long commands, `--kill-on-timeout`, and
11
+ ssh-style exit codes. Hosts can decline with `remote_exec: false` in the config.
12
+ - a15b55d: Add per-turn multi-image and local context-file inputs, plus authenticated attachment materialization from Conductor Web to the executing daemon.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [a15b55d]
17
+ - @love-moon/ai-sdk@0.9.0
18
+ - @love-moon/conductor-sdk@0.9.0
19
+
3
20
  ## 0.8.0
4
21
 
5
22
  ### 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}`
@@ -335,6 +355,10 @@ function detectInstalledCLIs() {
335
355
  detected.push(key);
336
356
  continue;
337
357
  }
358
+ if (runtimeBackend === "dsh" && isBuiltInDshAvailable()) {
359
+ detected.push(key);
360
+ continue;
361
+ }
338
362
  if (isCommandAvailable(info.command)) {
339
363
  detected.push(key);
340
364
  }
@@ -798,7 +798,7 @@ async function main() {
798
798
  extraEnv: env,
799
799
  extraHeaders: buildConductorConnectHeaders(pkgJson.version, {
800
800
  backends: [cliArgs.backend],
801
- capabilities: ["refresh_session_inplace"],
801
+ capabilities: ["refresh_session_inplace", "task_attachments_v1"],
802
802
  }),
803
803
  configFile: cliArgs.configFile,
804
804
  onConnected: (event) => {
@@ -3069,7 +3069,13 @@ export class BridgeRunner {
3069
3069
  }
3070
3070
 
3071
3071
  async respondToMessage(message) {
3072
- const content = String(message.content || "").trim();
3072
+ const localAttachments = Array.isArray(message.attachments)
3073
+ ? message.attachments.filter((attachment) =>
3074
+ attachment && typeof attachment === "object" && typeof (attachment.path || attachment.localPath) === "string"
3075
+ )
3076
+ : [];
3077
+ const rawContent = String(message.content || "").trim();
3078
+ const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
3073
3079
  if (!content) {
3074
3080
  this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
3075
3081
  return;
@@ -3091,6 +3097,22 @@ export class BridgeRunner {
3091
3097
  String(message.role || "").toLowerCase() === "user" &&
3092
3098
  content === this.pendingInitialPrompt;
3093
3099
  const useInitialImages = isQueuedInitialPromptMessage && this.includeInitialImages;
3100
+ const nativeImageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
3101
+ const media = localAttachments
3102
+ .filter((attachment) => attachment.kind === "image" && nativeImageMimes.has(String(attachment.mimeType || "").toLowerCase()))
3103
+ .map((attachment) => ({
3104
+ kind: "image",
3105
+ path: attachment.path || attachment.localPath,
3106
+ mimeType: attachment.mimeType,
3107
+ name: attachment.name,
3108
+ }));
3109
+ const contextFiles = localAttachments
3110
+ .filter((attachment) => !media.some((image) => image.path === (attachment.path || attachment.localPath)))
3111
+ .map((attachment) => ({
3112
+ path: attachment.path || attachment.localPath,
3113
+ mimeType: attachment.mimeType,
3114
+ name: attachment.name,
3115
+ }));
3094
3116
  if (
3095
3117
  this.useSessionFileReplyStream &&
3096
3118
  typeof this.backendSession?.setSessionReplyTarget === "function"
@@ -3140,6 +3162,8 @@ export class BridgeRunner {
3140
3162
 
3141
3163
  const turnPromise = this.dispatchBackendTurn(content, {
3142
3164
  useInitialImages,
3165
+ media,
3166
+ contextFiles,
3143
3167
  onProgress: (payload) => {
3144
3168
  void this.reportRuntimeStatus(payload, replyTo);
3145
3169
  },
@@ -3330,8 +3354,12 @@ export class BridgeRunner {
3330
3354
  const goalCapable = Boolean(
3331
3355
  snapshot && snapshot.capabilities && snapshot.capabilities.goal === true,
3332
3356
  );
3357
+ const hasAttachmentInputs =
3358
+ (Array.isArray(options.media) && options.media.length > 0) ||
3359
+ (Array.isArray(options.contextFiles) && options.contextFiles.length > 0);
3333
3360
  const willRunGoal =
3334
3361
  goalDirective != null &&
3362
+ !hasAttachmentInputs &&
3335
3363
  goalCapable &&
3336
3364
  typeof this.backendSession?.runGoal === "function";
3337
3365
 
@@ -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.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.9.0",
4
+ "gitCommitId": "606e5a6",
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.9.0",
28
+ "@love-moon/conductor-sdk": "0.9.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.9.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
@@ -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;
@@ -799,6 +830,7 @@ export function startDaemon(config = {}, deps = {}) {
799
830
  // warning and silently fall back to direct spawn rather than failing every
800
831
  // create_task with ENOENT.
801
832
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
833
+ const remoteExecEnabled = getRemoteExecEnabled(userConfig);
802
834
 
803
835
  // Get allow_cli_list from config
804
836
  const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
@@ -2196,11 +2228,19 @@ export function startDaemon(config = {}, deps = {}) {
2196
2228
 
2197
2229
  if (linkStat) {
2198
2230
  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.`,
2231
+ // A real file/dir at the destination means something materialised
2232
+ // data inside the worktree (e.g. `pnpm install` replaced the
2233
+ // node_modules link with a real directory). It may hold real data,
2234
+ // so never clobber it — but throwing here made every task in this
2235
+ // worktree permanently un-restartable. Keep the local copy and skip
2236
+ // the link; remove the path manually (or drop the entry from
2237
+ // worktree.symlink) to restore sharing with the project workspace.
2238
+ logError(
2239
+ `[worktree] skipping symlink for ${configuredPath}: destination already exists and ` +
2240
+ `is not a symlink: ${linkPath}. Keeping the local copy — remove it manually to ` +
2241
+ `restore sharing via worktree.symlink in .conductor/settings.yaml.`,
2203
2242
  );
2243
+ continue;
2204
2244
  }
2205
2245
  // Compare the link's TARGET, not whether that target resolves. A link
2206
2246
  // that already points at the right place is correct even when the
@@ -2934,8 +2974,14 @@ export function startDaemon(config = {}, deps = {}) {
2934
2974
  "project_agents_registry",
2935
2975
  "restart_daemon",
2936
2976
  "refresh_session_inplace",
2977
+ "task_attachments_v1",
2937
2978
  CUSTOM_COMMANDS_CAPABILITY,
2938
2979
  ];
2980
+ if (remoteExecEnabled) {
2981
+ advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
2982
+ } else {
2983
+ log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
2984
+ }
2939
2985
  if (ptyTaskCapabilityEnabled) {
2940
2986
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
2941
2987
  }
@@ -2944,6 +2990,9 @@ export function startDaemon(config = {}, deps = {}) {
2944
2990
  }
2945
2991
  const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
2946
2992
  const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
2993
+ const remoteExecHandlers = remoteExecEnabled
2994
+ ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
2995
+ : null;
2947
2996
 
2948
2997
  const client = createWebSocketClient(sdkConfig, {
2949
2998
  extraHeaders,
@@ -5171,6 +5220,40 @@ export function startDaemon(config = {}, deps = {}) {
5171
5220
  logError(`Unhandled custom_commands_request failure: ${error?.message || error}`);
5172
5221
  });
5173
5222
  }
5223
+ if (event.type === "remote_exec_request") {
5224
+ // Remote exec is the only execution path that leaves no Task row and no
5225
+ // per-run log file, so record it here — otherwise a run is invisible
5226
+ // once the daemon restarts. argv is deliberately omitted: it routinely
5227
+ // carries secrets and this log is collected by `collect_logs`.
5228
+ const execArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
5229
+ log(
5230
+ `[remote-exec] ${event?.payload?.action || "?"} command=${execArgs.command || ""} ` +
5231
+ `argc=${Array.isArray(execArgs.args) ? execArgs.args.length : 0} cwd=${execArgs.workspace || "<default>"}`,
5232
+ );
5233
+ // Fail fast rather than letting the caller wait out its full timeout —
5234
+ // it has no other way to learn that this daemon will never answer.
5235
+ const rejectReason = !remoteExecHandlers
5236
+ ? "remote exec is disabled on this daemon (remote_exec: false)"
5237
+ : daemonShuttingDown
5238
+ ? "daemon is shutting down"
5239
+ : "";
5240
+ if (rejectReason) {
5241
+ void client
5242
+ .sendJson({
5243
+ type: "remote_exec_response",
5244
+ payload: {
5245
+ request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
5246
+ action: event?.payload?.action ? String(event.payload.action) : "",
5247
+ error: rejectReason,
5248
+ },
5249
+ })
5250
+ .catch(() => {});
5251
+ return;
5252
+ }
5253
+ handleRemoteExecRequest(client, remoteExecHandlers, event.payload).catch((error) => {
5254
+ logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
5255
+ });
5256
+ }
5174
5257
  if (event.type === "restart_daemon") {
5175
5258
  void handleRestartDaemon(event.payload).catch((error) => {
5176
5259
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
@@ -6218,6 +6301,10 @@ export function startDaemon(config = {}, deps = {}) {
6218
6301
  PWD: taskDir,
6219
6302
  CONDUCTOR_PROJECT_ID: projectId,
6220
6303
  CONDUCTOR_TASK_ID: taskId,
6304
+ // Fire derives its own stable host identity from the owning daemon plus
6305
+ // the task. Passing the resolved name keeps that identity meaningful
6306
+ // even when the daemon was named through the config file.
6307
+ CONDUCTOR_DAEMON_NAME: AGENT_NAME,
6221
6308
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
6222
6309
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
6223
6310
  };
@@ -6732,7 +6819,11 @@ export function startDaemon(config = {}, deps = {}) {
6732
6819
  resolvedResumeCwd = await resolveRestartCwd({
6733
6820
  taskId: normalizedTargetTaskId,
6734
6821
  projectId: normalizedProjectId,
6735
- backendType: effectiveBackend,
6822
+ // A fork starts a fresh target-backend session, but its workspace
6823
+ // still belongs to the source task. Resolve the source session in
6824
+ // its own provider namespace; target + source session id is not a
6825
+ // meaningful pair for cross-backend handoff.
6826
+ backendType: sourceBackendType,
6736
6827
  launchConfig: targetLaunchConfig,
6737
6828
  sessionId: normalizedSourceSessionId,
6738
6829
  sourceSessionFilePath: sourceSessionFilePath ? String(sourceSessionFilePath) : "",
@@ -6920,6 +7011,7 @@ export function startDaemon(config = {}, deps = {}) {
6920
7011
  PWD: taskDir,
6921
7012
  CONDUCTOR_PROJECT_ID: normalizedProjectId,
6922
7013
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
7014
+ CONDUCTOR_DAEMON_NAME: AGENT_NAME,
6923
7015
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
6924
7016
  ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
6925
7017
  };
@@ -0,0 +1,421 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { StringDecoder } from "node:string_decoder";
6
+
7
+ import { resolveUserHome } from "./conductor-paths.js";
8
+
9
+ const VALID_ACTIONS = new Set(["exec", "status", "cancel"]);
10
+ const MAX_TAIL_CHARS = 64_000;
11
+ const MAX_RUNS = 200;
12
+ const MAX_CONCURRENT_RUNS = 32;
13
+ const MAX_ARGS = 256;
14
+ const DEFAULT_WAIT_MS = 30_000;
15
+ const MAX_WAIT_MS = 120_000;
16
+ /** Grace between SIGTERM and SIGKILL when a run is cancelled. */
17
+ const KILL_GRACE_MS = 5_000;
18
+ const NUL = String.fromCharCode(0);
19
+
20
+ export const REMOTE_EXEC_CAPABILITY = "remote_exec";
21
+
22
+ /**
23
+ * Run arbitrary commands on this daemon's host on behalf of the account that
24
+ * owns it.
25
+ *
26
+ * This is not a new trust boundary: the same account can already reach the same
27
+ * shell through `create_pty_task`, whose `entrypoint_type: "custom"` branch
28
+ * takes a caller-supplied command, argv, cwd and env, and whose server-side
29
+ * validation only checks `cols`/`rows`. The one case where this genuinely does
30
+ * add reach is a host whose node-pty probe failed — there `pty_task` is not
31
+ * advertised at all — which is why the capability is opt-out-able via
32
+ * `remote_exec: false` in the daemon's config.
33
+ *
34
+ * Commands are spawned without a shell: the caller sends argv, so nothing is
35
+ * re-parsed here. Callers that genuinely want a shell pass `-- bash -lc "..."`.
36
+ *
37
+ * @param {object} opts
38
+ * @param {typeof spawn} [opts.spawnFn]
39
+ * @param {string} [opts.defaultWorkspace] cwd used when the request omits one
40
+ */
41
+ export function createRemoteExecHandlers(opts = {}) {
42
+ const spawnFn = opts.spawnFn || spawn;
43
+ const defaultWorkspace = opts.defaultWorkspace || resolveUserHome();
44
+ const runs = new Map();
45
+
46
+ async function exec(args = {}) {
47
+ // Start the clock before any I/O. The server's waiter starts ticking the
48
+ // moment it sends the request, and its only margin is a few seconds of
49
+ // slack; a slow `stat` on a stale network mount would otherwise let the
50
+ // server time out first, stranding a running child whose runId the caller
51
+ // never learned.
52
+ const waitMs = clampWaitMs(args.timeoutMs ?? args.timeout_ms);
53
+ const waitDeadline = Date.now() + waitMs;
54
+
55
+ const command = normalizeCommand(args.command);
56
+ if (!command) {
57
+ throw new Error("exec requires a `command` string");
58
+ }
59
+ const argv = normalizeArgv(args.args);
60
+ const running = countRunning(runs);
61
+ if (running >= MAX_CONCURRENT_RUNS) {
62
+ throw new Error(
63
+ `too many concurrent remote exec runs on this daemon (${running}/${MAX_CONCURRENT_RUNS}); cancel one first`,
64
+ );
65
+ }
66
+ const cwd = await resolveWorkspace(args.workspace ?? args.workspace_path, defaultWorkspace);
67
+ const env = buildExecEnv(args.env);
68
+
69
+ const runState = {
70
+ runId: randomUUID(),
71
+ command,
72
+ args: argv,
73
+ workspace: cwd,
74
+ status: "running",
75
+ pid: null,
76
+ exitCode: null,
77
+ signal: null,
78
+ stdoutTail: "",
79
+ stderrTail: "",
80
+ truncated: false,
81
+ error: null,
82
+ startedAt: new Date().toISOString(),
83
+ finishedAt: null,
84
+ settleWaiters: [],
85
+ child: null,
86
+ cancelRequested: false,
87
+ killTimer: null,
88
+ };
89
+ rememberRun(runs, runState);
90
+
91
+ let child;
92
+ try {
93
+ child = spawnFn(command, argv, {
94
+ cwd,
95
+ env,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+ } catch (error) {
99
+ finishRun(runState, "failed", { error: errMsg(error) });
100
+ return toPublicRun(runState);
101
+ }
102
+
103
+ runState.child = child;
104
+ runState.pid = typeof child.pid === "number" ? child.pid : null;
105
+
106
+ // Decode per stream: a multi-byte character can straddle two `data` chunks,
107
+ // and a naive String(chunk) would turn each half into U+FFFD.
108
+ const stdoutDecoder = new StringDecoder("utf8");
109
+ const stderrDecoder = new StringDecoder("utf8");
110
+ child.stdout?.on("data", (chunk) => {
111
+ runState.stdoutTail = appendTail(runState, runState.stdoutTail, stdoutDecoder.write(chunk));
112
+ });
113
+ child.stderr?.on("data", (chunk) => {
114
+ runState.stderrTail = appendTail(runState, runState.stderrTail, stderrDecoder.write(chunk));
115
+ });
116
+ child.on("error", (error) => {
117
+ finishRun(runState, "failed", { error: errMsg(error) });
118
+ });
119
+ child.on("close", (code, signal) => {
120
+ runState.stdoutTail = appendTail(runState, runState.stdoutTail, stdoutDecoder.end());
121
+ runState.stderrTail = appendTail(runState, runState.stderrTail, stderrDecoder.end());
122
+ const status = runState.cancelRequested ? "cancelled" : code === 0 ? "completed" : "failed";
123
+ finishRun(runState, status, {
124
+ exitCode: typeof code === "number" ? code : null,
125
+ signal: signal || null,
126
+ });
127
+ });
128
+
129
+ await waitForSettle(runState, Math.max(0, waitDeadline - Date.now()));
130
+ return toPublicRun(runState);
131
+ }
132
+
133
+ async function status(args = {}) {
134
+ return toPublicRun(mustFindRun(runs, args.runId));
135
+ }
136
+
137
+ async function cancel(args = {}) {
138
+ const runState = mustFindRun(runs, args.runId);
139
+ if (runState.status !== "running") {
140
+ return toPublicRun(runState);
141
+ }
142
+ runState.cancelRequested = true;
143
+ try {
144
+ runState.child?.kill("SIGTERM");
145
+ } catch (error) {
146
+ runState.error = runState.error || errMsg(error);
147
+ }
148
+ // Escalate if the child ignores SIGTERM. The timer is unref'd so it can
149
+ // never hold the daemon's event loop open.
150
+ runState.killTimer = setTimeout(() => {
151
+ try {
152
+ runState.child?.kill("SIGKILL");
153
+ } catch {
154
+ /* already gone */
155
+ }
156
+ }, KILL_GRACE_MS);
157
+ runState.killTimer.unref?.();
158
+
159
+ await waitForSettle(runState, KILL_GRACE_MS * 2);
160
+ return toPublicRun(runState);
161
+ }
162
+
163
+ /**
164
+ * Run a single action and return a `result` object, never throwing.
165
+ * @param {{action:string,args?:object}} payload
166
+ */
167
+ async function dispatch(payload) {
168
+ const action = payload?.action;
169
+ if (!VALID_ACTIONS.has(action)) {
170
+ return { error: `unknown action: ${action}` };
171
+ }
172
+ try {
173
+ switch (action) {
174
+ case "exec":
175
+ return { result: await exec(payload?.args ?? {}) };
176
+ case "status":
177
+ return { result: await status(payload?.args ?? {}) };
178
+ case "cancel":
179
+ return { result: await cancel(payload?.args ?? {}) };
180
+ default:
181
+ return { error: `unhandled action: ${action}` };
182
+ }
183
+ } catch (err) {
184
+ return { error: errMsg(err) };
185
+ }
186
+ }
187
+
188
+ return { dispatch, runs, defaultWorkspace };
189
+ }
190
+
191
+ /**
192
+ * @param {object} client
193
+ * @param {ReturnType<typeof createRemoteExecHandlers>} handlers
194
+ * @param {object} payload
195
+ */
196
+ export async function handleRemoteExecRequest(client, handlers, payload) {
197
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
198
+ const action = payload?.action ? String(payload.action) : "";
199
+ if (!requestId) {
200
+ return { error: "missing request_id" };
201
+ }
202
+
203
+ const response = await handlers.dispatch({
204
+ action,
205
+ args: payload?.args && typeof payload.args === "object" ? payload.args : {},
206
+ });
207
+
208
+ const outgoing = {
209
+ type: "remote_exec_response",
210
+ payload: {
211
+ request_id: requestId,
212
+ action,
213
+ ...(response?.error ? { error: response.error } : { result: response?.result }),
214
+ },
215
+ };
216
+ await client.sendJson(outgoing).catch(() => {});
217
+ return response;
218
+ }
219
+
220
+ function mustFindRun(runs, rawRunId) {
221
+ const runId = typeof rawRunId === "string" ? rawRunId.trim() : "";
222
+ if (!runId) {
223
+ throw new Error("this action requires a `runId` string");
224
+ }
225
+ const runState = runs.get(runId);
226
+ if (!runState) {
227
+ throw new Error(`remote exec run not found: ${runId}`);
228
+ }
229
+ return runState;
230
+ }
231
+
232
+ function countRunning(runs) {
233
+ let count = 0;
234
+ for (const entry of runs.values()) {
235
+ if (entry.status === "running") count += 1;
236
+ }
237
+ return count;
238
+ }
239
+
240
+ export function normalizeCommand(value) {
241
+ if (typeof value !== "string") {
242
+ return "";
243
+ }
244
+ const command = value.trim();
245
+ if (!command || command.includes(NUL)) {
246
+ return "";
247
+ }
248
+ return command;
249
+ }
250
+
251
+ export function normalizeArgv(value) {
252
+ if (value === undefined || value === null) {
253
+ return [];
254
+ }
255
+ if (!Array.isArray(value)) {
256
+ throw new Error("`args` must be an array of strings");
257
+ }
258
+ if (value.length > MAX_ARGS) {
259
+ throw new Error(`\`args\` must contain at most ${MAX_ARGS} entries`);
260
+ }
261
+ return value.map((entry) => {
262
+ if (typeof entry !== "string") {
263
+ throw new Error("`args` must contain only strings");
264
+ }
265
+ if (entry.includes(NUL)) {
266
+ throw new Error("`args` must not contain NUL bytes");
267
+ }
268
+ return entry;
269
+ });
270
+ }
271
+
272
+ export async function resolveWorkspace(value, fallback) {
273
+ const raw = typeof value === "string" ? value.trim() : "";
274
+ const target = raw ? expandHome(raw) : fallback;
275
+ const resolved = path.resolve(target);
276
+
277
+ let stat;
278
+ try {
279
+ stat = await fsp.stat(resolved);
280
+ } catch (error) {
281
+ if (error?.code === "ENOENT") {
282
+ throw new Error(`workspace does not exist: ${resolved}`);
283
+ }
284
+ throw error;
285
+ }
286
+ if (!stat.isDirectory()) {
287
+ throw new Error(`workspace is not a directory: ${resolved}`);
288
+ }
289
+ return resolved;
290
+ }
291
+
292
+ function expandHome(raw) {
293
+ if (raw === "~") {
294
+ return resolveUserHome();
295
+ }
296
+ if (raw.startsWith("~/")) {
297
+ return path.join(resolveUserHome(), raw.slice(2));
298
+ }
299
+ return raw;
300
+ }
301
+
302
+ export function clampWaitMs(value) {
303
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
304
+ if (!Number.isFinite(parsed) || parsed <= 0) {
305
+ return DEFAULT_WAIT_MS;
306
+ }
307
+ return Math.min(Math.trunc(parsed), MAX_WAIT_MS);
308
+ }
309
+
310
+ /**
311
+ * Inherit the daemon environment minus `CONDUCTOR_*`, matching what
312
+ * `custom_commands` does. This keeps the agent token out of the child's env; it
313
+ * is NOT a confidentiality guarantee — the token still sits in
314
+ * `~/.conductor/config.yaml`, which an arbitrary command can simply read. Do
315
+ * not build any security control on top of this.
316
+ */
317
+ export function buildExecEnv(overrides) {
318
+ const env = { ...process.env };
319
+ for (const name of Object.keys(env)) {
320
+ if (name.startsWith("CONDUCTOR_")) {
321
+ delete env[name];
322
+ }
323
+ }
324
+ if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
325
+ for (const [key, value] of Object.entries(overrides)) {
326
+ if (!key || key.includes("=") || key.includes(NUL)) {
327
+ throw new Error(`invalid env variable name: ${key}`);
328
+ }
329
+ if (typeof value !== "string" || value.includes(NUL)) {
330
+ throw new Error(`env variable ${key} must be a string`);
331
+ }
332
+ env[key] = value;
333
+ }
334
+ }
335
+ return env;
336
+ }
337
+
338
+ function waitForSettle(runState, waitMs) {
339
+ if (runState.status !== "running") {
340
+ return Promise.resolve();
341
+ }
342
+ return new Promise((resolve) => {
343
+ let settled = false;
344
+ const finish = () => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ const index = runState.settleWaiters.indexOf(finish);
349
+ if (index >= 0) runState.settleWaiters.splice(index, 1);
350
+ resolve();
351
+ };
352
+ const timer = setTimeout(finish, waitMs);
353
+ timer.unref?.();
354
+ runState.settleWaiters.push(finish);
355
+ });
356
+ }
357
+
358
+ function rememberRun(runs, runState) {
359
+ runs.set(runState.runId, runState);
360
+ while (runs.size > MAX_RUNS) {
361
+ const oldest = [...runs.values()].find((entry) => entry.status !== "running")?.runId;
362
+ // Only finished runs are evictable, and `MAX_CONCURRENT_RUNS` bounds how
363
+ // many can be running at once, so the map cannot grow without limit.
364
+ if (!oldest) break;
365
+ runs.delete(oldest);
366
+ }
367
+ }
368
+
369
+ function finishRun(runState, status, updates = {}) {
370
+ if (runState.status !== "running") {
371
+ return;
372
+ }
373
+ runState.status = status;
374
+ runState.finishedAt = new Date().toISOString();
375
+ runState.exitCode = updates.exitCode ?? runState.exitCode;
376
+ runState.signal = updates.signal ?? runState.signal;
377
+ runState.error = updates.error ?? runState.error;
378
+ if (runState.killTimer) {
379
+ clearTimeout(runState.killTimer);
380
+ runState.killTimer = null;
381
+ }
382
+ runState.child = null;
383
+ for (const waiter of runState.settleWaiters.splice(0)) {
384
+ waiter();
385
+ }
386
+ }
387
+
388
+ function appendTail(runState, current, chunk) {
389
+ if (!chunk) {
390
+ return current;
391
+ }
392
+ const next = current + chunk;
393
+ if (next.length <= MAX_TAIL_CHARS) {
394
+ return next;
395
+ }
396
+ runState.truncated = true;
397
+ return next.slice(next.length - MAX_TAIL_CHARS);
398
+ }
399
+
400
+ function toPublicRun(runState) {
401
+ return {
402
+ runId: runState.runId,
403
+ command: runState.command,
404
+ args: runState.args,
405
+ workspace: runState.workspace,
406
+ status: runState.status,
407
+ pid: runState.pid,
408
+ exitCode: runState.exitCode,
409
+ signal: runState.signal,
410
+ error: runState.error,
411
+ startedAt: runState.startedAt,
412
+ finishedAt: runState.finishedAt,
413
+ stdoutTail: runState.stdoutTail,
414
+ stderrTail: runState.stderrTail,
415
+ truncated: runState.truncated,
416
+ };
417
+ }
418
+
419
+ function errMsg(err) {
420
+ return err?.message ?? String(err);
421
+ }
@@ -9,12 +9,13 @@ import { resolveConductorConfigPath } from "./conductor-paths.js";
9
9
  // CLI display order for built-in backends. ai-sdk owns the canonical set of
10
10
  // built-in backends; CLI just picks an ordering for "Supported Backends:" log
11
11
  // output. The self-check below ensures this list always matches ai-sdk's set.
12
- const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot", "chat-web"];
12
+ const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot", "chat-web", "dsh"];
13
13
  const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
14
14
  // Backends that don't shell out to a CLI binary AND should be advertised
15
- // without any user-side allow_cli_list entry. `copilot` is the only such
16
- // backend today — it ships with @github/copilot-sdk as a hard dep so
17
- // every install gets it for free.
15
+ // without any user-side allow_cli_list entry. `copilot` ships with
16
+ // @github/copilot-sdk as a hard dep so every install gets it for free;
17
+ // `dsh` (DeepSeek Harness) ships its pinned runtime inside ai-sdk's own
18
+ // dependencies and is spawned via @deepseek-ai/dsh-sdk-client.
18
19
  //
19
20
  // chat-web is intentionally NOT here even though it's also command-optional
20
21
  // (it drives a Chromium browser, not a CLI). The reason: chat-web has
@@ -23,7 +24,7 @@ const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
23
24
  // aliases like `web-chatgpt` / `web-gemini` is just confusing — the alias
24
25
  // IS the sub-provider choice. Users who want chat-web must declare an
25
26
  // explicit allow_cli_list entry (which is also where `--model` lives).
26
- const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot"];
27
+ const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot", "dsh"];
27
28
  const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET = new Set(COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS);
28
29
 
29
30
  // Legacy aliases (e.g. "code" → "codex", "kimi-cli" → "kimi") are derived