@love-moon/conductor-cli 0.17.0 → 0.17.2

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,48 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.17.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 0a9488e: Add Kimi website text conversations with persistent login, multi-turn context,
8
+ reply progress, new chats and browser diagnostics. Expose the provider through
9
+ ai-sdk's chat-web backend and the web-kimi Conductor configuration alias.
10
+
11
+ Explicit unknown chat-web providers now fail with a clear error instead of
12
+ silently opening ChatGPT. The existing kimi CLI backend is unchanged.
13
+
14
+ - b16c1ae: Allow a shared daemon to run the AI side of a Global AI Backend task while code
15
+ stays on one of the user's own daemons. Advertise
16
+ `shared_ai_global_backend_v1` and forward the task id on remote exec, file, and
17
+ MCP requests so the backend can authorize only that task's remote binding.
18
+
19
+ Keep remote grants tied to active tasks: shared credentials cannot mint remote
20
+ bindings, renew them through restarts or persistent rounds, or reactivate ended
21
+ tasks through status updates. Check shared AI compatibility when restarting or
22
+ starting later rounds of an existing remote task too.
23
+
24
+ - Updated dependencies [b124565]
25
+ - Updated dependencies [0a9488e]
26
+ - @love-moon/ai-sdk@0.17.2
27
+ - @love-moon/conductor-sdk@0.17.2
28
+
29
+ ## 0.17.1
30
+
31
+ ### Patch Changes
32
+
33
+ - 7e92c0f: When a task's model quota runs out ("You've hit your session limit · resets
34
+ 1:20am"), fire no longer stops there. It reads the reset time, schedules a
35
+ one-off Conductor scheduled message to continue a minute or few after the
36
+ reset, and posts a single notice. Messages that arrive while waiting are queued
37
+ and handed to the resume turn together instead of each failing with another
38
+ "处理失败". The resume shows up in the task's scheduled-message list, where it
39
+ can be canceled. This also covers a quota hit while Claude works on its own
40
+ between user turns: ai-sdk now reports such a background turn that ends in an
41
+ error result (or a process exit after it) as a failed turn.
42
+ - Updated dependencies [7e92c0f]
43
+ - @love-moon/ai-sdk@0.17.1
44
+ - @love-moon/conductor-sdk@0.17.1
45
+
3
46
  ## 0.17.0
4
47
 
5
48
  ### Minor Changes
@@ -55,13 +55,19 @@ const DEFAULT_CLIs = {
55
55
  // chat-web is the runtime backend (an in-process Chromium driver, not a CLI
56
56
  // binary). It has multiple sub-providers selected via --model. Each user-
57
57
  // facing alias below resolves to the chat-web runtime; advertising the bare
58
- // `chat-web` backend would be ambiguous, so we emit two explicit aliases.
58
+ // `chat-web` backend would be ambiguous, so we emit explicit aliases.
59
59
  "web-chatgpt": {
60
60
  command: "chat-web",
61
61
  execArgs: "--model chatgpt",
62
62
  description: "Chat web (ChatGPT) via @love-moon/chat-web",
63
63
  runtimeBackend: "chat-web"
64
64
  },
65
+ "web-kimi": {
66
+ command: "chat-web",
67
+ execArgs: "--model kimi",
68
+ description: "Chat web (Kimi) via @love-moon/chat-web",
69
+ runtimeBackend: "chat-web"
70
+ },
65
71
  "web-gemini": {
66
72
  command: "chat-web",
67
73
  execArgs: "--model gemini",
@@ -137,10 +143,10 @@ function buildConfigEntryLines(cli, info, { commented = false } = {}) {
137
143
  lines.push(`${commentPrefix}opencode runs via ai-sdk server mode with permission=allow`);
138
144
  }
139
145
  if (cli === "web-chatgpt") {
140
- // web-chatgpt / web-gemini both resolve to the chat-web runtime backend
146
+ // web-chatgpt / web-gemini / web-kimi resolve to the chat-web runtime backend
141
147
  // (an in-process Chromium driver via @love-moon/chat-web). The command
142
148
  // line is not executed; CLI parses --model from it.
143
- lines.push(`${commentPrefix}web-chatgpt / web-gemini drive a real Chromium browser via @love-moon/chat-web`);
149
+ lines.push(`${commentPrefix}web-chatgpt / web-gemini / web-kimi drive a real Chromium browser via @love-moon/chat-web`);
144
150
  }
145
151
 
146
152
  lines.push(`${entryPrefix}${cli}: ${fullCommand}`);
@@ -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,
@@ -929,10 +940,10 @@ async function main() {
929
940
  env: process.env,
930
941
  });
931
942
 
932
- // chat-web tasks drive a real Chromium browser that holds a per-profile
933
- // singleton lock for as long as the task lives. A task that never ends
934
- // pins that browser indefinitely (and blocks other chats for the same
935
- // provider), so we cap its active lifetime and auto-stop it when exceeded.
943
+ // chat-web tasks retain a tab in the shared profile browser while alive.
944
+ // New runtimes schedule turns across tasks; older runtimes exclusively
945
+ // own the profile. Cap task lifetime so abandoned sessions release their
946
+ // browser resources in either runtime.
936
947
  // The conversation itself is preserved (it lives in the provider account
937
948
  // and the persisted profile; closing the browser does not delete it).
938
949
  const runtimeBackendToken = String(sessionCommandLine || "")
@@ -2100,8 +2111,19 @@ export class BridgeRunner {
2100
2111
  shouldProcessPrePrompt,
2101
2112
  createFreshBackendSession,
2102
2113
  withFreshSessionBootstrap,
2114
+ quotaResumeScheduler,
2115
+ now,
2116
+ random,
2103
2117
  }) {
2104
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;
2105
2127
  this.createFreshBackendSession = createFreshBackendSession;
2106
2128
  this.withFreshSessionBootstrap = withFreshSessionBootstrap;
2107
2129
  this.conductor = conductor;
@@ -2428,6 +2450,7 @@ export class BridgeRunner {
2428
2450
  if (this.needsReconnectRecovery && !this.runningTurn) {
2429
2451
  await this.recoverAfterReconnect();
2430
2452
  }
2453
+ await this.runLocalQuotaResumeIfDue();
2431
2454
  let processed = false;
2432
2455
  try {
2433
2456
  processed = await this.processIncomingBatch();
@@ -3255,6 +3278,19 @@ export class BridgeRunner {
3255
3278
  }
3256
3279
 
3257
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
+ }
3258
3294
  await this.reportRuntimeStatus(payload, payload?.replyTo);
3259
3295
  }
3260
3296
 
@@ -3319,6 +3355,228 @@ export class BridgeRunner {
3319
3355
  this.errorLoop = null;
3320
3356
  }
3321
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
+
3322
3580
  evaluateErrorLoop(errorMessage) {
3323
3581
  const normalizedKey = normalizeExecutionErrorKey(errorMessage);
3324
3582
  const now = Date.now();
@@ -3383,7 +3641,7 @@ export class BridgeRunner {
3383
3641
  )
3384
3642
  : [];
3385
3643
  const rawContent = String(message.content || "").trim();
3386
- const content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
3644
+ let content = rawContent || (localAttachments.length ? "Analyze the attached files." : "");
3387
3645
  if (!content) {
3388
3646
  this.copilotLog(`skip empty message replyTo=${message?.message_id || "latest"}`);
3389
3647
  return;
@@ -3397,6 +3655,16 @@ export class BridgeRunner {
3397
3655
  this.copilotLog(`skip in-flight duplicated message replyTo=${replyTo}`);
3398
3656
  return;
3399
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
+ }
3400
3668
  if (replyTo) {
3401
3669
  this.inFlightMessageIds.add(replyTo);
3402
3670
  }
@@ -3538,6 +3806,8 @@ export class BridgeRunner {
3538
3806
  this.pendingInitialPrompt = "";
3539
3807
  }
3540
3808
  this.resetErrorLoop();
3809
+ this.quotaResumeAttempts = 0;
3810
+ this.lastQuotaResumeQueue = null;
3541
3811
  if (this.useSessionFileReplyStream) {
3542
3812
  this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
3543
3813
  } else {
@@ -3565,6 +3835,12 @@ export class BridgeRunner {
3565
3835
  if (await this.settleCodexCheckpointUnavailableAfterStream(replyTo, errorMessage)) {
3566
3836
  return;
3567
3837
  }
3838
+ if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo, content })) {
3839
+ if (replyTo) {
3840
+ this.processedMessageIds.add(replyTo);
3841
+ }
3842
+ return;
3843
+ }
3568
3844
  this.copilotLog(
3569
3845
  `turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
3570
3846
  );
@@ -3942,6 +4218,12 @@ export class BridgeRunner {
3942
4218
  surfaceUserMessage,
3943
4219
  replyMetadata,
3944
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
+ }
3945
4227
  this.lastRuntimeStatusSignature = null;
3946
4228
  this.runningTurn = true;
3947
4229
  // Lets a target-less interrupt (`/stop`) reach this turn.
@@ -4032,6 +4314,9 @@ export class BridgeRunner {
4032
4314
  ) {
4033
4315
  return;
4034
4316
  }
4317
+ if (await this.enterQuotaWaitIfQuotaError(errorMessage, { replyTo: replyTarget, content })) {
4318
+ return;
4319
+ }
4035
4320
  this.copilotLog(
4036
4321
  `${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
4037
4322
  );
@@ -19,7 +19,7 @@
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>] [--code-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>
@@ -875,7 +875,8 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
875
875
  .option("refresh-session", { type: "boolean", default: false, describe: "Start a fresh AI session in place" })
876
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
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" }),
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" }),
879
880
  run(extra.handleRestart),
880
881
  )
881
882
  .command(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.17.0",
4
- "gitCommitId": "2799bc0",
3
+ "version": "0.17.2",
4
+ "gitCommitId": "1671ee1",
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.17.0",
28
- "@love-moon/conductor-sdk": "0.17.0",
27
+ "@love-moon/ai-sdk": "0.17.2",
28
+ "@love-moon/conductor-sdk": "0.17.2",
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.17.0"
41
+ "@love-moon/chat-web": "0.17.2"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -3928,6 +3928,9 @@ export function startDaemon(config = {}, deps = {}) {
3928
3928
  // another daemon over `conductor remote` and binds the remote_* MCP tools
3929
3929
  // to a remoteWorkspace as well as a remoteWorktree.
3930
3930
  "global_backend_v1",
3931
+ // A shared AI also sends CONDUCTOR_TASK_ID on every remote request so the
3932
+ // backend can grant only that task's A → B remote permission.
3933
+ "shared_ai_global_backend_v1",
3931
3934
  "backend_session_list",
3932
3935
  CUSTOM_COMMANDS_CAPABILITY,
3933
3936
  UPDATE_DAEMON_CAPABILITY,
@@ -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
+ }
@@ -62,7 +62,11 @@ export function loadCliConfig(configFile, env = process.env) {
62
62
  }
63
63
 
64
64
  function authHeaders(config) {
65
- return { Authorization: `Bearer ${config.agentToken}` };
65
+ const taskId = process.env.CONDUCTOR_TASK_ID?.trim();
66
+ return {
67
+ Authorization: `Bearer ${config.agentToken}`,
68
+ ...(taskId ? { "X-Conductor-Task-Id": taskId } : {}),
69
+ };
66
70
  }
67
71
 
68
72
  async function toApiError(response) {
@@ -19,6 +19,7 @@ export const REMOTE_WORKTREE_ENV = "CONDUCTOR_REMOTE_WORKTREE";
19
19
  const FORWARDED_ENV = [
20
20
  "CONDUCTOR_AGENT_TOKEN",
21
21
  "CONDUCTOR_BACKEND_URL",
22
+ "CONDUCTOR_TASK_ID",
22
23
  "CONDUCTOR_CONFIG",
23
24
  "CONDUCTOR_HOME",
24
25
  "CONDUCTOR_REMOTE_CHUNK_BYTES",
@@ -19,9 +19,9 @@ const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
19
19
  //
20
20
  // chat-web is intentionally NOT here even though it's also command-optional
21
21
  // (it drives a Chromium browser, not a CLI). The reason: chat-web has
22
- // configurable sub-providers (chatgpt / gemini / deepseek) selected via
22
+ // configurable sub-providers (chatgpt / gemini / kimi) selected via
23
23
  // `--model`, and surfacing a bare `chat-web` backend alongside user
24
- // aliases like `web-chatgpt` / `web-gemini` is just confusing — the alias
24
+ // aliases like `web-chatgpt` / `web-gemini` / `web-kimi` is just confusing — the alias
25
25
  // IS the sub-provider choice. Users who want chat-web must declare an
26
26
  // explicit allow_cli_list entry (which is also where `--model` lives).
27
27
  const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot", "dsh"];
@@ -110,6 +110,12 @@ export async function handleRestart(argv, deps) {
110
110
  if (argv.strategy !== "new_task") throw argsError("--first-message requires --strategy new_task");
111
111
  body.first_message = trimmed(argv.firstMessage);
112
112
  }
113
+ if (argv.newWorktree) {
114
+ if (argv.strategy !== "new_task") throw argsError("--new-worktree requires --strategy new_task");
115
+ // A fresh worktree does not load the source conversation; the first message replaces it.
116
+ if (!trimmed(argv.firstMessage)) throw argsError("--new-worktree requires --first-message");
117
+ body.new_worktree = true;
118
+ }
113
119
  if (argv.refreshSession && argv.strategy) {
114
120
  throw argsError("--refresh-session cannot be combined with --strategy");
115
121
  }