@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 CHANGED
@@ -1,5 +1,89 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e92c0f: When a task's model quota runs out ("You've hit your session limit · resets
8
+ 1:20am"), fire no longer stops there. It reads the reset time, schedules a
9
+ one-off Conductor scheduled message to continue a minute or few after the
10
+ reset, and posts a single notice. Messages that arrive while waiting are queued
11
+ and handed to the resume turn together instead of each failing with another
12
+ "处理失败". The resume shows up in the task's scheduled-message list, where it
13
+ can be canceled. This also covers a quota hit while Claude works on its own
14
+ between user turns: ai-sdk now reports such a background turn that ends in an
15
+ error result (or a process exit after it) as a failed turn.
16
+ - Updated dependencies [7e92c0f]
17
+ - @love-moon/ai-sdk@0.17.1
18
+ - @love-moon/conductor-sdk@0.17.1
19
+
20
+ ## 0.17.0
21
+
22
+ ### Minor Changes
23
+
24
+ - 88e1ae0: Support `/stop` in task chats. A bare `/stop` message, sent from the web, the
25
+ CLI or a channel, makes the server interrupt the AI's running turn right away
26
+ instead of waiting for fire to read the message once that turn ends. When fire
27
+ does read the queued `/stop`, it posts a confirmation and does not send it to
28
+ the model. `/stop` also drops every message queued before it (except the
29
+ server's end-of-round summary request); resend what you still want. The server
30
+ targets the reply it knows is running, so older fires stop it too;
31
+ `interrupt_turn` no longer needs a reply target, and without one fire
32
+ interrupts the turn it is running, including the initial prompt and pre_prompt
33
+ turns. In the web chat, the ⋯ menu gains `/stop`, `/clear` and `/compact`,
34
+ which send those commands as messages, and Interrupt (Esc) sends `/stop`,
35
+ ignored while an earlier `/stop` is unanswered. The Interrupt action is gone
36
+ from the message toolbar.
37
+
38
+ ### Patch Changes
39
+
40
+ - 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
41
+ That fallback looked like it worked, but in a headless task it silently refused
42
+ every shell command (ssh, rsync, ...) and every read outside the project. Claude
43
+ now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
44
+ chat shows a warning right after "session started" telling you to add
45
+ `envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
46
+ regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
47
+ `envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
48
+ writes `--dangerously-skip-permissions` for claude.
49
+ - c0c1489: `conductor task speak <text> [-o FILE|-]` saves Doubao text-to-speech as an
50
+ .mp3 — the same voice the web chat's new voice-conversation mode reads replies with.
51
+ - 39ee057: `conductor update` and the daemon updater compile node-pty against the headers
52
+ the running Node ships, instead of downloading them from nodejs.org, so an
53
+ update no longer needs that host to be reachable. `conductor update` also stops
54
+ an npm install that has not finished after 15 minutes instead of waiting on a
55
+ stalled registry connection forever.
56
+ - b09b28a: Remove `conductor task create --remote-worktree <host>`; passing it now fails
57
+ with a pointer to the replacement. Running the AI on one daemon while the code
58
+ lives on another is done only through a global AI backend:
59
+ `--global-backend <backend>@<host>` (add `--worktree` for a worktree on the
60
+ code's daemon). Note the host changes meaning: `--remote-worktree` named the
61
+ daemon holding the code, `--global-backend` names the daemon running the AI, so
62
+ create the task in the project on the code's daemon. The server refuses a caller-supplied
63
+ `launch_config.remoteWorktree`.
64
+
65
+ `conductor task create --global-backend` now takes `<backend>@<host>`, the same
66
+ form as `conductor issue start` and the web UI. The older `<host>:<backend>` is
67
+ still accepted.
68
+
69
+ - 68fe7b9: Add `conductor task preview <id> <path>`: open a temporary link to a file on the
70
+ task's daemon (HTML with its CSS/JS/images, Markdown, images, video), the same
71
+ preview the chat opens when you click a file link in an AI reply or shows in
72
+ place for `![](picture.png)` / `![](recording.mp4)`. The link expires after
73
+ 5 idle minutes (30 minutes at most) and only reaches the file's own directory.
74
+
75
+ The daemon advertises a new `remote_file_preview` capability; previews need a
76
+ daemon of this version or later. `conductor config` now writes
77
+ `remote_file: true` explicitly into a new config file (the default was already
78
+ on; set it to `false` to decline file transfers and previews).
79
+
80
+ - Updated dependencies [d088b31]
81
+ - Updated dependencies [79ca552]
82
+ - Updated dependencies [ccbfeeb]
83
+ - Updated dependencies [88e1ae0]
84
+ - @love-moon/ai-sdk@0.17.0
85
+ - @love-moon/conductor-sdk@0.17.0
86
+
3
87
  ## 0.16.0
4
88
 
5
89
  ### Minor Changes
@@ -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
- // claude exits 1 with `--dangerously-skip-permissions` under root, so never
26
- // write a flag the user's own machine cannot run. Same root check the
27
- // daemon and the ai-sdk session use.
28
- execArgs: isClaudeRootPermissionRestricted()
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
- "# remote_file: true",
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",
@@ -26,6 +26,17 @@ import {
26
26
  resolveSessionRunDirectory as resolveCliSessionRunDirectory,
27
27
  resumeProviderForBackend as resumeProviderForCliBackend,
28
28
  } from "../src/fire/resume.js";
29
+ import {
30
+ QUOTA_RESUME_MAX_ATTEMPTS,
31
+ buildQuotaResumePrompt,
32
+ computeQuotaResumeAt,
33
+ createQuotaResumeScheduler,
34
+ formatResumeTime,
35
+ isQuotaResumePrompt,
36
+ mergeQueuedMessages,
37
+ parseQuotaLimitError,
38
+ parseQuotaResumePrompt,
39
+ } from "../src/fire/quota-resume.js";
29
40
  import {
30
41
  REMOTE_WORKTREE_ENV,
31
42
  buildRemoteWorktreeSessionOptions,
@@ -1648,6 +1659,10 @@ export function formatCompactReply(backendName, compact, instructions = "") {
1648
1659
  * Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
1649
1660
  * counts: anything after it is more likely an instruction for the model.
1650
1661
  */
1662
+ export function isStopCommand(content) {
1663
+ return typeof content === "string" && /^\/stop$/i.test(content.trim());
1664
+ }
1665
+
1651
1666
  export function isClearCommand(content) {
1652
1667
  return typeof content === "string" && /^\/clear$/i.test(content.trim());
1653
1668
  }
@@ -2096,8 +2111,19 @@ export class BridgeRunner {
2096
2111
  shouldProcessPrePrompt,
2097
2112
  createFreshBackendSession,
2098
2113
  withFreshSessionBootstrap,
2114
+ quotaResumeScheduler,
2115
+ now,
2116
+ random,
2099
2117
  }) {
2100
2118
  this.backendSession = backendSession;
2119
+ // Model quota handling (see src/fire/quota-resume.js). `undefined` means
2120
+ // "build the HTTP scheduler from env on first use"; tests inject their own.
2121
+ this.quotaResumeSchedulerOverride = quotaResumeScheduler;
2122
+ this.now = typeof now === "function" ? now : () => new Date();
2123
+ this.random = typeof random === "function" ? random : Math.random;
2124
+ this.quotaWait = null;
2125
+ this.quotaResumeAttempts = 0;
2126
+ this.lastQuotaResumeQueue = null;
2101
2127
  this.createFreshBackendSession = createFreshBackendSession;
2102
2128
  this.withFreshSessionBootstrap = withFreshSessionBootstrap;
2103
2129
  this.conductor = conductor;
@@ -2168,6 +2194,8 @@ export class BridgeRunner {
2168
2194
  this.remoteInterruptsByReplyTo = new Map();
2169
2195
  this.pendingInterruptRetryTimers = new Map();
2170
2196
  this.activeTurnReplyTo = "";
2197
+ // A `/stop` arrived mid-batch: drop the rest of this batch (all sent before it).
2198
+ this.dropQueuedBeforeStop = false;
2171
2199
  this.sessionAnnouncementSent = false;
2172
2200
  this.sessionAnnouncementSubscribed = false;
2173
2201
  this.boundSessionId = "";
@@ -2309,6 +2337,7 @@ export class BridgeRunner {
2309
2337
  });
2310
2338
  this.sessionAnnouncementSent = true;
2311
2339
  this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
2340
+ await this.reportBackendNotices();
2312
2341
  await this.reportRuntimeStatus(
2313
2342
  {
2314
2343
  state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
@@ -2421,6 +2450,7 @@ export class BridgeRunner {
2421
2450
  if (this.needsReconnectRecovery && !this.runningTurn) {
2422
2451
  await this.recoverAfterReconnect();
2423
2452
  }
2453
+ await this.runLocalQuotaResumeIfDue();
2424
2454
  let processed = false;
2425
2455
  try {
2426
2456
  processed = await this.processIncomingBatch();
@@ -2577,7 +2607,12 @@ export class BridgeRunner {
2577
2607
  }
2578
2608
  const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
2579
2609
  const reason = typeof event.reason === "string" ? event.reason.trim() : "";
2580
- const targetReplyTo = this.normalizeReplyTarget(event.targetReplyTo);
2610
+ if (reason === "user_stop" && this.runningTurn) {
2611
+ this.dropQueuedBeforeStop = true;
2612
+ }
2613
+ // No target (`/stop`): interrupt whatever turn is running right now.
2614
+ const targetReplyTo =
2615
+ this.normalizeReplyTarget(event.targetReplyTo) || (this.runningTurn ? this.activeTurnReplyTo : "");
2581
2616
  if (!targetReplyTo) {
2582
2617
  return false;
2583
2618
  }
@@ -2767,7 +2802,12 @@ export class BridgeRunner {
2767
2802
  .join(",")}`,
2768
2803
  );
2769
2804
 
2770
- for (const message of messages) {
2805
+ // `/stop` drops every message queued before it; the user resends what they still want.
2806
+ let lastStopIndex = -1;
2807
+ messages.forEach((message, index) => {
2808
+ if (this.isUserStopMessage(message)) lastStopIndex = index;
2809
+ });
2810
+ for (const [index, message] of messages.entries()) {
2771
2811
  if (this.stopped) {
2772
2812
  break;
2773
2813
  }
@@ -2775,8 +2815,16 @@ export class BridgeRunner {
2775
2815
  this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
2776
2816
  continue;
2777
2817
  }
2818
+ if (this.isUserStopMessage(message)) {
2819
+ this.dropQueuedBeforeStop = false;
2820
+ } else if ((index < lastStopIndex || this.dropQueuedBeforeStop) && this.isDroppableOnStop(message)) {
2821
+ if (message.message_id) this.processedMessageIds.add(message.message_id);
2822
+ this.copilotLog(`drop message queued before /stop replyTo=${message.message_id || "unknown"}`);
2823
+ continue;
2824
+ }
2778
2825
  await this.respondToMessage(message);
2779
2826
  }
2827
+ this.dropQueuedBeforeStop = false;
2780
2828
 
2781
2829
  if (ackToken) {
2782
2830
  await this.conductor.ackMessages(this.taskId, ackToken);
@@ -2786,6 +2834,15 @@ export class BridgeRunner {
2786
2834
  return hasMore;
2787
2835
  }
2788
2836
 
2837
+ isUserStopMessage(message) {
2838
+ return String(message?.role || "").toLowerCase() === "user" && isStopCommand(message.content);
2839
+ }
2840
+
2841
+ // The end-of-round summary request is the server's, not the user's: dropping it would lock the round.
2842
+ isDroppableOnStop(message) {
2843
+ return String(message?.role || "").toLowerCase() === "user" && message.metadata?.kind !== "persistent_round_end";
2844
+ }
2845
+
2789
2846
  shouldRespond(message) {
2790
2847
  if (!message || typeof message !== "object") {
2791
2848
  return false;
@@ -3221,6 +3278,19 @@ export class BridgeRunner {
3221
3278
  }
3222
3279
 
3223
3280
  async handleContinuousWorkingStatus(payload) {
3281
+ // A turn claude ran on its own (after a background task or subagent) hit
3282
+ // the quota: no user turn fails, so park the task here.
3283
+ if (
3284
+ payload?.background === true &&
3285
+ payload?.phase === "turn_failed" &&
3286
+ !this.runningTurn &&
3287
+ (await this.enterQuotaWaitIfQuotaError(String(payload.status_done_line || ""), {
3288
+ replyTo: undefined,
3289
+ content: "",
3290
+ }))
3291
+ ) {
3292
+ return;
3293
+ }
3224
3294
  await this.reportRuntimeStatus(payload, payload?.replyTo);
3225
3295
  }
3226
3296
 
@@ -3285,6 +3355,228 @@ export class BridgeRunner {
3285
3355
  this.errorLoop = null;
3286
3356
  }
3287
3357
 
3358
+ getQuotaResumeScheduler() {
3359
+ if (this.quotaResumeSchedulerOverride !== undefined) {
3360
+ return this.quotaResumeSchedulerOverride;
3361
+ }
3362
+ this.quotaResumeSchedulerOverride = createQuotaResumeScheduler();
3363
+ return this.quotaResumeSchedulerOverride;
3364
+ }
3365
+
3366
+ /**
3367
+ * A turn failed because the model quota is used up: park the task until the
3368
+ * quota resets. The resume is a one-off Conductor scheduled message, so it
3369
+ * survives restarts and can be canceled from the schedule list; messages that
3370
+ * arrive meanwhile are queued instead of each failing again. Returns false
3371
+ * (report the error as usual) for other errors, or after
3372
+ * QUOTA_RESUME_MAX_ATTEMPTS resumes in a row hit the quota again.
3373
+ */
3374
+ async enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content }) {
3375
+ const now = this.now();
3376
+ const quota = parseQuotaLimitError(errorMessage, { now });
3377
+ if (!quota) {
3378
+ return false;
3379
+ }
3380
+ const failedItems = this.pendingItemsOfFailedTurn(replyTo, content);
3381
+ if (this.quotaWait) {
3382
+ // Already parked (a background failure, or a turn that raced the wait):
3383
+ // fold this one in rather than scheduling a second resume.
3384
+ await this.addToQuotaWait(failedItems);
3385
+ return true;
3386
+ }
3387
+ this.quotaResumeAttempts += 1;
3388
+ if (this.quotaResumeAttempts > QUOTA_RESUME_MAX_ATTEMPTS) {
3389
+ log(`[quota] ${this.quotaResumeAttempts - 1} resumes in a row hit the quota again; not scheduling another`);
3390
+ this.quotaResumeAttempts = 0;
3391
+ this.lastQuotaResumeQueue = null;
3392
+ return false;
3393
+ }
3394
+ this.lastQuotaResumeQueue = null;
3395
+ const sendAt = computeQuotaResumeAt(quota.resetAt, { now, random: this.random });
3396
+ // Set synchronously, before any await, so messages and background failures
3397
+ // that arrive while the schedule is being created are queued into it.
3398
+ const wait = {
3399
+ // Messages that arrive before this are queued; later ones end the wait.
3400
+ resumeAfter: quota.resetAt && quota.resetAt.getTime() > now.getTime() ? quota.resetAt : sendAt,
3401
+ sendAt,
3402
+ scheduleId: null,
3403
+ localResumeAt: null,
3404
+ queued: failedItems,
3405
+ };
3406
+ this.quotaWait = wait;
3407
+ const scheduler = this.getQuotaResumeScheduler();
3408
+ let scheduleNote = "可在定时消息列表里取消。";
3409
+ let scheduledContent = "";
3410
+ if (scheduler) {
3411
+ // A fire that restarted mid-wait left its resume schedule behind; the new
3412
+ // one replaces it, so carry over what it listed.
3413
+ if (typeof scheduler.listActive === "function") {
3414
+ try {
3415
+ const leftover = await scheduler.listActive({ taskId: this.taskId });
3416
+ wait.queued = mergeQueuedMessages(
3417
+ ...leftover.map((row) => parseQuotaResumePrompt(row?.content)),
3418
+ wait.queued,
3419
+ );
3420
+ } catch (error) {
3421
+ log(`[quota] failed to read earlier resume schedules: ${error?.message || error}`);
3422
+ }
3423
+ }
3424
+ scheduledContent = buildQuotaResumePrompt(wait.queued);
3425
+ try {
3426
+ const created = await scheduler.schedule({
3427
+ taskId: this.taskId,
3428
+ sendAt,
3429
+ content: scheduledContent,
3430
+ });
3431
+ wait.scheduleId = created?.id || null;
3432
+ } catch (error) {
3433
+ log(`[quota] failed to schedule resume: ${error?.message || error}`);
3434
+ }
3435
+ }
3436
+ if (!wait.scheduleId) {
3437
+ // No server schedule: fire keeps the time itself (lost if fire restarts).
3438
+ wait.localResumeAt = sendAt;
3439
+ scheduleNote = "定时消息没有建成,改由本进程计时;如果 fire 重启,需要手动发消息继续。";
3440
+ } else if (buildQuotaResumePrompt(wait.queued) !== scheduledContent) {
3441
+ // Messages queued while the schedule was being created.
3442
+ await this.syncQuotaResumeSchedule();
3443
+ }
3444
+ const resumeLabel = formatResumeTime(sendAt);
3445
+ log(
3446
+ `[quota] waiting until ${sendAt.toISOString()} (reset ${quota.resetAt ? quota.resetAt.toISOString() : "unknown"}) schedule=${wait.scheduleId || "local"} attempt=${this.quotaResumeAttempts}`,
3447
+ );
3448
+ await this.reportRuntimeStatus(
3449
+ {
3450
+ state: "WAITING",
3451
+ phase: "quota_wait",
3452
+ reply_in_progress: false,
3453
+ status_done_line: `额度已用完,${resumeLabel} 自动继续`,
3454
+ quota_resume_at: sendAt.toISOString(),
3455
+ },
3456
+ replyTo,
3457
+ );
3458
+ try {
3459
+ await this.conductor.sendMessage(
3460
+ this.taskId,
3461
+ `${this.backendName} 额度已用完:${errorMessage}\n将在 ${resumeLabel} 自动继续,在此之前收到的消息会排队,届时一起处理。${scheduleNote}`,
3462
+ {
3463
+ severity: "warning",
3464
+ backend: this.backendName,
3465
+ reply_to: replyTo || undefined,
3466
+ quota_wait: true,
3467
+ quota_resume_at: sendAt.toISOString(),
3468
+ cli_args: this.cliArgs,
3469
+ },
3470
+ );
3471
+ } catch (error) {
3472
+ log(`[quota] failed to post wait notice: ${error?.message || error}`);
3473
+ }
3474
+ return true;
3475
+ }
3476
+
3477
+ /** What still has to run after a turn that hit the quota. */
3478
+ pendingItemsOfFailedTurn(replyTo, content) {
3479
+ const text = String(content || "").trim();
3480
+ if (!text) {
3481
+ // A background turn: nothing to redo beyond "continue".
3482
+ return [];
3483
+ }
3484
+ if (!isQuotaResumePrompt(text)) {
3485
+ return [{ messageId: replyTo || null, content: text }];
3486
+ }
3487
+ // A failed resume: what it carried. A fire that restarted mid-wait has no
3488
+ // memory of it, so read the list back out of the prompt itself.
3489
+ return this.lastQuotaResumeQueue
3490
+ ? [...this.lastQuotaResumeQueue]
3491
+ : parseQuotaResumePrompt(text);
3492
+ }
3493
+
3494
+ async addToQuotaWait(items) {
3495
+ const wait = this.quotaWait;
3496
+ const before = wait.queued.length;
3497
+ wait.queued = mergeQueuedMessages(wait.queued, items);
3498
+ if (wait.queued.length !== before) {
3499
+ await this.syncQuotaResumeSchedule();
3500
+ }
3501
+ }
3502
+
3503
+ /** Keep the scheduled resume's text complete, so a fire restart loses nothing. */
3504
+ async syncQuotaResumeSchedule() {
3505
+ const wait = this.quotaWait;
3506
+ const scheduler = this.getQuotaResumeScheduler();
3507
+ if (!wait?.scheduleId || typeof scheduler?.update !== "function") {
3508
+ return;
3509
+ }
3510
+ try {
3511
+ await scheduler.update({
3512
+ taskId: this.taskId,
3513
+ scheduleId: wait.scheduleId,
3514
+ content: buildQuotaResumePrompt(wait.queued),
3515
+ });
3516
+ } catch (error) {
3517
+ log(`[quota] failed to update resume schedule: ${error?.message || error}`);
3518
+ }
3519
+ }
3520
+
3521
+ isQuotaResumeMessage(message, content) {
3522
+ const scheduledMessageId = message?.metadata?.scheduledMessageId;
3523
+ if (scheduledMessageId && this.quotaWait?.scheduleId && scheduledMessageId === this.quotaWait.scheduleId) {
3524
+ return true;
3525
+ }
3526
+ return isQuotaResumePrompt(content);
3527
+ }
3528
+
3529
+ /** While waiting for the quota, queue the message. Returns false once the wait should end. */
3530
+ async queueMessageDuringQuotaWait(message, content) {
3531
+ const wait = this.quotaWait;
3532
+ if (this.isQuotaResumeMessage(message, content)) {
3533
+ return false;
3534
+ }
3535
+ if (this.now().getTime() >= wait.resumeAfter.getTime()) {
3536
+ return false;
3537
+ }
3538
+ const replyTo = message?.message_id || null;
3539
+ if (replyTo) {
3540
+ this.processedMessageIds.add(replyTo);
3541
+ }
3542
+ await this.addToQuotaWait([{ messageId: replyTo, content }]);
3543
+ log(`[quota] queued message ${replyTo || "(no id)"} until ${wait.sendAt.toISOString()} (${wait.queued.length} queued)`);
3544
+ return true;
3545
+ }
3546
+
3547
+ /** Ends the quota wait; returns the prompt for the turn that resumes the task. */
3548
+ async leaveQuotaWait(message, content) {
3549
+ const wait = this.quotaWait;
3550
+ this.quotaWait = null;
3551
+ const isResume = this.isQuotaResumeMessage(message, content);
3552
+ const queued = [...wait.queued];
3553
+ if (!isResume) {
3554
+ // Someone wrote after the quota reset, before the scheduled resume fired.
3555
+ queued.push({ messageId: message?.message_id || null, content });
3556
+ const scheduler = this.getQuotaResumeScheduler();
3557
+ // At or after the send time the schedule is (being) sent: leave it be.
3558
+ if (wait.scheduleId && scheduler?.cancel && this.now().getTime() < wait.sendAt.getTime()) {
3559
+ try {
3560
+ await scheduler.cancel({ taskId: this.taskId, scheduleId: wait.scheduleId });
3561
+ } catch (error) {
3562
+ log(`[quota] failed to cancel resume schedule: ${error?.message || error}`);
3563
+ }
3564
+ }
3565
+ }
3566
+ this.lastQuotaResumeQueue = queued;
3567
+ log(`[quota] resuming with ${queued.length} pending message(s)${isResume ? "" : " (ended by a new message)"}`);
3568
+ return buildQuotaResumePrompt(queued);
3569
+ }
3570
+
3571
+ /** The fallback when no server schedule could be made: resume from fire's own clock. */
3572
+ async runLocalQuotaResumeIfDue() {
3573
+ const wait = this.quotaWait;
3574
+ if (!wait?.localResumeAt || this.runningTurn || this.now().getTime() < wait.localResumeAt.getTime()) {
3575
+ return;
3576
+ }
3577
+ await this.respondToMessage({ role: "user", content: buildQuotaResumePrompt(wait.queued) });
3578
+ }
3579
+
3288
3580
  evaluateErrorLoop(errorMessage) {
3289
3581
  const normalizedKey = normalizeExecutionErrorKey(errorMessage);
3290
3582
  const now = Date.now();
@@ -3349,7 +3641,7 @@ export class BridgeRunner {
3349
3641
  )
3350
3642
  : [];
3351
3643
  const rawContent = String(message.content || "").trim();
3352
- const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
3644
+ let content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
3353
3645
  if (!content) {
3354
3646
  this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
3355
3647
  return;
@@ -3363,6 +3655,16 @@ export class BridgeRunner {
3363
3655
  this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
3364
3656
  return;
3365
3657
  }
3658
+ if (this.quotaWait) {
3659
+ if (await this.queueMessageDuringQuotaWait(message, content)) {
3660
+ return;
3661
+ }
3662
+ content = await this.leaveQuotaWait(message, content);
3663
+ } else if (isQuotaResumePrompt(content)) {
3664
+ // A resume this fire did not schedule (it restarted while waiting):
3665
+ // its text already lists what was queued.
3666
+ this.lastQuotaResumeQueue = null;
3667
+ }
3366
3668
  if (replyTo) {
3367
3669
  this.inFlightMessageIds.add(replyTo);
3368
3670
  }
@@ -3504,6 +3806,8 @@ export class BridgeRunner {
3504
3806
  this.pendingInitialPrompt = "";
3505
3807
  }
3506
3808
  this.resetErrorLoop();
3809
+ this.quotaResumeAttempts = 0;
3810
+ this.lastQuotaResumeQueue = null;
3507
3811
  if (this.useSessionFileReplyStream) {
3508
3812
  this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
3509
3813
  } else {
@@ -3531,6 +3835,12 @@ export class BridgeRunner {
3531
3835
  if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
3532
3836
  return;
3533
3837
  }
3838
+ if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content })) {
3839
+ if (replyTo) {
3840
+ this.processedMessageIds.add(replyTo);
3841
+ }
3842
+ return;
3843
+ }
3534
3844
  this.copilotLog(
3535
3845
  `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
3536
3846
  );
@@ -3664,6 +3974,9 @@ export class BridgeRunner {
3664
3974
  if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
3665
3975
  return this.runClearCommand(options);
3666
3976
  }
3977
+ if (!hasAttachmentInputs && isStopCommand(content)) {
3978
+ return this.runStopCommand(options);
3979
+ }
3667
3980
 
3668
3981
  if (willRunGoal) {
3669
3982
  const goalResult = await this.runWithTurnUsage(() =>
@@ -3852,6 +4165,24 @@ export class BridgeRunner {
3852
4165
  return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
3853
4166
  }
3854
4167
 
4168
+ /**
4169
+ * `/stop`: the server already interrupted the running turn when the message
4170
+ * arrived (fire only reads it once that turn ends), so just confirm it
4171
+ * instead of sending it to the model.
4172
+ */
4173
+ async runStopCommand({ onProgress, replyTo = "" } = {}) {
4174
+ const text = `${this.backendName} 已停止。`;
4175
+ onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
4176
+ if (this.useSessionFileReplyStream && !this.stopped) {
4177
+ try {
4178
+ await this.sendSessionStreamMessage({ text, replyTo });
4179
+ } catch (error) {
4180
+ log(`[stop] failed to post confirmation: ${error?.message || error}`);
4181
+ }
4182
+ }
4183
+ return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
4184
+ }
4185
+
3855
4186
  async handlePrePromptMessage(content) {
3856
4187
  const text = typeof content === "string" ? content.trim() : "";
3857
4188
  if (!text) {
@@ -3887,8 +4218,16 @@ export class BridgeRunner {
3887
4218
  surfaceUserMessage,
3888
4219
  replyMetadata,
3889
4220
  }) {
4221
+ if (this.quotaWait) {
4222
+ // Parked on the quota (e.g. the pre-prompt just hit it): run this with the resume.
4223
+ log(`[quota] ${logTag} queued until ${this.quotaWait.sendAt.toISOString()}`);
4224
+ await this.addToQuotaWait([{ messageId: replyTarget || null, content: String(content || "") }]);
4225
+ return;
4226
+ }
3890
4227
  this.lastRuntimeStatusSignature = null;
3891
4228
  this.runningTurn = true;
4229
+ // Lets a target-less interrupt (`/stop`) reach this turn.
4230
+ this.activeTurnReplyTo = replyTarget;
3892
4231
  // Only this turn's own streamed replies may carry its usage.
3893
4232
  this.lastStreamedReplyIds.clear();
3894
4233
  const startedAt = Date.now();
@@ -3963,6 +4302,11 @@ export class BridgeRunner {
3963
4302
  this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
3964
4303
  return;
3965
4304
  }
4305
+ const interruptInfo = this.remoteInterruptsByReplyTo.get(replyTarget);
4306
+ if (interruptInfo && this.isTurnInterruptedError(error)) {
4307
+ await this.handleInterruptedTurn(replyTarget, interruptInfo);
4308
+ return;
4309
+ }
3966
4310
  if (
3967
4311
  await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
3968
4312
  markProcessed: false,
@@ -3970,17 +4314,47 @@ export class BridgeRunner {
3970
4314
  ) {
3971
4315
  return;
3972
4316
  }
4317
+ if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo: replyTarget, content })) {
4318
+ return;
4319
+ }
3973
4320
  this.copilotLog(
3974
4321
  `${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
3975
4322
  );
3976
4323
  await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
3977
4324
  } finally {
3978
4325
  this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
4326
+ this.activeTurnReplyTo = "";
4327
+ this.clearInterruptRetryForReplyTarget(replyTarget);
4328
+ this.remoteInterruptsByReplyTo.delete(replyTarget);
3979
4329
  this.runningTurn = false;
3980
4330
  this.scheduleRuntimeHeartbeat();
3981
4331
  }
3982
4332
  }
3983
4333
 
4334
+ // Provider-level warnings the user has to act on (e.g. claude refusing
4335
+ // bypassPermissions as root without IS_SANDBOX=1). Sent right after the
4336
+ // "session started" line so the reason is visible before the turn fails.
4337
+ async reportBackendNotices() {
4338
+ const snapshot =
4339
+ typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
4340
+ const notices = Array.isArray(snapshot?.notices)
4341
+ ? snapshot.notices.filter((notice) => typeof notice === "string" && notice.trim())
4342
+ : [];
4343
+ for (const notice of notices) {
4344
+ try {
4345
+ await this.conductor.sendMessage(this.taskId, notice, {
4346
+ severity: "warning",
4347
+ backend: this.backendName,
4348
+ cli_args: this.cliArgs,
4349
+ synthetic: true,
4350
+ });
4351
+ this.copilotLog(`backend notice sent: ${sanitizeForLog(notice, 120)}`);
4352
+ } catch (error) {
4353
+ log(`Failed to send backend notice: ${error?.message || error}`);
4354
+ }
4355
+ }
4356
+ }
4357
+
3984
4358
  async reportError(message, replyTo) {
3985
4359
  try {
3986
4360
  await this.conductor.sendMessage(this.taskId, message, {