@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.
- package/CHANGELOG.md +84 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-fire.js +377 -3
- package/bin/conductor-task.js +36 -7
- package/bin/conductor-update.js +17 -0
- package/package.json +5 -5
- package/src/backend-http.js +9 -3
- package/src/daemon-update.js +3 -0
- package/src/daemon.js +51 -11
- package/src/fire/quota-resume.js +313 -0
- package/src/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +57 -15
- package/src/version-check.js +10 -0
|
@@ -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
|
-
|
|
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 >
|
|
322
|
-
throw new Error(`${source} is ${stat.size} bytes, over the ${
|
|
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(),
|
package/src/task-commands.js
CHANGED
|
@@ -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
|
|
575
|
-
const
|
|
576
|
-
const backend =
|
|
577
|
-
|
|
578
|
-
|
|
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 (
|
|
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
|
|
package/src/version-check.js
CHANGED
|
@@ -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") {
|