@love-moon/conductor-cli 0.16.0 → 0.17.1

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.
@@ -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,10 +102,20 @@ 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);
108
112
  }
113
+ if (argv.newWorktree) {
114
+ if (argv.strategy !== "new_task") throw argsError("--new-worktree requires --strategy new_task");
115
+ // A fresh worktree does not load the source conversation; the first message replaces it.
116
+ if (!trimmed(argv.firstMessage)) throw argsError("--new-worktree requires --first-message");
117
+ body.new_worktree = true;
118
+ }
109
119
  if (argv.refreshSession && argv.strategy) {
110
120
  throw argsError("--refresh-session cannot be combined with --strategy");
111
121
  }
@@ -248,6 +258,15 @@ export async function handleShare(argv, deps) {
248
258
  return print(deps, argv, url ? { ...data, url } : data, url ? `Share link: ${url}` : `Shared task ${argv.id}`);
249
259
  }
250
260
 
261
+ /** Open a temporary preview link for a file on the task's daemon (what clicking a file link in the chat does). */
262
+ export async function handlePreview(argv, deps) {
263
+ const http = await buildHttp(deps);
264
+ const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", taskPath(argv.id, "preview"), { path: argv.path });
265
+ if (dryRun) return EXIT.OK;
266
+ const url = data?.viewUrl ? `${http.baseUrl}${data.viewUrl}` : null;
267
+ return print(deps, argv, url ? { ...data, url } : data, url ? `Preview link (expires after 5 idle minutes): ${url}` : `No preview link for ${argv.path}`);
268
+ }
269
+
251
270
  export async function handleUnshare(argv, deps) {
252
271
  const http = await buildHttp(deps);
253
272
  const { dryRun, data } = await sendOrPreview(http, argv, deps, "DELETE", taskPath(argv.id, "share"));
@@ -317,6 +336,31 @@ export async function handleTranscribe(argv, deps) {
317
336
  return print(deps, argv, data, String(data?.text ?? ""));
318
337
  }
319
338
 
339
+ /** Text to speech (the web voice mode's reply voice); saves an .mp3. */
340
+ export async function handleSpeak(argv, deps) {
341
+ const text = trimmed(argv.text);
342
+ if (!text) throw argsError("Text is required");
343
+ const http = await buildHttp(deps);
344
+ if (argv.dryRun) {
345
+ return sendOrPreview(http, argv, deps, "POST", apiPath("speech", "synthesize"), { text, format: "mp3" }).then(() => EXIT.OK);
346
+ }
347
+ const response = await http.download(apiPath("speech", "synthesize"), { body: { text, format: "mp3" } });
348
+ let bytes;
349
+ try {
350
+ bytes = Buffer.from(await response.arrayBuffer());
351
+ } catch (error) {
352
+ // Synthesis failed mid-stream: never save a truncated file as success.
353
+ throw new Error(`Speech synthesis was interrupted: ${error?.message || String(error)}`);
354
+ }
355
+ if (argv.output === "-") {
356
+ deps.stdout.write(bytes);
357
+ return EXIT.OK;
358
+ }
359
+ const target = path.resolve(deps.cwd || process.cwd(), argv.output ? String(argv.output) : "speech.mp3");
360
+ fs.writeFileSync(target, bytes);
361
+ return print(deps, argv, { path: target, bytes: bytes.length }, `Saved ${bytes.length} bytes to ${target}`);
362
+ }
363
+
320
364
  // ---- persistent tasks (RFC 0039) -----------------------------------------
321
365
 
322
366
  export async function handlePersistent(argv, deps) {
@@ -568,14 +612,21 @@ export function parseAgents(values) {
568
612
  });
569
613
  }
570
614
 
615
+ /** `claude@l20`, as the web UI and `issue start` write it; the older `l20:claude` still parses. */
571
616
  export function parseGlobalBackend(value) {
572
617
  const raw = trimmed(value);
573
618
  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 };
619
+ const at = raw.indexOf("@");
620
+ const colon = raw.indexOf(":");
621
+ const [backend, host] = at > 0
622
+ ? [raw.slice(0, at), raw.slice(at + 1)]
623
+ : colon > 0
624
+ ? [raw.slice(colon + 1), raw.slice(0, colon)]
625
+ : ["", ""];
626
+ if (!host.trim() || !backend.trim()) {
627
+ throw argsError("--global-backend must look like <backend>@<host>, e.g. claude@l20");
628
+ }
629
+ return { host: host.trim(), backend: backend.trim().toLowerCase() };
579
630
  }
580
631
 
581
632
  /** True when `task create` needs the full frontend payload rather than the SDK subset. */
@@ -585,24 +636,16 @@ export function hasExtendedCreateOptions(argv) {
585
636
  || (argv.agent && [].concat(argv.agent).length)
586
637
  || trimmed(argv.globalBackend)
587
638
  || argv.worktree
588
- || trimmed(argv.remoteWorktree)
589
639
  || argv.persistent,
590
640
  );
591
641
  }
592
642
 
593
643
  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
644
  const globalBackend = parseGlobalBackend(argv.globalBackend);
598
645
  const agents = parseAgents(argv.agent);
599
646
  if (globalBackend && (agents.length || trimmed(argv.daemonHost))) {
600
647
  throw argsError("--global-backend cannot be combined with --agent or --daemon-host");
601
648
  }
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
649
  if (globalBackend && trimmed(argv.backend) && trimmed(argv.backend) !== globalBackend.backend) {
607
650
  throw argsError(
608
651
  `--backend ${trimmed(argv.backend)} does not match --global-backend backend ${globalBackend.backend}; drop --backend`,
@@ -625,8 +668,7 @@ export function buildExtendedCreateBody(argv, deps, projectId, title) {
625
668
  if (trimmed(argv.backend)) body.backendType = trimmed(argv.backend);
626
669
  }
627
670
  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 };
671
+ if (argv.worktree) body.launchConfig = { worktree: true };
630
672
  return body;
631
673
  }
632
674
 
@@ -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") {