@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.
@@ -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>:<backend>, --worktree,
35
- * --remote-worktree <host>, --persistent
36
+ * is the worker), --global-backend <backend>@<host>, --worktree, --persistent
36
37
  * send also takes: --attach FILE (repeatable)
37
38
  * messages also takes: --follow [--interval <sec>] [--until-idle]
38
39
  * list also takes (without --archived): --all-projects, --project-ids a,b
@@ -351,6 +352,16 @@ async function handleList(argv, deps) {
351
352
  }
352
353
 
353
354
  async function handleCreate(argv, deps) {
355
+ // Removed flag: an unknown argument only warns, and would start a plain local task.
356
+ if (argv.remoteWorktree !== undefined) {
357
+ const err = new Error(
358
+ "--remote-worktree was removed. Create the task in the project on the daemon that holds the code, "
359
+ + "with --global-backend <backend>@<ai-host> --worktree: the host now names the daemon that runs the AI, "
360
+ + "not the one that holds the code.",
361
+ );
362
+ err.code = "ARGS";
363
+ throw err;
364
+ }
354
365
  const apis = await buildApis(deps);
355
366
  const project = await resolveProject(apis, {
356
367
  env: deps.env,
@@ -774,9 +785,9 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
774
785
  array: true,
775
786
  describe: "Multi-agent group member <name>[:backend]; repeat it. The first is the worker, the rest reviewers",
776
787
  })
777
- .option("global-backend", { type: "string", describe: "Run the AI on a global backend <host>:<backend> (RFC 0041)" })
788
+ .option("global-backend", { type: "string", describe: "Run the AI on a global AI backend from settings, as <backend>@<host>" })
778
789
  .option("worktree", { type: "boolean", describe: "Run in a new git worktree on the project's daemon" })
779
- .option("remote-worktree", { type: "string", describe: "Run in a new git worktree on this remote daemon" })
790
+ .option("remote-worktree", { type: "string", hidden: true })
780
791
  .option("persistent", { type: "boolean", describe: "Create a persistent task (RFC 0039)" }),
781
792
  async (argv) => {
782
793
  exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
@@ -862,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 session on this daemon" })
866
- .option("first-message", { type: "string", describe: "With --strategy new_task: first message instead of the transcript handoff" }),
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)",
@@ -17,6 +17,7 @@ import {
17
17
  fetchLatestVersion,
18
18
  isNewerVersion,
19
19
  detectPackageManager,
20
+ resolveBundledNodeDir,
20
21
  resolveGlobalInstallPrefix,
21
22
  } from "../src/version-check.js";
22
23
  import {
@@ -31,6 +32,7 @@ const PKG_ROOT = path.join(__dirname, "..");
31
32
 
32
33
  const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
33
34
  const CURRENT_VERSION = pkgJson.version;
35
+ const INSTALL_TIMEOUT_MS = 15 * 60_000;
34
36
  const INSTALL_METHOD = resolveInstallMethod({
35
37
  env: process.env,
36
38
  packageRoot: PKG_ROOT,
@@ -176,6 +178,11 @@ async function performUpdate() {
176
178
  console.log("");
177
179
  }
178
180
 
181
+ const nodeDir = resolveBundledNodeDir();
182
+ if (nodeDir && !process.env.npm_config_nodedir) {
183
+ process.env.npm_config_nodedir = nodeDir;
184
+ }
185
+
179
186
  if (packageManager === "pnpm") {
180
187
  console.log(" Preparing pnpm native dependency allowlist...");
181
188
  await ensurePnpmOnlyBuiltDependencies({
@@ -212,16 +219,26 @@ async function performUpdate() {
212
219
  stdio: "inherit",
213
220
  shell: true
214
221
  });
222
+ // npm never gives up on a registry connection that stalls mid-handshake.
223
+ let timedOut = false;
224
+ const timer = setTimeout(() => {
225
+ timedOut = true;
226
+ child.kill("SIGTERM");
227
+ }, INSTALL_TIMEOUT_MS);
215
228
 
216
229
  child.on("close", (code) => {
230
+ clearTimeout(timer);
217
231
  if (code === 0) {
218
232
  resolve();
233
+ } else if (timedOut) {
234
+ reject(new Error(`${cmd} did not finish within ${INSTALL_TIMEOUT_MS / 60_000} minutes`));
219
235
  } else {
220
236
  reject(new Error(`Exit code ${code}`));
221
237
  }
222
238
  });
223
239
 
224
240
  child.on("error", (error) => {
241
+ clearTimeout(timer);
225
242
  reject(error);
226
243
  });
227
244
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.16.0",
4
- "gitCommitId": "77239b9",
3
+ "version": "0.17.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.16.0",
28
- "@love-moon/conductor-sdk": "0.16.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.16.0"
41
+ "@love-moon/chat-web": "0.17.1"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
@@ -134,13 +134,19 @@ export function createBackendHttp({ config, fetchImpl, env = process.env }) {
134
134
  return payload;
135
135
  }
136
136
 
137
- /** GET a binary body; resolves to the raw fetch Response (caller streams it). */
137
+ /** Fetch a binary body (GET, or POST with `options.body` as JSON); resolves to the raw Response. */
138
138
  async function download(pathname, options = {}) {
139
+ const hasBody = options.body !== undefined;
139
140
  let response;
140
141
  try {
141
142
  response = await doFetch(buildUrl(pathname, options.query), {
142
- method: "GET",
143
- headers: { Authorization: `Bearer ${config.agentToken}`, Accept: "*/*" },
143
+ method: hasBody ? "POST" : "GET",
144
+ headers: {
145
+ Authorization: `Bearer ${config.agentToken}`,
146
+ Accept: "*/*",
147
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
148
+ },
149
+ ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
144
150
  });
145
151
  } catch (error) {
146
152
  throw new BackendHttpError(`Download failed: ${error?.message || String(error)}`, undefined, undefined);
@@ -11,6 +11,7 @@ import {
11
11
  detectPackageManager,
12
12
  fetchLatestVersion,
13
13
  isNewerVersion,
14
+ resolveBundledNodeDir,
14
15
  resolveGlobalInstallPrefix,
15
16
  } from "./version-check.js";
16
17
  import {
@@ -283,6 +284,8 @@ function buildInstallEnv(packageManager, packageRoot, env) {
283
284
  const prefix = resolveGlobalInstallPrefix(packageRoot);
284
285
  if (prefix) installEnv.npm_config_prefix = prefix;
285
286
  }
287
+ const nodeDir = resolveBundledNodeDir();
288
+ if (nodeDir && !installEnv.npm_config_nodedir) installEnv.npm_config_nodedir = nodeDir;
286
289
  return installEnv;
287
290
  }
288
291
 
package/src/daemon.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  import { REMOTE_WORKTREE_ENV, remoteWorktreeFireEnv } from "./remote/mcp-launch.js";
48
48
  import {
49
49
  REMOTE_FILE_CAPABILITY,
50
+ REMOTE_FILE_PREVIEW_CAPABILITY,
50
51
  createRemoteFileHandlers,
51
52
  handleRemoteFileRequest,
52
53
  } from "./remote-file-handlers.js";
@@ -68,7 +69,7 @@ import {
68
69
  normalizeRuntimeBackendAlias,
69
70
  normalizeRuntimeBackendName,
70
71
  } from "./runtime-backends.js";
71
- import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
72
+ import { buildClaudeRootSandboxNotice, claudeCommandNeedsRootSandbox } from "@love-moon/ai-sdk";
72
73
  import {
73
74
  PACKAGE_NAME,
74
75
  buildUpgradeCommand,
@@ -573,15 +574,20 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
573
574
  }
574
575
 
575
576
  // A tool-preset PTY task runs the configured allow_cli_list command through a
576
- // login shell, bypassing ai-sdk entirely. claude refuses to start as root with
577
- // `--dangerously-skip-permissions`, so rewrite the command the same way the
578
- // ai-sdk session rewrites its permission mode — one shared root check, so the
579
- // two paths cannot drift.
580
- export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
577
+ // login shell, bypassing ai-sdk entirely. The command is never rewritten: as
578
+ // root without IS_SANDBOX=1, claude refuses `--dangerously-skip-permissions`
579
+ // and exits, so print the same IS_SANDBOX hint the chat path shows, right in
580
+ // the terminal before claude's own error.
581
+ export function resolvePtyToolPresetCommand(cliCommand, env = process.env, options = {}) {
581
582
  if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
582
583
  return cliCommand;
583
584
  }
584
- return resolveClaudeCommandForRoot(cliCommand, env);
585
+ if (!claudeCommandNeedsRootSandbox(cliCommand, env)) {
586
+ return cliCommand;
587
+ }
588
+ const notice = buildClaudeRootSandboxNotice({ configFile: options.configFile });
589
+ const quoted = `'${notice.replace(/'/g, "'\\''")}'`;
590
+ return `printf '%s\\n\\n' ${quoted} >&2; ${cliCommand}`;
585
591
  }
586
592
 
587
593
  export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
@@ -989,6 +995,21 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
989
995
  };
990
996
  }
991
997
 
998
+ const CLAUDE_ROOT_OPT_OUT_ENV_KEYS = ["IS_SANDBOX", "CLAUDE_CODE_BUBBLEWRAP"];
999
+
1000
+ function pickClaudeRootOptOutEnv(configEnv) {
1001
+ const picked = {};
1002
+ if (!configEnv || typeof configEnv !== "object") {
1003
+ return picked;
1004
+ }
1005
+ for (const key of CLAUDE_ROOT_OPT_OUT_ENV_KEYS) {
1006
+ if (configEnv[key] !== undefined && configEnv[key] !== null) {
1007
+ picked[key] = String(configEnv[key]);
1008
+ }
1009
+ }
1010
+ return picked;
1011
+ }
1012
+
992
1013
  // Module-level so the PTY launch wiring is directly testable: the tool-preset
993
1014
  // branch has to hand the *child's* env to the root check, and that seam is
994
1015
  // exactly where a bug hid before (the check read the daemon's own process.env,
@@ -1037,9 +1058,17 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
1037
1058
  if (!cliCommand) {
1038
1059
  throw new Error(`Unsupported tool preset: ${toolPreset}`);
1039
1060
  }
1040
- const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
1061
+ // The PTY child inherits the daemon env, not the config's `envs:` block,
1062
+ // yet the root hint tells users to set `envs.IS_SANDBOX: "1"`. Forward
1063
+ // just claude's root opt-outs so that advice works for terminal tasks too.
1064
+ // launch_config.env still wins, matching the spawned env.
1065
+ const isClaudePreset = inferBuiltInRuntimeBackendFromCommand(cliCommand) === "claude";
1066
+ const launchEnv = isClaudePreset ? { ...pickClaudeRootOptOutEnv(deps.configEnv), ...env } : env;
1067
+ const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, launchEnv), {
1068
+ configFile: deps.configFile,
1069
+ });
1041
1070
  if (launchCommand !== cliCommand) {
1042
- log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
1071
+ log(`[pty] ${toolPreset} runs as root without IS_SANDBOX=1; claude will refuse bypassPermissions`);
1043
1072
  }
1044
1073
  return {
1045
1074
  entrypointType,
@@ -1048,7 +1077,7 @@ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
1048
1077
  args: ["-lc", launchCommand],
1049
1078
  shell: preferredShell,
1050
1079
  cwd,
1051
- env,
1080
+ env: launchEnv,
1052
1081
  cols,
1053
1082
  rows,
1054
1083
  };
@@ -3909,7 +3938,7 @@ export function startDaemon(config = {}, deps = {}) {
3909
3938
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3910
3939
  }
3911
3940
  if (remoteFileEnabled) {
3912
- advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
3941
+ advertisedCapabilities.push(REMOTE_FILE_CAPABILITY, REMOTE_FILE_PREVIEW_CAPABILITY);
3913
3942
  } else {
3914
3943
  log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
3915
3944
  }
@@ -3985,6 +4014,15 @@ export function startDaemon(config = {}, deps = {}) {
3985
4014
  config: sdkConfig,
3986
4015
  agentHost: AGENT_NAME,
3987
4016
  guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
4017
+ // Every Fire logs to `<task dir>/conductor.log`, so the log path is the
4018
+ // one place the working directory survives a daemon restart (adopted
4019
+ // tmux records carry it too).
4020
+ resolveTaskCwd: (taskId) => {
4021
+ const ptyCwd = activePtySessions.get(taskId)?.cwd;
4022
+ if (ptyCwd) return ptyCwd;
4023
+ const logPath = activeTaskProcesses.get(taskId)?.logPath;
4024
+ return logPath ? path.dirname(logPath) : null;
4025
+ },
3988
4026
  })
3989
4027
  : null;
3990
4028
 
@@ -5554,6 +5592,8 @@ export function startDaemon(config = {}, deps = {}) {
5554
5592
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
5555
5593
  return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
5556
5594
  allowCliList: ALLOW_CLI_LIST,
5595
+ configEnv: userConfig?.envs,
5596
+ configFile: effectiveConfigPath,
5557
5597
  supportedBackends: SUPPORTED_BACKENDS,
5558
5598
  existsSync: existsSyncFn,
5559
5599
  log,
@@ -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
+ }
@@ -139,9 +139,9 @@ export function buildRemoteWorktreeSessionOptions({
139
139
  ...(sessionOptions.mcpServers || {}),
140
140
  [MCP_SERVER_NAME]: { type: "stdio", command: execPath, args },
141
141
  },
142
- // Under the default bypassPermissions a root install is downgraded to
143
- // acceptEdits, which would deny every remote_* call headless. A mode the
144
- // user chose on purpose is left alone rather than widened.
142
+ // Pre-approve the remote_* tools under the default bypassPermissions so
143
+ // they keep working if claude ever runs in a prompting mode headless. A
144
+ // mode the user chose on purpose is left alone rather than widened.
145
145
  ...(mode === undefined || mode === "bypassPermissions"
146
146
  ? { allowedTools: [...new Set([...allowed, `mcp__${MCP_SERVER_NAME}`])] }
147
147
  : {}),