@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 +43 -0
- package/bin/conductor-config.js +9 -3
- package/bin/conductor-fire.js +290 -5
- package/bin/conductor-task.js +3 -2
- package/package.json +5 -5
- package/src/daemon.js +3 -0
- package/src/fire/quota-resume.js +313 -0
- package/src/remote/client.js +5 -1
- package/src/remote/mcp-launch.js +1 -0
- package/src/runtime-backends.js +2 -2
- package/src/task-commands.js +6 -0
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
|
package/bin/conductor-config.js
CHANGED
|
@@ -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
|
|
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
|
|
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}`);
|
package/bin/conductor-fire.js
CHANGED
|
@@ -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
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
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
|
-
|
|
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
|
);
|
package/bin/conductor-task.js
CHANGED
|
@@ -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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.17.
|
|
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.
|
|
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
|
+
}
|
package/src/remote/client.js
CHANGED
|
@@ -62,7 +62,11 @@ export function loadCliConfig(configFile, env = process.env) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function authHeaders(config) {
|
|
65
|
-
|
|
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) {
|
package/src/remote/mcp-launch.js
CHANGED
package/src/runtime-backends.js
CHANGED
|
@@ -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 /
|
|
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"];
|
package/src/task-commands.js
CHANGED
|
@@ -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
|
}
|