@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
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> [--new-worktree]]
|
|
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,8 +873,10 @@ 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
|
|
866
|
-
.option("
|
|
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" })
|
|
878
|
+
.option("first-message", { type: "string", describe: "With --strategy new_task: first message instead of the transcript handoff" })
|
|
879
|
+
.option("new-worktree", { type: "boolean", default: false, describe: "With --strategy new_task and --first-message: run the new task in its own fresh git worktree (a parallel branch) instead of the source task's workspace, without loading the source conversation; the source keeps its place in its agent group" }),
|
|
867
880
|
run(extra.handleRestart),
|
|
868
881
|
)
|
|
869
882
|
.command(
|
|
@@ -932,6 +945,14 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
932
945
|
(cmd) => cmd.positional("id", { type: "string", demandOption: true }),
|
|
933
946
|
run(extra.handleShare),
|
|
934
947
|
)
|
|
948
|
+
.command(
|
|
949
|
+
"preview <id> <path>",
|
|
950
|
+
"Open a temporary link to a file on the task's daemon (HTML, Markdown, images…)",
|
|
951
|
+
(cmd) => cmd
|
|
952
|
+
.positional("id", { type: "string", demandOption: true })
|
|
953
|
+
.positional("path", { type: "string", demandOption: true, describe: "Absolute, ~/ or relative to the task's working directory" }),
|
|
954
|
+
run(extra.handlePreview),
|
|
955
|
+
)
|
|
935
956
|
.command(
|
|
936
957
|
"unshare <id>",
|
|
937
958
|
"Revoke a task's share link",
|
|
@@ -952,6 +973,14 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
|
952
973
|
.option("language", { type: "string", describe: "Language hint, e.g. zh or en" }),
|
|
953
974
|
run(extra.handleTranscribe),
|
|
954
975
|
)
|
|
976
|
+
.command(
|
|
977
|
+
"speak <text>",
|
|
978
|
+
"Text to speech as .mp3 (the web voice mode's reply voice)",
|
|
979
|
+
(cmd) => cmd
|
|
980
|
+
.positional("text", { type: "string", demandOption: true })
|
|
981
|
+
.option("output", { alias: "o", type: "string", describe: "Output file (default speech.mp3), or - for stdout" }),
|
|
982
|
+
run(extra.handleSpeak),
|
|
983
|
+
)
|
|
955
984
|
.command(
|
|
956
985
|
"persistent <id>",
|
|
957
986
|
"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.1",
|
|
4
|
+
"gitCommitId": "e3dbbad",
|
|
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.1",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.17.1",
|
|
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.1"
|
|
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,
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model quota ("session limit") handling for fire.
|
|
3
|
+
*
|
|
4
|
+
* When a backend turn fails because the subscription quota is used up, fire
|
|
5
|
+
* parks the task until the quota resets instead of failing every following
|
|
6
|
+
* message. The resume itself is a one-off Conductor scheduled message
|
|
7
|
+
* (`source: "quota_resume"`), so it survives fire/daemon restarts and shows up
|
|
8
|
+
* in the task's schedule list where a person can cancel it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const QUOTA_ERROR_PATTERNS = [
|
|
12
|
+
/hit your (?:[\w-]+ )?limit/i,
|
|
13
|
+
/usage limit (?:reached|exceeded)/i,
|
|
14
|
+
/\b(?:session|5-hour|five-hour|weekly|daily|opus|sonnet) limit\b/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
18
|
+
|
|
19
|
+
const MINUTE_MS = 60_000;
|
|
20
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
21
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
22
|
+
|
|
23
|
+
/** Used when the error names no reset time at all. */
|
|
24
|
+
export const QUOTA_FALLBACK_WAIT_MS = HOUR_MS;
|
|
25
|
+
/** Resume this long after the reset at the earliest, so the quota has really refilled. */
|
|
26
|
+
export const QUOTA_RESUME_MIN_DELAY_MS = MINUTE_MS;
|
|
27
|
+
/** Extra random spread on top, so tasks sharing one subscription do not all start at once. */
|
|
28
|
+
export const QUOTA_RESUME_JITTER_MS = 4 * MINUTE_MS;
|
|
29
|
+
/** Consecutive quota hits on resume before fire stops scheduling more. */
|
|
30
|
+
export const QUOTA_RESUME_MAX_ATTEMPTS = 3;
|
|
31
|
+
|
|
32
|
+
export const QUOTA_RESUME_PROMPT =
|
|
33
|
+
"[额度已恢复,自动继续] 模型额度用完时中断了你的工作。请继续中断前未完成的工作。";
|
|
34
|
+
|
|
35
|
+
export function isQuotaLimitError(message) {
|
|
36
|
+
const text = String(message || "");
|
|
37
|
+
return QUOTA_ERROR_PATTERNS.some((pattern) => pattern.test(text));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recognises a quota error and works out when the quota resets.
|
|
42
|
+
* Returns null for any other error; `resetAt` is null when the text names no time.
|
|
43
|
+
*/
|
|
44
|
+
export function parseQuotaLimitError(message, { now = new Date(), defaultTimeZone } = {}) {
|
|
45
|
+
const text = String(message || "");
|
|
46
|
+
if (!isQuotaLimitError(text)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return { resetAt: parseQuotaResetAt(text, { now, defaultTimeZone }) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseQuotaResetAt(text, { now = new Date(), defaultTimeZone } = {}) {
|
|
53
|
+
const nowMs = now.getTime();
|
|
54
|
+
|
|
55
|
+
// Older Claude Code: "Claude AI usage limit reached|1759561200"
|
|
56
|
+
const epoch = /limit reached\|(\d{10,13})/i.exec(text);
|
|
57
|
+
if (epoch) {
|
|
58
|
+
const value = Number(epoch[1]);
|
|
59
|
+
return new Date(epoch[1].length === 10 ? value * 1000 : value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// "try again in 2 hours 13 minutes"
|
|
63
|
+
const relative = /try again in\s+(?:(\d+)\s*(?:hours?|hrs?|h)\b)?\s*(?:(\d+)\s*(?:minutes?|mins?|m)\b)?/i.exec(text);
|
|
64
|
+
if (relative && (relative[1] || relative[2])) {
|
|
65
|
+
return new Date(nowMs + Number(relative[1] || 0) * HOUR_MS + Number(relative[2] || 0) * MINUTE_MS);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// "resets 1:20am (Asia/Shanghai)", "resets Oct 7, 2am (Asia/Shanghai)", "try again at 3:05 PM"
|
|
69
|
+
const clock =
|
|
70
|
+
/(?:resets?|try again at)\s+(?:at\s+)?(?:([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?(?:\s*\(([^)]+)\))?/i.exec(
|
|
71
|
+
text,
|
|
72
|
+
);
|
|
73
|
+
if (!clock) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const [, monthName, dayText, hourText, minuteText, meridiem, zoneText] = clock;
|
|
77
|
+
let hour = Number(hourText);
|
|
78
|
+
const minute = Number(minuteText || 0);
|
|
79
|
+
if (meridiem) {
|
|
80
|
+
const pm = meridiem.toLowerCase().startsWith("p");
|
|
81
|
+
if (hour < 1 || hour > 12) return null;
|
|
82
|
+
hour = (hour % 12) + (pm ? 12 : 0);
|
|
83
|
+
}
|
|
84
|
+
if (hour > 23 || minute > 59) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const timeZone = resolveTimeZone(zoneText) || resolveTimeZone(defaultTimeZone) || undefined;
|
|
88
|
+
const today = zonedParts(nowMs, timeZone);
|
|
89
|
+
|
|
90
|
+
if (monthName) {
|
|
91
|
+
const month = MONTHS.indexOf(monthName.slice(0, 3).toLowerCase());
|
|
92
|
+
if (month < 0) return null;
|
|
93
|
+
const day = Number(dayText);
|
|
94
|
+
let resetMs = zonedWallTimeToUtc({ year: today.year, month: month + 1, day, hour, minute }, timeZone);
|
|
95
|
+
if (resetMs < nowMs - DAY_MS) {
|
|
96
|
+
resetMs = zonedWallTimeToUtc({ year: today.year + 1, month: month + 1, day, hour, minute }, timeZone);
|
|
97
|
+
}
|
|
98
|
+
return new Date(resetMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let resetMs = zonedWallTimeToUtc({ ...today, hour, minute }, timeZone);
|
|
102
|
+
if (resetMs <= nowMs - 10 * MINUTE_MS) {
|
|
103
|
+
// A time-only reset already behind us means tomorrow.
|
|
104
|
+
resetMs = zonedWallTimeToUtc({ ...today, day: today.day + 1, hour, minute }, timeZone);
|
|
105
|
+
} else if (resetMs < nowMs) {
|
|
106
|
+
resetMs = nowMs;
|
|
107
|
+
}
|
|
108
|
+
return new Date(resetMs);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** When to send the resume message: after the reset, plus jitter. */
|
|
112
|
+
export function computeQuotaResumeAt(resetAt, { now = new Date(), random = Math.random } = {}) {
|
|
113
|
+
const nowMs = now.getTime();
|
|
114
|
+
const base = resetAt instanceof Date && Number.isFinite(resetAt.getTime())
|
|
115
|
+
? resetAt.getTime()
|
|
116
|
+
: nowMs + QUOTA_FALLBACK_WAIT_MS;
|
|
117
|
+
const jitter = Math.floor(Math.max(0, Math.min(1, Number(random()) || 0)) * QUOTA_RESUME_JITTER_MS);
|
|
118
|
+
return new Date(Math.max(base, nowMs) + QUOTA_RESUME_MIN_DELAY_MS + jitter);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The prompt for the resume turn: the resume note plus every message that has
|
|
123
|
+
* not been handled yet (the one whose turn hit the quota, then those that
|
|
124
|
+
* arrived while waiting).
|
|
125
|
+
*/
|
|
126
|
+
export function buildQuotaResumePrompt(queued = []) {
|
|
127
|
+
const parts = [QUOTA_RESUME_PROMPT];
|
|
128
|
+
const pending = queued.filter((item) => item && String(item.content || "").trim());
|
|
129
|
+
if (pending.length) {
|
|
130
|
+
parts.push(`以下 ${pending.length} 条消息还没有处理(按时间顺序):`);
|
|
131
|
+
pending.forEach((item, index) => {
|
|
132
|
+
parts.push(`--- 消息 ${index + 1} ---\n${String(item.content).trim()}`);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return parts.join("\n\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isQuotaResumePrompt(content) {
|
|
139
|
+
return String(content || "").trim().startsWith(QUOTA_RESUME_PROMPT);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const RESUME_ITEM_SEPARATOR = /\n\n--- 消息 \d+ ---\n/;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The pending messages a resume prompt lists (the inverse of
|
|
146
|
+
* buildQuotaResumePrompt), so a fire that restarted mid-wait can carry them on.
|
|
147
|
+
*/
|
|
148
|
+
export function parseQuotaResumePrompt(content) {
|
|
149
|
+
const text = String(content || "").trim();
|
|
150
|
+
if (!isQuotaResumePrompt(text)) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
return text
|
|
154
|
+
.split(RESUME_ITEM_SEPARATOR)
|
|
155
|
+
.slice(1)
|
|
156
|
+
.map((part) => part.trim())
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.map((part) => ({ messageId: null, content: part }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Concatenates pending-message lists, dropping repeats of the same text. */
|
|
162
|
+
export function mergeQueuedMessages(...lists) {
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const merged = [];
|
|
165
|
+
for (const item of lists.flat()) {
|
|
166
|
+
const key = String(item?.content || "").trim();
|
|
167
|
+
if (!key || seen.has(key)) continue;
|
|
168
|
+
seen.add(key);
|
|
169
|
+
merged.push(item);
|
|
170
|
+
}
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function formatResumeTime(date, timeZone) {
|
|
175
|
+
try {
|
|
176
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
177
|
+
timeZone: resolveTimeZone(timeZone) || undefined,
|
|
178
|
+
month: "2-digit",
|
|
179
|
+
day: "2-digit",
|
|
180
|
+
hour: "2-digit",
|
|
181
|
+
minute: "2-digit",
|
|
182
|
+
hourCycle: "h23",
|
|
183
|
+
}).format(date);
|
|
184
|
+
} catch {
|
|
185
|
+
return date.toISOString();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates and cancels the resume schedule through the Conductor HTTP API.
|
|
191
|
+
* Returns null when fire has no backend URL/token (e.g. a guest daemon).
|
|
192
|
+
*/
|
|
193
|
+
export function createQuotaResumeScheduler({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
|
|
194
|
+
const backendUrl = typeof env.CONDUCTOR_BACKEND_URL === "string" ? env.CONDUCTOR_BACKEND_URL.trim() : "";
|
|
195
|
+
const token = typeof env.CONDUCTOR_AGENT_TOKEN === "string" ? env.CONDUCTOR_AGENT_TOKEN.trim() : "";
|
|
196
|
+
if (!backendUrl || !token || typeof fetchImpl !== "function") {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const base = backendUrl.replace(/\/+$/, "");
|
|
200
|
+
// No X-Conductor-Actor header: this is fire itself, not the agent, so the
|
|
201
|
+
// task's agent schedule-access setting must not block it.
|
|
202
|
+
const headers = {
|
|
203
|
+
Authorization: `Bearer ${token}`,
|
|
204
|
+
Accept: "application/json",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
};
|
|
207
|
+
const schedulesUrl = (taskId) => `${base}/api/tasks/${encodeURIComponent(taskId)}/scheduled-messages`;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
async schedule({ taskId, sendAt, content }) {
|
|
211
|
+
const response = await fetchImpl(schedulesUrl(taskId), {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers,
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
content,
|
|
216
|
+
source: "quota_resume",
|
|
217
|
+
schedule: { mode: "at", sendAt: sendAt.toISOString() },
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const detail = await response.text().catch(() => "");
|
|
222
|
+
throw new Error(`schedule quota resume failed: HTTP ${response.status} ${detail}`.trim());
|
|
223
|
+
}
|
|
224
|
+
const body = await response.json().catch(() => ({}));
|
|
225
|
+
return { id: typeof body?.id === "string" ? body.id : null };
|
|
226
|
+
},
|
|
227
|
+
/** Active quota_resume schedules of the task (left behind by an earlier fire). */
|
|
228
|
+
async listActive({ taskId }) {
|
|
229
|
+
const response = await fetchImpl(`${schedulesUrl(taskId)}?status=active`, { method: "GET", headers });
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new Error(`list quota resumes failed: HTTP ${response.status}`);
|
|
232
|
+
}
|
|
233
|
+
const body = await response.json().catch(() => ({}));
|
|
234
|
+
const schedules = Array.isArray(body?.schedules) ? body.schedules : [];
|
|
235
|
+
// Servers before `source` existed: recognise the resume by its text.
|
|
236
|
+
return schedules.filter((row) =>
|
|
237
|
+
row?.source ? row.source === "quota_resume" : isQuotaResumePrompt(row?.content),
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
async update({ taskId, scheduleId, content }) {
|
|
241
|
+
const response = await fetchImpl(`${schedulesUrl(taskId)}/${encodeURIComponent(scheduleId)}`, {
|
|
242
|
+
method: "PATCH",
|
|
243
|
+
headers,
|
|
244
|
+
body: JSON.stringify({ content }),
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
throw new Error(`update quota resume failed: HTTP ${response.status}`);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
async cancel({ taskId, scheduleId }) {
|
|
251
|
+
// cancel_only: never delete a row the dispatcher already sent (that
|
|
252
|
+
// would erase its history); a server without the flag still cancels
|
|
253
|
+
// active rows, and fire only cancels before the send time.
|
|
254
|
+
const response = await fetchImpl(
|
|
255
|
+
`${schedulesUrl(taskId)}/${encodeURIComponent(scheduleId)}?cancel_only=1`,
|
|
256
|
+
{ method: "DELETE", headers },
|
|
257
|
+
);
|
|
258
|
+
if (!response.ok && response.status !== 404 && response.status !== 409) {
|
|
259
|
+
throw new Error(`cancel quota resume failed: HTTP ${response.status}`);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveTimeZone(value) {
|
|
266
|
+
const zone = typeof value === "string" ? value.trim() : "";
|
|
267
|
+
if (!zone) return null;
|
|
268
|
+
try {
|
|
269
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
270
|
+
return zone;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function zonedParts(ms, timeZone) {
|
|
277
|
+
const parts = {};
|
|
278
|
+
for (const part of new Intl.DateTimeFormat("en-US", {
|
|
279
|
+
timeZone,
|
|
280
|
+
hourCycle: "h23",
|
|
281
|
+
year: "numeric",
|
|
282
|
+
month: "2-digit",
|
|
283
|
+
day: "2-digit",
|
|
284
|
+
hour: "2-digit",
|
|
285
|
+
minute: "2-digit",
|
|
286
|
+
second: "2-digit",
|
|
287
|
+
}).formatToParts(new Date(ms))) {
|
|
288
|
+
if (part.type !== "literal") parts[part.type] = Number(part.value);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
year: parts.year,
|
|
292
|
+
month: parts.month,
|
|
293
|
+
day: parts.day,
|
|
294
|
+
hour: parts.hour === 24 ? 0 : parts.hour,
|
|
295
|
+
minute: parts.minute,
|
|
296
|
+
second: parts.second,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Offset of `timeZone` from UTC at instant `ms`, in ms (local minus UTC). */
|
|
301
|
+
function zoneOffsetMs(ms, timeZone) {
|
|
302
|
+
const p = zonedParts(ms, timeZone);
|
|
303
|
+
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
304
|
+
return asUtc - Math.floor(ms / 1000) * 1000;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** UTC instant of a wall-clock time in `timeZone` (day overflow is normalised). */
|
|
308
|
+
function zonedWallTimeToUtc({ year, month, day, hour, minute }, timeZone) {
|
|
309
|
+
const guess = Date.UTC(year, month - 1, day, hour, minute, 0);
|
|
310
|
+
const first = guess - zoneOffsetMs(guess, timeZone);
|
|
311
|
+
const second = guess - zoneOffsetMs(first, timeZone);
|
|
312
|
+
return second;
|
|
313
|
+
}
|
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
|
: {}),
|