@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 +67 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-fire.js +91 -2
- package/bin/conductor-task.js +34 -6
- 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/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +51 -15
- package/src/version-check.js +10 -0
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 `` / ``. 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
|
package/bin/conductor-config.js
CHANGED
|
@@ -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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// daemon
|
|
28
|
-
execArgs:
|
|
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
|
-
"
|
|
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",
|
package/bin/conductor-fire.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, {
|
package/bin/conductor-task.js
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
|
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
|
|
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)",
|
package/bin/conductor-update.js
CHANGED
|
@@ -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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "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.
|
|
41
|
+
"@love-moon/chat-web": "0.17.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
package/src/backend-http.js
CHANGED
|
@@ -134,13 +134,19 @@ export function createBackendHttp({ config, fetchImpl, env = process.env }) {
|
|
|
134
134
|
return payload;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
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: {
|
|
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);
|
package/src/daemon-update.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
577
|
-
// `--dangerously-skip-permissions
|
|
578
|
-
//
|
|
579
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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,
|
package/src/remote/mcp-launch.js
CHANGED
|
@@ -139,9 +139,9 @@ export function buildRemoteWorktreeSessionOptions({
|
|
|
139
139
|
...(sessionOptions.mcpServers || {}),
|
|
140
140
|
[MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
|
|
141
141
|
},
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
-
|
|
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,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
|
|
575
|
-
const
|
|
576
|
-
const backend =
|
|
577
|
-
|
|
578
|
-
|
|
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 (
|
|
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
|
|
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") {
|