@love-moon/conductor-cli 0.16.0 → 0.17.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,72 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 88e1ae0: Support `/stop` in task chats. A bare `/stop` message, sent from the web, the
8
+ CLI or a channel, makes the server interrupt the AI's running turn right away
9
+ instead of waiting for fire to read the message once that turn ends. When fire
10
+ does read the queued `/stop`, it posts a confirmation and does not send it to
11
+ the model. `/stop` also drops every message queued before it (except the
12
+ server's end-of-round summary request); resend what you still want. The server
13
+ targets the reply it knows is running, so older fires stop it too;
14
+ `interrupt_turn` no longer needs a reply target, and without one fire
15
+ interrupts the turn it is running, including the initial prompt and pre_prompt
16
+ turns. In the web chat, the ⋯ menu gains `/stop`, `/clear` and `/compact`,
17
+ which send those commands as messages, and Interrupt (Esc) sends `/stop`,
18
+ ignored while an earlier `/stop` is unanswered. The Interrupt action is gone
19
+ from the message toolbar.
20
+
21
+ ### Patch Changes
22
+
23
+ - 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
24
+ That fallback looked like it worked, but in a headless task it silently refused
25
+ every shell command (ssh, rsync, ...) and every read outside the project. Claude
26
+ now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
27
+ chat shows a warning right after "session started" telling you to add
28
+ `envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
29
+ regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
30
+ `envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
31
+ writes `--dangerously-skip-permissions` for claude.
32
+ - c0c1489: `conductor task speak <text> [-o FILE|-]` saves Doubao text-to-speech as an
33
+ .mp3 — the same voice the web chat's new voice-conversation mode reads replies with.
34
+ - 39ee057: `conductor update` and the daemon updater compile node-pty against the headers
35
+ the running Node ships, instead of downloading them from nodejs.org, so an
36
+ update no longer needs that host to be reachable. `conductor update` also stops
37
+ an npm install that has not finished after 15 minutes instead of waiting on a
38
+ stalled registry connection forever.
39
+ - b09b28a: Remove `conductor task create --remote-worktree <host>`; passing it now fails
40
+ with a pointer to the replacement. Running the AI on one daemon while the code
41
+ lives on another is done only through a global AI backend:
42
+ `--global-backend <backend>@<host>` (add `--worktree` for a worktree on the
43
+ code's daemon). Note the host changes meaning: `--remote-worktree` named the
44
+ daemon holding the code, `--global-backend` names the daemon running the AI, so
45
+ create the task in the project on the code's daemon. The server refuses a caller-supplied
46
+ `launch_config.remoteWorktree`.
47
+
48
+ `conductor task create --global-backend` now takes `<backend>@<host>`, the same
49
+ form as `conductor issue start` and the web UI. The older `<host>:<backend>` is
50
+ still accepted.
51
+
52
+ - 68fe7b9: Add `conductor task preview <id> <path>`: open a temporary link to a file on the
53
+ task's daemon (HTML with its CSS/JS/images, Markdown, images, video), the same
54
+ preview the chat opens when you click a file link in an AI reply or shows in
55
+ place for `![](picture.png)` / `![](recording.mp4)`. The link expires after
56
+ 5 idle minutes (30 minutes at most) and only reaches the file's own directory.
57
+
58
+ The daemon advertises a new `remote_file_preview` capability; previews need a
59
+ daemon of this version or later. `conductor config` now writes
60
+ `remote_file: true` explicitly into a new config file (the default was already
61
+ on; set it to `false` to decline file transfers and previews).
62
+
63
+ - Updated dependencies [d088b31]
64
+ - Updated dependencies [79ca552]
65
+ - Updated dependencies [ccbfeeb]
66
+ - Updated dependencies [88e1ae0]
67
+ - @love-moon/ai-sdk@0.17.0
68
+ - @love-moon/conductor-sdk@0.17.0
69
+
3
70
  ## 0.16.0
4
71
 
5
72
  ### Minor Changes
@@ -10,7 +10,6 @@ import { createRequire } from "node:module";
10
10
  import yargs from "yargs/yargs";
11
11
  import { hideBin } from "yargs/helpers";
12
12
  import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
13
- import { isClaudeRootPermissionRestricted } from "@love-moon/ai-sdk";
14
13
  import { resolveConductorConfigPath } from "../src/conductor-paths.js";
15
14
 
16
15
  const CONFIG_FILE = resolveConductorConfigPath();
@@ -22,12 +21,10 @@ const packageJson = JSON.parse(
22
21
  const DEFAULT_CLIs = {
23
22
  claude: {
24
23
  command: "claude",
25
- // claude exits 1 with `--dangerously-skip-permissions` under root, so never
26
- // write a flag the user's own machine cannot run. Same root check the
27
- // daemon and the ai-sdk session use.
28
- execArgs: isClaudeRootPermissionRestricted()
29
- ? "--permission-mode acceptEdits"
30
- : "--dangerously-skip-permissions",
24
+ // Kept even on root: a headless acceptEdits session silently denies every
25
+ // shell command. Root users opt in with `envs.IS_SANDBOX: "1"`; fire and
26
+ // the daemon tell them so when claude starts.
27
+ execArgs: "--dangerously-skip-permissions",
31
28
  description: "Anthropic Claude CLI"
32
29
  },
33
30
  codex: {
@@ -387,9 +384,9 @@ async function main() {
387
384
  "# Env: CONDUCTOR_REMOTE_EXEC",
388
385
  "# remote_exec: true",
389
386
  "",
390
- "# Accept `conductor remote cp` file transfers for this host.",
387
+ "# Accept `conductor remote cp` file transfers and file previews for this host.",
391
388
  "# Env: CONDUCTOR_REMOTE_FILE",
392
- "# remote_file: true",
389
+ "remote_file: true",
393
390
  "",
394
391
  "# Launch each Fire process inside a detached tmux session so it survives a",
395
392
  "# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
@@ -1648,6 +1648,10 @@ export function formatCompactReply(backendName, compact, instructions = "") {
1648
1648
  * Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
1649
1649
  * counts: anything after it is more likely an instruction for the model.
1650
1650
  */
1651
+ export function isStopCommand(content) {
1652
+ return typeof content === "string" && /^\/stop$/i.test(content.trim());
1653
+ }
1654
+
1651
1655
  export function isClearCommand(content) {
1652
1656
  return typeof content === "string" && /^\/clear$/i.test(content.trim());
1653
1657
  }
@@ -2168,6 +2172,8 @@ export class BridgeRunner {
2168
2172
  this.remoteInterruptsByReplyTo = new Map();
2169
2173
  this.pendingInterruptRetryTimers = new Map();
2170
2174
  this.activeTurnReplyTo = "";
2175
+ // A `/stop` arrived mid-batch: drop the rest of this batch (all sent before it).
2176
+ this.dropQueuedBeforeStop = false;
2171
2177
  this.sessionAnnouncementSent = false;
2172
2178
  this.sessionAnnouncementSubscribed = false;
2173
2179
  this.boundSessionId = "";
@@ -2309,6 +2315,7 @@ export class BridgeRunner {
2309
2315
  });
2310
2316
  this.sessionAnnouncementSent = true;
2311
2317
  this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
2318
+ await this.reportBackendNotices();
2312
2319
  await this.reportRuntimeStatus(
2313
2320
  {
2314
2321
  state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
@@ -2577,7 +2584,12 @@ export class BridgeRunner {
2577
2584
  }
2578
2585
  const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
2579
2586
  const reason = typeof event.reason === "string" ? event.reason.trim() : "";
2580
- const targetReplyTo = this.normalizeReplyTarget(event.targetReplyTo);
2587
+ if (reason === "user_stop" && this.runningTurn) {
2588
+ this.dropQueuedBeforeStop = true;
2589
+ }
2590
+ // No target (`/stop`): interrupt whatever turn is running right now.
2591
+ const targetReplyTo =
2592
+ this.normalizeReplyTarget(event.targetReplyTo) || (this.runningTurn ? this.activeTurnReplyTo : "");
2581
2593
  if (!targetReplyTo) {
2582
2594
  return false;
2583
2595
  }
@@ -2767,7 +2779,12 @@ export class BridgeRunner {
2767
2779
  .join(",")}`,
2768
2780
  );
2769
2781
 
2770
- for (const message of messages) {
2782
+ // `/stop` drops every message queued before it; the user resends what they still want.
2783
+ let lastStopIndex = -1;
2784
+ messages.forEach((message, index) => {
2785
+ if (this.isUserStopMessage(message)) lastStopIndex = index;
2786
+ });
2787
+ for (const [index, message] of messages.entries()) {
2771
2788
  if (this.stopped) {
2772
2789
  break;
2773
2790
  }
@@ -2775,8 +2792,16 @@ export class BridgeRunner {
2775
2792
  this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
2776
2793
  continue;
2777
2794
  }
2795
+ if (this.isUserStopMessage(message)) {
2796
+ this.dropQueuedBeforeStop = false;
2797
+ } else if ((index < lastStopIndex || this.dropQueuedBeforeStop) && this.isDroppableOnStop(message)) {
2798
+ if (message.message_id) this.processedMessageIds.add(message.message_id);
2799
+ this.copilotLog(`drop message queued before /stop replyTo=${message.message_id || "unknown"}`);
2800
+ continue;
2801
+ }
2778
2802
  await this.respondToMessage(message);
2779
2803
  }
2804
+ this.dropQueuedBeforeStop = false;
2780
2805
 
2781
2806
  if (ackToken) {
2782
2807
  await this.conductor.ackMessages(this.taskId, ackToken);
@@ -2786,6 +2811,15 @@ export class BridgeRunner {
2786
2811
  return hasMore;
2787
2812
  }
2788
2813
 
2814
+ isUserStopMessage(message) {
2815
+ return String(message?.role || "").toLowerCase() === "user" && isStopCommand(message.content);
2816
+ }
2817
+
2818
+ // The end-of-round summary request is the server's, not the user's: dropping it would lock the round.
2819
+ isDroppableOnStop(message) {
2820
+ return String(message?.role || "").toLowerCase() === "user" && message.metadata?.kind !== "persistent_round_end";
2821
+ }
2822
+
2789
2823
  shouldRespond(message) {
2790
2824
  if (!message || typeof message !== "object") {
2791
2825
  return false;
@@ -3664,6 +3698,9 @@ export class BridgeRunner {
3664
3698
  if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
3665
3699
  return this.runClearCommand(options);
3666
3700
  }
3701
+ if (!hasAttachmentInputs && isStopCommand(content)) {
3702
+ return this.runStopCommand(options);
3703
+ }
3667
3704
 
3668
3705
  if (willRunGoal) {
3669
3706
  const goalResult = await this.runWithTurnUsage(() =>
@@ -3852,6 +3889,24 @@ export class BridgeRunner {
3852
3889
  return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
3853
3890
  }
3854
3891
 
3892
+ /**
3893
+ * `/stop`: the server already interrupted the running turn when the message
3894
+ * arrived (fire only reads it once that turn ends), so just confirm it
3895
+ * instead of sending it to the model.
3896
+ */
3897
+ async runStopCommand({ onProgress, replyTo = "" } = {}) {
3898
+ const text = `${this.backendName} 已停止。`;
3899
+ onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
3900
+ if (this.useSessionFileReplyStream && !this.stopped) {
3901
+ try {
3902
+ await this.sendSessionStreamMessage({ text, replyTo });
3903
+ } catch (error) {
3904
+ log(`[stop] failed to post confirmation: ${error?.message || error}`);
3905
+ }
3906
+ }
3907
+ return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
3908
+ }
3909
+
3855
3910
  async handlePrePromptMessage(content) {
3856
3911
  const text = typeof content === "string" ? content.trim() : "";
3857
3912
  if (!text) {
@@ -3889,6 +3944,8 @@ export class BridgeRunner {
3889
3944
  }) {
3890
3945
  this.lastRuntimeStatusSignature = null;
3891
3946
  this.runningTurn = true;
3947
+ // Lets a target-less interrupt (`/stop`) reach this turn.
3948
+ this.activeTurnReplyTo = replyTarget;
3892
3949
  // Only this turn's own streamed replies may carry its usage.
3893
3950
  this.lastStreamedReplyIds.clear();
3894
3951
  const startedAt = Date.now();
@@ -3963,6 +4020,11 @@ export class BridgeRunner {
3963
4020
  this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
3964
4021
  return;
3965
4022
  }
4023
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(replyTarget);
4024
+ if (interruptInfo && this.isTurnInterruptedError(error)) {
4025
+ await this.handleInterruptedTurn(replyTarget, interruptInfo);
4026
+ return;
4027
+ }
3966
4028
  if (
3967
4029
  await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
3968
4030
  markProcessed: false,
@@ -3976,11 +4038,38 @@ export class BridgeRunner {
3976
4038
  await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
3977
4039
  } finally {
3978
4040
  this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
4041
+ this.activeTurnReplyTo = "";
4042
+ this.clearInterruptRetryForReplyTarget(replyTarget);
4043
+ this.remoteInterruptsByReplyTo.delete(replyTarget);
3979
4044
  this.runningTurn = false;
3980
4045
  this.scheduleRuntimeHeartbeat();
3981
4046
  }
3982
4047
  }
3983
4048
 
4049
+ // Provider-level warnings the user has to act on (e.g. claude refusing
4050
+ // bypassPermissions as root without IS_SANDBOX=1). Sent right after the
4051
+ // "session started" line so the reason is visible before the turn fails.
4052
+ async reportBackendNotices() {
4053
+ const snapshot =
4054
+ typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
4055
+ const notices = Array.isArray(snapshot?.notices)
4056
+ ? snapshot.notices.filter((notice) => typeof notice === "string" && notice.trim())
4057
+ : [];
4058
+ for (const notice of notices) {
4059
+ try {
4060
+ await this.conductor.sendMessage(this.taskId, notice, {
4061
+ severity: "warning",
4062
+ backend: this.backendName,
4063
+ cli_args: this.cliArgs,
4064
+ synthetic: true,
4065
+ });
4066
+ this.copilotLog(`backend notice sent: ${sanitizeForLog(notice, 120)}`);
4067
+ } catch (error) {
4068
+ log(`Failed to send backend notice: ${error?.message || error}`);
4069
+ }
4070
+ }
4071
+ }
4072
+
3984
4073
  async reportError(message, replyTo) {
3985
4074
  try {
3986
4075
  await this.conductor.sendMessage(this.taskId, message, {
@@ -19,11 +19,13 @@
19
19
  * schedule update <id> <schedule-id> [<message>] [--delay|--at|--every ...]
20
20
  * stop <id> | interrupt <id> [--target-reply-to <msg-id>]
21
21
  * restart <id> [--strategy inplace|new_task] [--backend] [--refresh-session]
22
- * [--daemon-host <h>] [--first-message <text>]
22
+ * [--daemon-host <h>] [--code-host <h>] [--first-message <text>]
23
23
  * delete <id> --yes [--permanent] | archive <id> | unarchive <id> [--daemon-host <h>]
24
24
  * rename <id> <title> | pin <id> | unpin <id> | labels <id> [<label-id>...] [--clear]
25
25
  * move <id> [<project>] [--back] | share <id> | unshare <id> | shared <token|link>
26
+ * preview <id> <path>
26
27
  * transcribe <audio.wav|mp3> [--language <tag>]
28
+ * speak <text> [-o FILE|-]
27
29
  * persistent <id> [--enable|--disable] [--instructions ...] [--summary ...]
28
30
  * round end <id> | round start <id> [<message>] [--backend] [--daemon-host] [--worktree inherit|new|none]
29
31
  * cleanup-worktree <id> | terminal open|close|show <id>
@@ -31,8 +33,7 @@
31
33
  * resume --daemon-host <h> --backend <b> --session <id> [--session-file <p>] [--title] [--prompt]
32
34
  *
33
35
  * create also takes: --daemon-host, --agent <name>[:backend] (repeatable; first
34
- * is the worker), --global-backend <host>:<backend>, --worktree,
35
- * --remote-worktree <host>, --persistent
36
+ * is the worker), --global-backend <backend>@<host>, --worktree, --persistent
36
37
  * send also takes: --attach FILE (repeatable)
37
38
  * messages also takes: --follow [--interval <sec>] [--until-idle]
38
39
  * list also takes (without --archived): --all-projects, --project-ids a,b
@@ -351,6 +352,16 @@ async function handleList(argv, deps) {
351
352
  }
352
353
 
353
354
  async function handleCreate(argv, deps) {
355
+ // Removed flag: an unknown argument only warns, and would start a plain local task.
356
+ if (argv.remoteWorktree !== undefined) {
357
+ const err = new Error(
358
+ "--remote-worktree was removed. Create the task in the project on the daemon that holds the code, "
359
+ + "with --global-backend <backend>@<ai-host> --worktree: the host now names the daemon that runs the AI, "
360
+ + "not the one that holds the code.",
361
+ );
362
+ err.code = "ARGS";
363
+ throw err;
364
+ }
354
365
  const apis = await buildApis(deps);
355
366
  const project = await resolveProject(apis, {
356
367
  env: deps.env,
@@ -774,9 +785,9 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
774
785
  array: true,
775
786
  describe: "Multi-agent group member <name>[:backend]; repeat it. The first is the worker, the rest reviewers",
776
787
  })
777
- .option("global-backend", { type: "string", describe: "Run the AI on a global backend <host>:<backend> (RFC 0041)" })
788
+ .option("global-backend", { type: "string", describe: "Run the AI on a global AI backend from settings, as <backend>@<host>" })
778
789
  .option("worktree", { type: "boolean", describe: "Run in a new git worktree on the project's daemon" })
779
- .option("remote-worktree", { type: "string", describe: "Run in a new git worktree on this remote daemon" })
790
+ .option("remote-worktree", { type: "string", hidden: true })
780
791
  .option("persistent", { type: "boolean", describe: "Create a persistent task (RFC 0039)" }),
781
792
  async (argv) => {
782
793
  exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
@@ -862,7 +873,8 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
862
873
  .option("strategy", { choices: ["inplace", "new_task"], describe: "inplace resumes the session; new_task forks a successor" })
863
874
  .option("backend", { type: "string", describe: "Switch to this AI backend" })
864
875
  .option("refresh-session", { type: "boolean", default: false, describe: "Start a fresh AI session in place" })
865
- .option("daemon-host", { type: "string", describe: "Run the restarted session on this daemon" })
876
+ .option("daemon-host", { type: "string", describe: "Run the restarted AI on this daemon; with --strategy new_task it keeps working on the source task's code remotely when it can (add --code-host <same> to move the work there instead)" })
877
+ .option("code-host", { type: "string", describe: "With --strategy new_task: daemon holding the code (default: the source task's); another daemon moves the work there and needs --daemon-host set to it" })
866
878
  .option("first-message", { type: "string", describe: "With --strategy new_task: first message instead of the transcript handoff" }),
867
879
  run(extra.handleRestart),
868
880
  )
@@ -932,6 +944,14 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
932
944
  (cmd) => cmd.positional("id", { type: "string", demandOption: true }),
933
945
  run(extra.handleShare),
934
946
  )
947
+ .command(
948
+ "preview <id> <path>",
949
+ "Open a temporary link to a file on the task's daemon (HTML, Markdown, images…)",
950
+ (cmd) => cmd
951
+ .positional("id", { type: "string", demandOption: true })
952
+ .positional("path", { type: "string", demandOption: true, describe: "Absolute, ~/ or relative to the task's working directory" }),
953
+ run(extra.handlePreview),
954
+ )
935
955
  .command(
936
956
  "unshare <id>",
937
957
  "Revoke a task's share link",
@@ -952,6 +972,14 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
952
972
  .option("language", { type: "string", describe: "Language hint, e.g. zh or en" }),
953
973
  run(extra.handleTranscribe),
954
974
  )
975
+ .command(
976
+ "speak <text>",
977
+ "Text to speech as .mp3 (the web voice mode's reply voice)",
978
+ (cmd) => cmd
979
+ .positional("text", { type: "string", demandOption: true })
980
+ .option("output", { alias: "o", type: "string", describe: "Output file (default speech.mp3), or - for stdout" }),
981
+ run(extra.handleSpeak),
982
+ )
955
983
  .command(
956
984
  "persistent <id>",
957
985
  "Change persistent-task settings (RFC 0039)",
@@ -17,6 +17,7 @@ import {
17
17
  fetchLatestVersion,
18
18
  isNewerVersion,
19
19
  detectPackageManager,
20
+ resolveBundledNodeDir,
20
21
  resolveGlobalInstallPrefix,
21
22
  } from "../src/version-check.js";
22
23
  import {
@@ -31,6 +32,7 @@ const PKG_ROOT = path.join(__dirname, "..");
31
32
 
32
33
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
33
34
  const CURRENT_VERSION = pkgJson.version;
35
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
34
36
  const INSTALL_METHOD = resolveInstallMethod({
35
37
  env: process.env,
36
38
  packageRoot: PKG_ROOT,
@@ -176,6 +178,11 @@ async function performUpdate() {
176
178
  console.log("");
177
179
  }
178
180
 
181
+ const nodeDir = resolveBundledNodeDir();
182
+ if (nodeDir && !process.env.npm_config_nodedir) {
183
+ process.env.npm_config_nodedir = nodeDir;
184
+ }
185
+
179
186
  if (packageManager === "pnpm") {
180
187
  console.log(" Preparing pnpm native dependency allowlist...");
181
188
  await ensurePnpmOnlyBuiltDependencies({
@@ -212,16 +219,26 @@ async function performUpdate() {
212
219
  stdio: "inherit",
213
220
  shell: true
214
221
  });
222
+ // npm never gives up on a registry connection that stalls mid-handshake.
223
+ let timedOut = false;
224
+ const timer = setTimeout(() => {
225
+ timedOut = true;
226
+ child.kill("SIGTERM");
227
+ }, INSTALL_TIMEOUT_MS);
215
228
 
216
229
  child.on("close", (code) => {
230
+ clearTimeout(timer);
217
231
  if (code === 0) {
218
232
  resolve();
233
+ } else if (timedOut) {
234
+ reject(new Error(`${cmd} did not finish within ${INSTALL_TIMEOUT_MS / 60_000} minutes`));
219
235
  } else {
220
236
  reject(new Error(`Exit code ${code}`));
221
237
  }
222
238
  });
223
239
 
224
240
  child.on("error", (error) => {
241
+ clearTimeout(timer);
225
242
  reject(error);
226
243
  });
227
244
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.16.0",
4
- "gitCommitId": "77239b9",
3
+ "version": "0.17.0",
4
+ "gitCommitId": "2799bc0",
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.16.0",
28
- "@love-moon/conductor-sdk": "0.16.0",
27
+ "@love-moon/ai-sdk": "0.17.0",
28
+ "@love-moon/conductor-sdk": "0.17.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.16.0"
41
+ "@love-moon/chat-web": "0.17.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
@@ -134,13 +134,19 @@ export function createBackendHttp({ config, fetchImpl, env = process.env }) {
134
134
  return payload;
135
135
  }
136
136
 
137
- /** GET a binary body; resolves to the raw fetch Response (caller streams it). */
137
+ /** Fetch a binary body (GET, or POST with `options.body` as JSON); resolves to the raw Response. */
138
138
  async function download(pathname, options = {}) {
139
+ const hasBody = options.body !== undefined;
139
140
  let response;
140
141
  try {
141
142
  response = await doFetch(buildUrl(pathname, options.query), {
142
- method: "GET",
143
- headers: { Authorization: `Bearer ${config.agentToken}`, Accept: "*/*" },
143
+ method: hasBody ? "POST" : "GET",
144
+ headers: {
145
+ Authorization: `Bearer ${config.agentToken}`,
146
+ Accept: "*/*",
147
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
148
+ },
149
+ ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
144
150
  });
145
151
  } catch (error) {
146
152
  throw new BackendHttpError(`Download failed: ${error?.message || String(error)}`, undefined, undefined);
@@ -11,6 +11,7 @@ import {
11
11
  detectPackageManager,
12
12
  fetchLatestVersion,
13
13
  isNewerVersion,
14
+ resolveBundledNodeDir,
14
15
  resolveGlobalInstallPrefix,
15
16
  } from "./version-check.js";
16
17
  import {
@@ -283,6 +284,8 @@ function buildInstallEnv(packageManager, packageRoot, env) {
283
284
  const prefix = resolveGlobalInstallPrefix(packageRoot);
284
285
  if (prefix) installEnv.npm_config_prefix = prefix;
285
286
  }
287
+ const nodeDir = resolveBundledNodeDir();
288
+ if (nodeDir && !installEnv.npm_config_nodedir) installEnv.npm_config_nodedir = nodeDir;
286
289
  return installEnv;
287
290
  }
288
291
 
package/src/daemon.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
48
48
  import {
49
49
  REMOTE_FILE_CAPABILITY,
50
+ REMOTE_FILE_PREVIEW_CAPABILITY,
50
51
  createRemoteFileHandlers,
51
52
  handleRemoteFileRequest,
52
53
  } from "./remote-file-handlers.js";
@@ -68,7 +69,7 @@ import {
68
69
  normalizeRuntimeBackendAlias,
69
70
  normalizeRuntimeBackendName,
70
71
  } from "./runtime-backends.js";
71
- import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
72
+ import { buildClaudeRootSandboxNotice, claudeCommandNeedsRootSandbox } from "@love-moon/ai-sdk";
72
73
  import {
73
74
  PACKAGE_NAME,
74
75
  buildUpgradeCommand,
@@ -573,15 +574,20 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
573
574
  }
574
575
 
575
576
  // A tool-preset PTY task runs the configured allow_cli_list command through a
576
- // login shell, bypassing ai-sdk entirely. claude refuses to start as root with
577
- // `--dangerously-skip-permissions`, so rewrite the command the same way the
578
- // ai-sdk session rewrites its permission mode — one shared root check, so the
579
- // two paths cannot drift.
580
- export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
577
+ // login shell, bypassing ai-sdk entirely. The command is never rewritten: as
578
+ // root without IS_SANDBOX=1, claude refuses `--dangerously-skip-permissions`
579
+ // and exits, so print the same IS_SANDBOX hint the chat path shows, right in
580
+ // the terminal before claude's own error.
581
+ export function resolvePtyToolPresetCommand(cliCommand, env = process.env, options = {}) {
581
582
  if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
582
583
  return cliCommand;
583
584
  }
584
- return resolveClaudeCommandForRoot(cliCommand, env);
585
+ if (!claudeCommandNeedsRootSandbox(cliCommand, env)) {
586
+ return cliCommand;
587
+ }
588
+ const notice = buildClaudeRootSandboxNotice({ configFile: options.configFile });
589
+ const quoted = `'${notice.replace(/'/g, "'\\''")}'`;
590
+ return `printf '%s\\n\\n' ${quoted} >&2; ${cliCommand}`;
585
591
  }
586
592
 
587
593
  export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
@@ -989,6 +995,21 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
989
995
  };
990
996
  }
991
997
 
998
+ const CLAUDE_ROOT_OPT_OUT_ENV_KEYS = ["IS_SANDBOX", "CLAUDE_CODE_BUBBLEWRAP"];
999
+
1000
+ function pickClaudeRootOptOutEnv(configEnv) {
1001
+ const picked = {};
1002
+ if (!configEnv || typeof configEnv !== "object") {
1003
+ return picked;
1004
+ }
1005
+ for (const key of CLAUDE_ROOT_OPT_OUT_ENV_KEYS) {
1006
+ if (configEnv[key] !== undefined && configEnv[key] !== null) {
1007
+ picked[key] = String(configEnv[key]);
1008
+ }
1009
+ }
1010
+ return picked;
1011
+ }
1012
+
992
1013
  // Module-level so the PTY launch wiring is directly testable: the tool-preset
993
1014
  // branch has to hand the *child's* env to the root check, and that seam is
994
1015
  // exactly where a bug hid before (the check read the daemon's own process.env,
@@ -1037,9 +1058,17 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
1037
1058
  if (!cliCommand) {
1038
1059
  throw new Error(`Unsupported tool preset: ${toolPreset}`);
1039
1060
  }
1040
- const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
1061
+ // The PTY child inherits the daemon env, not the config's `envs:` block,
1062
+ // yet the root hint tells users to set `envs.IS_SANDBOX: "1"`. Forward
1063
+ // just claude's root opt-outs so that advice works for terminal tasks too.
1064
+ // launch_config.env still wins, matching the spawned env.
1065
+ const isClaudePreset = inferBuiltInRuntimeBackendFromCommand(cliCommand) === "claude";
1066
+ const launchEnv = isClaudePreset ? { ...pickClaudeRootOptOutEnv(deps.configEnv), ...env } : env;
1067
+ const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, launchEnv), {
1068
+ configFile: deps.configFile,
1069
+ });
1041
1070
  if (launchCommand !== cliCommand) {
1042
- log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
1071
+ log(`[pty] ${toolPreset} runs as root without IS_SANDBOX=1; claude will refuse bypassPermissions`);
1043
1072
  }
1044
1073
  return {
1045
1074
  entrypointType,
@@ -1048,7 +1077,7 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
1048
1077
  args: ["-lc", launchCommand],
1049
1078
  shell: preferredShell,
1050
1079
  cwd,
1051
- env,
1080
+ env: launchEnv,
1052
1081
  cols,
1053
1082
  rows,
1054
1083
  };
@@ -3909,7 +3938,7 @@ export function startDaemon(config = {}, deps = {}) {
3909
3938
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3910
3939
  }
3911
3940
  if (remoteFileEnabled) {
3912
- advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
3941
+ advertisedCapabilities.push(REMOTE_FILE_CAPABILITY, REMOTE_FILE_PREVIEW_CAPABILITY);
3913
3942
  } else {
3914
3943
  log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
3915
3944
  }
@@ -3985,6 +4014,15 @@ export function startDaemon(config = {}, deps = {}) {
3985
4014
  config: sdkConfig,
3986
4015
  agentHost: AGENT_NAME,
3987
4016
  guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
4017
+ // Every Fire logs to `<task dir>/conductor.log`, so the log path is the
4018
+ // one place the working directory survives a daemon restart (adopted
4019
+ // tmux records carry it too).
4020
+ resolveTaskCwd: (taskId) => {
4021
+ const ptyCwd = activePtySessions.get(taskId)?.cwd;
4022
+ if (ptyCwd) return ptyCwd;
4023
+ const logPath = activeTaskProcesses.get(taskId)?.logPath;
4024
+ return logPath ? path.dirname(logPath) : null;
4025
+ },
3988
4026
  })
3989
4027
  : null;
3990
4028
 
@@ -5554,6 +5592,8 @@ export function startDaemon(config = {}, deps = {}) {
5554
5592
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
5555
5593
  return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
5556
5594
  allowCliList: ALLOW_CLI_LIST,
5595
+ configEnv: userConfig?.envs,
5596
+ configFile: effectiveConfigPath,
5557
5597
  supportedBackends: SUPPORTED_BACKENDS,
5558
5598
  existsSync: existsSyncFn,
5559
5599
  log,
@@ -139,9 +139,9 @@ export function buildRemoteWorktreeSessionOptions({
139
139
  ...(sessionOptions.mcpServers || {}),
140
140
  [MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
141
141
  },
142
- // Under the default bypassPermissions a root install is downgraded to
143
- // acceptEdits, which would deny every remote_* call headless. A mode the
144
- // user chose on purpose is left alone rather than widened.
142
+ // Pre-approve the remote_* tools under the default bypassPermissions so
143
+ // they keep working if claude ever runs in a prompting mode headless. A
144
+ // mode the user chose on purpose is left alone rather than widened.
145
145
  ...(mode === undefined || mode === "bypassPermissions"
146
146
  ? { allowedTools: [...new Set([...allowed, `mcp__${MCP_SERVER_NAME}`])] }
147
147
  : {}),
@@ -38,6 +38,13 @@ const MAX_CONCURRENT_TRANSFERS = 4;
38
38
  const NUL = String.fromCharCode(0);
39
39
 
40
40
  export const REMOTE_FILE_CAPABILITY = "remote_file";
41
+ /**
42
+ * This daemon enforces `rootPath` on `push` and resolves a relative path against
43
+ * a task's working directory. The backend must see it before opening a file
44
+ * preview: an older daemon would silently ignore `rootPath` and serve the whole
45
+ * disk to a preview link.
46
+ */
47
+ export const REMOTE_FILE_PREVIEW_CAPABILITY = "remote_file_preview";
41
48
 
42
49
  /**
43
50
  * Move single files between this daemon's disk and the account that owns it,
@@ -68,6 +75,7 @@ export const REMOTE_FILE_CAPABILITY = "remote_file";
68
75
  * @param {number} [opts.chunkBytes]
69
76
  * @param {(ms:number)=>Promise<unknown>} [opts.sleep] backoff hook, injectable for tests
70
77
  * @param {string|null} [opts.guestRoot] when set, paths outside it are refused
78
+ * @param {(taskId:string)=>string|null} [opts.resolveTaskCwd] working directory of a live task
71
79
  */
72
80
  export function createRemoteFileHandlers(opts = {}) {
73
81
  const config = opts.config || null;
@@ -77,6 +85,7 @@ export function createRemoteFileHandlers(opts = {}) {
77
85
  const chunkBytes = resolveRemoteChunkBytes(opts.chunkBytes);
78
86
  const sleep = typeof opts.sleep === "function" ? opts.sleep : delay;
79
87
  const guestRoot = opts.guestRoot || null;
88
+ const resolveTaskCwd = typeof opts.resolveTaskCwd === "function" ? opts.resolveTaskCwd : () => null;
80
89
  // One controller for the whole daemon: `abortAll()` on shutdown tears down
81
90
  // every in-flight stream instead of letting them hold the process open.
82
91
  const shutdownController = new AbortController();
@@ -114,14 +123,57 @@ export function createRemoteFileHandlers(opts = {}) {
114
123
  * mistakes rather than a determined escape — same caveat as everywhere else
115
124
  * in this repo.
116
125
  */
117
- function resolveRemotePath(value) {
118
- const resolved = normalizeRemotePath(value);
126
+ function resolveRemotePath(value, taskId) {
127
+ const resolved = normalizeRemotePath(taskId ? resolveAgainstTask(value, taskId) : value);
119
128
  if (guestRoot && !isPathInsideGuestRoot(resolved, guestRoot)) {
120
129
  throw new Error(`path is outside this daemon's shared root: ${resolved}`);
121
130
  }
122
131
  return resolved;
123
132
  }
124
133
 
134
+ /** A link the AI wrote as `docs/report.md` means "relative to where I ran". */
135
+ function resolveAgainstTask(value, taskId) {
136
+ const raw = typeof value === "string" ? value.trim() : "";
137
+ if (!raw || path.isAbsolute(raw) || raw === "~" || raw.startsWith("~/")) return value;
138
+ const cwd = resolveTaskCwd(String(taskId));
139
+ if (!cwd) {
140
+ throw new Error(`cannot resolve relative path ${raw}: task ${taskId} is not running on this daemon`);
141
+ }
142
+ return path.join(cwd, raw);
143
+ }
144
+
145
+ /**
146
+ * Confine a preview read to `rootPath`. Unlike the guest-root check this one
147
+ * follows symlinks: the paths under a preview link are chosen by the page
148
+ * being previewed, not typed by the machine's owner, so a symlink into
149
+ * `~/.ssh` must not be a way out. Dotfiles below the root are refused for the
150
+ * same reason (`.env`, `.git/config`).
151
+ */
152
+ async function resolveInsideRoot(source, rootPath) {
153
+ let real;
154
+ let root;
155
+ try {
156
+ root = await fsp.realpath(normalizeRemotePath(rootPath));
157
+ real = await fsp.realpath(source);
158
+ } catch (error) {
159
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
160
+ throw new Error(`no such file: ${source}`);
161
+ }
162
+ throw error;
163
+ }
164
+ const relative = path.relative(root, real);
165
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
166
+ throw new Error(`path is outside the preview root: ${source}`);
167
+ }
168
+ if (relative.split(path.sep).some((segment) => segment.startsWith("."))) {
169
+ throw new Error(`hidden files are not available to preview: ${source}`);
170
+ }
171
+ if (guestRoot && !isPathInsideGuestRoot(real, await fsp.realpath(guestRoot))) {
172
+ throw new Error(`path is outside this daemon's shared root: ${source}`);
173
+ }
174
+ return real;
175
+ }
176
+
125
177
  async function acquireSlot() {
126
178
  if (activeTransfers >= MAX_CONCURRENT_TRANSFERS) {
127
179
  throw new Error(
@@ -306,7 +358,10 @@ export function createRemoteFileHandlers(opts = {}) {
306
358
  async function push(args = {}) {
307
359
  const transferId = requireId(args.transferId ?? args.transfer_id, "transferId");
308
360
  const transferToken = requireId(args.transferToken ?? args.transfer_token, "transferToken");
309
- const source = resolveRemotePath(args.remotePath ?? args.remote_path);
361
+ let source = resolveRemotePath(args.remotePath ?? args.remote_path);
362
+ const rootPath = args.rootPath ?? args.root_path;
363
+ if (rootPath) source = await resolveInsideRoot(source, rootPath);
364
+ const limit = Math.min(maxBytes, toPositiveInt(args.maxBytes ?? args.max_bytes) || maxBytes);
310
365
 
311
366
  const stat = await statOrNull(source);
312
367
  if (!stat) {
@@ -318,8 +373,8 @@ export function createRemoteFileHandlers(opts = {}) {
318
373
  if (!stat.isFile()) {
319
374
  throw new Error(`${source} is not a regular file`);
320
375
  }
321
- if (stat.size > maxBytes) {
322
- throw new Error(`${source} is ${stat.size} bytes, over the ${maxBytes} byte limit`);
376
+ if (stat.size > limit) {
377
+ throw new Error(`${source} is ${stat.size} bytes, over the ${limit} byte limit`);
323
378
  }
324
379
 
325
380
  await acquireSlot();
@@ -414,11 +469,12 @@ export function createRemoteFileHandlers(opts = {}) {
414
469
 
415
470
  /** Probe a path. A missing path is an answer, not an error. */
416
471
  async function stat(args = {}) {
417
- const target = resolveRemotePath(args.remotePath ?? args.remote_path);
472
+ const target = resolveRemotePath(args.remotePath ?? args.remote_path, args.taskId ?? args.task_id);
418
473
  const info = await statOrNull(target);
419
474
  if (!info) {
420
475
  return {
421
476
  path: target,
477
+ realPath: null,
422
478
  exists: false,
423
479
  isFile: false,
424
480
  isDirectory: false,
@@ -429,6 +485,9 @@ export function createRemoteFileHandlers(opts = {}) {
429
485
  }
430
486
  return {
431
487
  path: target,
488
+ // Symlinks resolved: a preview roots itself here, so that the root it
489
+ // later enforces is the directory the file really lives in.
490
+ realPath: await fsp.realpath(target),
432
491
  exists: true,
433
492
  isFile: info.isFile(),
434
493
  isDirectory: info.isDirectory(),
@@ -102,6 +102,10 @@ export async function handleRestart(argv, deps) {
102
102
  if (trimmed(argv.backend)) body.backend_type = trimmed(argv.backend);
103
103
  if (argv.refreshSession) body.restart_mode = "refresh_session";
104
104
  if (trimmed(argv.daemonHost)) body.agent_host = trimmed(argv.daemonHost);
105
+ if (trimmed(argv.codeHost)) {
106
+ if (argv.strategy !== "new_task") throw argsError("--code-host requires --strategy new_task");
107
+ body.code_host = trimmed(argv.codeHost);
108
+ }
105
109
  if (trimmed(argv.firstMessage)) {
106
110
  if (argv.strategy !== "new_task") throw argsError("--first-message requires --strategy new_task");
107
111
  body.first_message = trimmed(argv.firstMessage);
@@ -248,6 +252,15 @@ export async function handleShare(argv, deps) {
248
252
  return print(deps, argv, url ? { ...data, url } : data, url ? `Share link: ${url}` : `Shared task ${argv.id}`);
249
253
  }
250
254
 
255
+ /** Open a temporary preview link for a file on the task's daemon (what clicking a file link in the chat does). */
256
+ export async function handlePreview(argv, deps) {
257
+ const http = await buildHttp(deps);
258
+ const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "preview"), { path: argv.path });
259
+ if (dryRun) return EXIT.OK;
260
+ const url = data?.viewUrl ? `${http.baseUrl}${data.viewUrl}` : null;
261
+ return print(deps, argv, url ? { ...data, url } : data, url ? `Preview link (expires after 5 idle minutes): ${url}` : `No preview link for ${argv.path}`);
262
+ }
263
+
251
264
  export async function handleUnshare(argv, deps) {
252
265
  const http = await buildHttp(deps);
253
266
  const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", taskPath(argv.id, "share"));
@@ -317,6 +330,31 @@ export async function handleTranscribe(argv, deps) {
317
330
  return print(deps, argv, data, String(data?.text ?? ""));
318
331
  }
319
332
 
333
+ /** Text to speech (the web voice mode's reply voice); saves an .mp3. */
334
+ export async function handleSpeak(argv, deps) {
335
+ const text = trimmed(argv.text);
336
+ if (!text) throw argsError("Text is required");
337
+ const http = await buildHttp(deps);
338
+ if (argv.dryRun) {
339
+ return sendOrPreview(http, argv, deps, "POST", apiPath("speech", "synthesize"), { text, format: "mp3" }).then(() => EXIT.OK);
340
+ }
341
+ const response = await http.download(apiPath("speech", "synthesize"), { body: { text, format: "mp3" } });
342
+ let bytes;
343
+ try {
344
+ bytes = Buffer.from(await response.arrayBuffer());
345
+ } catch (error) {
346
+ // Synthesis failed mid-stream: never save a truncated file as success.
347
+ throw new Error(`Speech synthesis was interrupted: ${error?.message || String(error)}`);
348
+ }
349
+ if (argv.output === "-") {
350
+ deps.stdout.write(bytes);
351
+ return EXIT.OK;
352
+ }
353
+ const target = path.resolve(deps.cwd || process.cwd(), argv.output ? String(argv.output) : "speech.mp3");
354
+ fs.writeFileSync(target, bytes);
355
+ return print(deps, argv, { path: target, bytes: bytes.length }, `Saved ${bytes.length} bytes to ${target}`);
356
+ }
357
+
320
358
  // ---- persistent tasks (RFC 0039) -----------------------------------------
321
359
 
322
360
  export async function handlePersistent(argv, deps) {
@@ -568,14 +606,21 @@ export function parseAgents(values) {
568
606
  });
569
607
  }
570
608
 
609
+ /** `claude@l20`, as the web UI and `issue start` write it; the older `l20:claude` still parses. */
571
610
  export function parseGlobalBackend(value) {
572
611
  const raw = trimmed(value);
573
612
  if (!raw) return undefined;
574
- const index = raw.indexOf(":");
575
- const host = index > 0 ? raw.slice(0, index).trim() : "";
576
- const backend = index > 0 ? raw.slice(index + 1).trim() : "";
577
- if (!host || !backend) throw argsError("--global-backend must look like <host>:<backend>, e.g. l20:claude");
578
- return { host, backend };
613
+ const at = raw.indexOf("@");
614
+ const colon = raw.indexOf(":");
615
+ const [backend, host] = at > 0
616
+ ? [raw.slice(0, at), raw.slice(at + 1)]
617
+ : colon > 0
618
+ ? [raw.slice(colon + 1), raw.slice(0, colon)]
619
+ : ["", ""];
620
+ if (!host.trim() || !backend.trim()) {
621
+ throw argsError("--global-backend must look like <backend>@<host>, e.g. claude@l20");
622
+ }
623
+ return { host: host.trim(), backend: backend.trim().toLowerCase() };
579
624
  }
580
625
 
581
626
  /** True when `task create` needs the full frontend payload rather than the SDK subset. */
@@ -585,24 +630,16 @@ export function hasExtendedCreateOptions(argv) {
585
630
  || (argv.agent && [].concat(argv.agent).length)
586
631
  || trimmed(argv.globalBackend)
587
632
  || argv.worktree
588
- || trimmed(argv.remoteWorktree)
589
633
  || argv.persistent,
590
634
  );
591
635
  }
592
636
 
593
637
  export function buildExtendedCreateBody(argv, deps, projectId, title) {
594
- if (argv.worktree && trimmed(argv.remoteWorktree)) {
595
- throw argsError("Pass --worktree or --remote-worktree, not both");
596
- }
597
638
  const globalBackend = parseGlobalBackend(argv.globalBackend);
598
639
  const agents = parseAgents(argv.agent);
599
640
  if (globalBackend && (agents.length || trimmed(argv.daemonHost))) {
600
641
  throw argsError("--global-backend cannot be combined with --agent or --daemon-host");
601
642
  }
602
- // Both are rejected by POST /api/tasks; fail before the round trip.
603
- if (globalBackend && trimmed(argv.remoteWorktree)) {
604
- throw argsError("--global-backend cannot be combined with --remote-worktree");
605
- }
606
643
  if (globalBackend && trimmed(argv.backend) && trimmed(argv.backend) !== globalBackend.backend) {
607
644
  throw argsError(
608
645
  `--backend ${trimmed(argv.backend)} does not match --global-backend backend ${globalBackend.backend}; drop --backend`,
@@ -625,8 +662,7 @@ export function buildExtendedCreateBody(argv, deps, projectId, title) {
625
662
  if (trimmed(argv.backend)) body.backendType = trimmed(argv.backend);
626
663
  }
627
664
  if (agents.length) body.agents = agents;
628
- if (trimmed(argv.remoteWorktree)) body.launchConfig = { remoteWorktree: { host: trimmed(argv.remoteWorktree) } };
629
- else if (argv.worktree) body.launchConfig = { worktree: true };
665
+ if (argv.worktree) body.launchConfig = { worktree: true };
630
666
  return body;
631
667
  }
632
668
 
@@ -162,6 +162,16 @@ export function resolveGlobalInstallPrefix(packageRoot) {
162
162
  return normalized.slice(0, markerIndex);
163
163
  }
164
164
 
165
+ /**
166
+ * The directory of the running Node when it ships its own headers, which official tarballs, nvm
167
+ * and Homebrew all do. Handed to node-gyp as `npm_config_nodedir`, it compiles node-pty against
168
+ * those instead of downloading the headers from nodejs.org.
169
+ */
170
+ export function resolveBundledNodeDir(nodeExecutable = process.execPath, { existsSync = fs.existsSync } = {}) {
171
+ const nodeDir = path.dirname(path.dirname(nodeExecutable));
172
+ return existsSync(path.join(nodeDir, "include", "node", "common.gypi")) ? nodeDir : null;
173
+ }
174
+
165
175
  export function buildUpgradeCommand(options = {}) {
166
176
  const installMethod = resolveInstallMethod(options);
167
177
  if (installMethod === "homebrew") {