@love-moon/conductor-cli 0.13.2 → 0.14.0
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 +89 -0
- package/bin/conductor-fire.js +190 -60
- package/package.json +5 -5
- package/src/daemon-update.js +29 -21
- package/src/daemon.js +16 -5
- package/src/remote/client.js +1 -1
- package/src/remote/exec.js +36 -6
- package/src/remote-exec-handlers.js +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e168077: Support `/clear` in task chats. Fire detects a bare `/clear` message (like
|
|
8
|
+
`/compact`) and drops the AI's context instead of sending the text to the model.
|
|
9
|
+
Sessions advertise `capabilities.clear` and implement the new optional
|
|
10
|
+
`runClear()` on top of each backend's own native reset, so the backend process,
|
|
11
|
+
browser or RPC connection stays up:
|
|
12
|
+
|
|
13
|
+
- claude: native `/clear` slash command (the SDK's `conversation_reset` reports
|
|
14
|
+
the new conversation id)
|
|
15
|
+
- codex app-server: `thread/start` on the live transport
|
|
16
|
+
- codex exec: drops the replayed history, which is the whole context for a
|
|
17
|
+
stateless CLI
|
|
18
|
+
- copilot: `createSession()` on the same client; the old conversation is
|
|
19
|
+
detached, not deleted
|
|
20
|
+
- kimi wire and legacy print: the CLI's built-in `/clear` (prompt mode opts out)
|
|
21
|
+
- opencode: `session.create` on the same `opencode serve`
|
|
22
|
+
- dsh: rotates onto a fresh wire session id and keeps the runtime subprocess
|
|
23
|
+
- chat-web: `newChat()` in the same browser and profile
|
|
24
|
+
|
|
25
|
+
Backends without the capability (external providers) fall back to closing the
|
|
26
|
+
session and continuing on a brand-new one. Either way the task's chat history is
|
|
27
|
+
kept and the task is rebound to the session the backend ends up on, so a later
|
|
28
|
+
restart resumes the cleared conversation rather than the old one. The codex
|
|
29
|
+
fresh-session bootstrap lock now covers that fallback too, and the kimi wire
|
|
30
|
+
transport drops configured `--continue`/`--resume`/`--session` flags (like kimi
|
|
31
|
+
print already did) so they cannot reattach a cleared session to the old
|
|
32
|
+
conversation. Two caveats: the configured `pre_prompt` is not re-sent to the
|
|
33
|
+
cleared session, and a chat-web task running with
|
|
34
|
+
`CONDUCTOR_AI_SDK_DISABLE_WORKER=1` can only clear natively — its fallback would
|
|
35
|
+
hit the browser profile lock, because in that mode the lock owner is fire itself.
|
|
36
|
+
|
|
37
|
+
- 12a829c: Show each AI reply's token usage beside its timestamp: the turn's tokens, the
|
|
38
|
+
task total after it, and the input cache share (the part of the turn's input
|
|
39
|
+
served from the prompt cache).
|
|
40
|
+
|
|
41
|
+
- `@love-moon/ai-sdk`: new `summarizeTurnUsage(usage)` normalizes a turn's usage
|
|
42
|
+
into `{ tokens, inputTokens, cachedInputTokens }` (Claude: fresh input + cache
|
|
43
|
+
writes + cache reads; Codex: input already includes cached). The Codex
|
|
44
|
+
app-server provider's turn/goal `usage` now also carries `turnInputTokens` and
|
|
45
|
+
`turnCachedInputTokens`.
|
|
46
|
+
- `@love-moon/conductor-sdk`: `sendTurnUsage` accepts `input_tokens`,
|
|
47
|
+
`cached_input_tokens` and the reply's `message_id`.
|
|
48
|
+
- `conductor fire` reports a finished turn's usage after its reply, naming that
|
|
49
|
+
reply (for streamed sessions, the turn's last streamed reply), so the server
|
|
50
|
+
records it on the message (`metadata.turn_usage`) and pushes it to open chats.
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- b06e80e: Fix new tasks never answering when their first message starts with "-" (a
|
|
55
|
+
markdown list, `--model ...`, `-i ...`). The daemon passed the message to Fire
|
|
56
|
+
as `--prefill <message>`, and Fire's argument parser read the leading dash as a
|
|
57
|
+
new flag and dropped the message. It is now passed as `--prefill=<message>`.
|
|
58
|
+
- 4141981: `conductor remote exec` now retries a POST lost to a network error or 5xx
|
|
59
|
+
without running the command twice. The CLI sends a `runId`, and an upgraded
|
|
60
|
+
daemon (capability `remote_exec_run_id`) returns the existing run for a repeated
|
|
61
|
+
id. Before retrying, the CLI asks the server whether the daemon dedupes. Old
|
|
62
|
+
servers and daemons keep the 429-only retry.
|
|
63
|
+
- b06e80e: "New task from this" can start the new task with a first message you write
|
|
64
|
+
yourself. When one is set, the daemon sends it to the successor AI instead of
|
|
65
|
+
the default prompt that loads the source task's conversation as background. The
|
|
66
|
+
daemon advertises this as the `restart_first_message` capability; for older
|
|
67
|
+
daemons the dialog locks the field (the new task still starts with the default
|
|
68
|
+
background) and the server rejects a custom first message.
|
|
69
|
+
- Updated dependencies [e168077]
|
|
70
|
+
- Updated dependencies [d4f6fb9]
|
|
71
|
+
- Updated dependencies [12a829c]
|
|
72
|
+
- @love-moon/ai-sdk@0.14.0
|
|
73
|
+
- @love-moon/conductor-sdk@0.14.0
|
|
74
|
+
|
|
75
|
+
## 0.13.3
|
|
76
|
+
|
|
77
|
+
### Patch Changes
|
|
78
|
+
|
|
79
|
+
- 9858ad5: Fix Update daemon deleting the running install when the upgrade failed. Any
|
|
80
|
+
failed `npm install -g` (a timeout on a slow registry, a node-gyp error) used to
|
|
81
|
+
trigger an uninstall plus `rm -rf` of the global package before one retry. When
|
|
82
|
+
the retry also failed, the daemon kept running with its files gone and every new
|
|
83
|
+
task died at Fire launch with `MODULE_NOT_FOUND`. The clear-and-retry now only
|
|
84
|
+
runs on `ENOTEMPTY`, and it moves the old install aside and restores it if the
|
|
85
|
+
retry fails.
|
|
86
|
+
- Updated dependencies [0859251]
|
|
87
|
+
- Updated dependencies [c1b66d1]
|
|
88
|
+
- Updated dependencies [f6dbd6b]
|
|
89
|
+
- @love-moon/ai-sdk@0.13.3
|
|
90
|
+
- @love-moon/conductor-sdk@0.13.3
|
|
91
|
+
|
|
3
92
|
## 0.13.2
|
|
4
93
|
|
|
5
94
|
### Patch Changes
|
package/bin/conductor-fire.js
CHANGED
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import yargs from "yargs/yargs";
|
|
19
19
|
import { hideBin } from "yargs/helpers";
|
|
20
20
|
import yaml from "js-yaml";
|
|
21
|
-
import { createAiSession } from "@love-moon/ai-sdk";
|
|
21
|
+
import { createAiSession, summarizeTurnUsage } from "@love-moon/ai-sdk";
|
|
22
22
|
import { ConductorClient, ProjectContext, loadConfig } from "@love-moon/conductor-sdk";
|
|
23
23
|
import {
|
|
24
24
|
buildResumeArgsForBackend as buildCliResumeArgsForBackend,
|
|
@@ -1024,18 +1024,20 @@ async function main() {
|
|
|
1024
1024
|
.trim()
|
|
1025
1025
|
.toLowerCase();
|
|
1026
1026
|
const enableGoalsForBackend = GOAL_CAPABLE_BACKENDS.includes(backendForGoalCheck);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1027
|
+
const openBackendSession = (resumeSessionId, { fresh = false } = {}) =>
|
|
1028
|
+
(backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
|
|
1029
|
+
initialImages: cliArgs.initialImages,
|
|
1030
|
+
cwd: runtimeProjectPath,
|
|
1031
|
+
resumeSessionId,
|
|
1032
|
+
configFile: cliArgs.configFile,
|
|
1033
|
+
...(cliArgs.sessionOptions || {}),
|
|
1034
|
+
...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
|
|
1035
|
+
...(enableGoalsForBackend ? { goalMode: true } : {}),
|
|
1036
|
+
logger: { log },
|
|
1037
|
+
sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
|
|
1038
|
+
resumePersistedSession: Boolean(!fresh && !resumeSessionId && taskContext.taskId),
|
|
1039
|
+
}));
|
|
1040
|
+
openBackendSession(nextResumeSessionId);
|
|
1039
1041
|
|
|
1040
1042
|
const runner = new BridgeRunner({
|
|
1041
1043
|
backendSession,
|
|
@@ -1051,6 +1053,11 @@ async function main() {
|
|
|
1051
1053
|
daemonName: resolvedDaemonName,
|
|
1052
1054
|
prePrompt: resolvedPrePrompt || "",
|
|
1053
1055
|
shouldProcessPrePrompt: Boolean(resolvedPrePrompt) && nextShouldProcessPrePrompt,
|
|
1056
|
+
// `/clear` swaps in a brand-new session (no resume) in-process, and
|
|
1057
|
+
// bootstraps it under the same lock the fresh-session boot path uses.
|
|
1058
|
+
createFreshBackendSession: () => openBackendSession(undefined, { fresh: true }),
|
|
1059
|
+
withFreshSessionBootstrap: (fn) =>
|
|
1060
|
+
withFreshSessionBootstrapLock(cliArgs.sessionBackend || cliArgs.backend, runtimeProjectPath, fn),
|
|
1054
1061
|
});
|
|
1055
1062
|
reconnectRunner = runner;
|
|
1056
1063
|
if (pendingRemoteStopEvent) {
|
|
@@ -1618,6 +1625,14 @@ export function formatCompactReply(backendName, compact, instructions = "") {
|
|
|
1618
1625
|
return `${backendName} 上下文已压缩${detail}。${ignored}`;
|
|
1619
1626
|
}
|
|
1620
1627
|
|
|
1628
|
+
/**
|
|
1629
|
+
* Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
|
|
1630
|
+
* counts: anything after it is more likely an instruction for the model.
|
|
1631
|
+
*/
|
|
1632
|
+
export function isClearCommand(content) {
|
|
1633
|
+
return typeof content === "string" && /^\/clear$/i.test(content.trim());
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1621
1636
|
export function injectResolvedTaskId(taskId, env = process.env) {
|
|
1622
1637
|
const normalizedTaskId = normalizeTaskId(taskId);
|
|
1623
1638
|
if (!normalizedTaskId) {
|
|
@@ -2060,8 +2075,12 @@ export class BridgeRunner {
|
|
|
2060
2075
|
daemonName,
|
|
2061
2076
|
prePrompt,
|
|
2062
2077
|
shouldProcessPrePrompt,
|
|
2078
|
+
createFreshBackendSession,
|
|
2079
|
+
withFreshSessionBootstrap,
|
|
2063
2080
|
}) {
|
|
2064
2081
|
this.backendSession = backendSession;
|
|
2082
|
+
this.createFreshBackendSession = createFreshBackendSession;
|
|
2083
|
+
this.withFreshSessionBootstrap = withFreshSessionBootstrap;
|
|
2065
2084
|
this.conductor = conductor;
|
|
2066
2085
|
this.taskId = taskId;
|
|
2067
2086
|
this.pollIntervalMs = pollIntervalMs;
|
|
@@ -2093,6 +2112,8 @@ export class BridgeRunner {
|
|
|
2093
2112
|
typeof prePrompt === "string" && prePrompt.trim() ? prePrompt.trim() : "";
|
|
2094
2113
|
this.shouldProcessPrePrompt = Boolean(shouldProcessPrePrompt) && Boolean(this.prePrompt);
|
|
2095
2114
|
this.sessionStreamReplyCounts = new Map();
|
|
2115
|
+
// Last streamed reply per reply target: the message a streamed turn's usage is recorded on.
|
|
2116
|
+
this.lastStreamedReplyIds = new Map();
|
|
2096
2117
|
this.lastRuntimeStatusSignature = null;
|
|
2097
2118
|
this.lastRuntimeStatusPayload = null;
|
|
2098
2119
|
this.runtimeContextSnapshot = null;
|
|
@@ -2150,6 +2171,10 @@ export class BridgeRunner {
|
|
|
2150
2171
|
2,
|
|
2151
2172
|
20,
|
|
2152
2173
|
);
|
|
2174
|
+
this.attachSessionStreamHandlers();
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
attachSessionStreamHandlers() {
|
|
2153
2178
|
if (
|
|
2154
2179
|
this.useSessionFileReplyStream &&
|
|
2155
2180
|
typeof this.backendSession?.setSessionMessageHandler === "function"
|
|
@@ -2326,6 +2351,13 @@ export class BridgeRunner {
|
|
|
2326
2351
|
this.copilotLog(`session binding sync skipped: ${sanitizeForLog(error?.message || error, 160)}`);
|
|
2327
2352
|
return false;
|
|
2328
2353
|
}
|
|
2354
|
+
// A deferred id is a local placeholder (chat-web before its first reply,
|
|
2355
|
+
// and right after a native /clear) — binding it would point the task at
|
|
2356
|
+
// something that can never be resumed. announceBackendSession holds off for
|
|
2357
|
+
// the same reason; the real id lands with the next completed turn.
|
|
2358
|
+
if (sessionInfo?.sessionIdDeferred === true) {
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2329
2361
|
return this.persistTaskSessionBinding({
|
|
2330
2362
|
sessionId: sessionInfo?.sessionId,
|
|
2331
2363
|
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
@@ -3145,7 +3177,7 @@ export class BridgeRunner {
|
|
|
3145
3177
|
usage: null,
|
|
3146
3178
|
replyTo: replyTo || "latest",
|
|
3147
3179
|
});
|
|
3148
|
-
await this.conductor.sendMessage(this.taskId, normalizedText, {
|
|
3180
|
+
const sent = await this.conductor.sendMessage(this.taskId, normalizedText, {
|
|
3149
3181
|
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3150
3182
|
backend: this.backendName,
|
|
3151
3183
|
thread_id: sessionId || this.backendSession.threadId,
|
|
@@ -3161,6 +3193,9 @@ export class BridgeRunner {
|
|
|
3161
3193
|
replyKey,
|
|
3162
3194
|
Number(this.sessionStreamReplyCounts.get(replyKey) || 0) + 1,
|
|
3163
3195
|
);
|
|
3196
|
+
if (sent?.message_id) {
|
|
3197
|
+
this.lastStreamedReplyIds.set(replyKey, sent.message_id);
|
|
3198
|
+
}
|
|
3164
3199
|
this.copilotLog(
|
|
3165
3200
|
`session_file sdk_message sent replyTo=${replyTo || "latest"} responseLen=${normalizedText.length}`,
|
|
3166
3201
|
);
|
|
@@ -3175,6 +3210,14 @@ export class BridgeRunner {
|
|
|
3175
3210
|
return normalizedReplyTo || "__latest__";
|
|
3176
3211
|
}
|
|
3177
3212
|
|
|
3213
|
+
/** A streamed turn's last reply, read once so a later turn never reuses it. */
|
|
3214
|
+
takeLastStreamedReplyId(replyTo) {
|
|
3215
|
+
const replyKey = this.normalizeSessionStreamReplyKey(replyTo);
|
|
3216
|
+
const messageId = this.lastStreamedReplyIds.get(replyKey);
|
|
3217
|
+
this.lastStreamedReplyIds.delete(replyKey);
|
|
3218
|
+
return messageId;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3178
3221
|
getSessionStreamReplyCount(replyTo) {
|
|
3179
3222
|
return Number(
|
|
3180
3223
|
this.sessionStreamReplyCounts.get(this.normalizeSessionStreamReplyKey(replyTo)) || 0,
|
|
@@ -3333,6 +3376,8 @@ export class BridgeRunner {
|
|
|
3333
3376
|
}
|
|
3334
3377
|
this.lastRuntimeStatusSignature = null;
|
|
3335
3378
|
this.runningTurn = true;
|
|
3379
|
+
// Only this turn's own streamed replies may carry its usage.
|
|
3380
|
+
this.lastStreamedReplyIds.clear();
|
|
3336
3381
|
this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
|
|
3337
3382
|
const turnStartedAt = Date.now();
|
|
3338
3383
|
this.turnStartedAt = turnStartedAt;
|
|
@@ -3403,22 +3448,30 @@ export class BridgeRunner {
|
|
|
3403
3448
|
);
|
|
3404
3449
|
}
|
|
3405
3450
|
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
usage: result.usage ||
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3451
|
+
// The usage report names the reply it belongs to, so it is sent after that
|
|
3452
|
+
// reply — but a turn that failed to post its reply still spent its tokens.
|
|
3453
|
+
let replyMessageId = this.takeLastStreamedReplyId(replyTo);
|
|
3454
|
+
try {
|
|
3455
|
+
if (!this.useSessionFileReplyStream) {
|
|
3456
|
+
const responseText =
|
|
3457
|
+
result.text ||
|
|
3458
|
+
extractAgentTextFromItems(result.items) ||
|
|
3459
|
+
extractAgentTextFromMetadata(result.metadata) ||
|
|
3460
|
+
`(${this.backendName} 未返回任何文本)`;
|
|
3461
|
+
logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
|
|
3462
|
+
const sent = await this.conductor.sendMessage(this.taskId, responseText, {
|
|
3463
|
+
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3464
|
+
backend: this.backendName,
|
|
3465
|
+
usage: result.usage || null,
|
|
3466
|
+
thread_id: this.backendSession.threadId,
|
|
3467
|
+
items: result.items,
|
|
3468
|
+
reply_to: replyTo,
|
|
3469
|
+
cli_args: this.cliArgs,
|
|
3470
|
+
});
|
|
3471
|
+
replyMessageId = sent?.message_id;
|
|
3472
|
+
}
|
|
3473
|
+
} finally {
|
|
3474
|
+
this.reportTurnUsage(result.usage, { messageId: replyMessageId });
|
|
3422
3475
|
}
|
|
3423
3476
|
await this.syncBackendSessionBinding();
|
|
3424
3477
|
if (replyTo) {
|
|
@@ -3589,6 +3642,9 @@ export class BridgeRunner {
|
|
|
3589
3642
|
if (compactDirective) {
|
|
3590
3643
|
return this.runCompactCommand(compactDirective, options);
|
|
3591
3644
|
}
|
|
3645
|
+
if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
|
|
3646
|
+
return this.runClearCommand(options);
|
|
3647
|
+
}
|
|
3592
3648
|
|
|
3593
3649
|
if (willRunGoal) {
|
|
3594
3650
|
const goalResult = await this.runWithTurnUsage(() =>
|
|
@@ -3615,30 +3671,39 @@ export class BridgeRunner {
|
|
|
3615
3671
|
return this.runWithTurnUsage(() => this.backendSession.runTurn(content, options));
|
|
3616
3672
|
}
|
|
3617
3673
|
|
|
3618
|
-
/**
|
|
3674
|
+
/**
|
|
3675
|
+
* Runs one backend turn and reports a failed or interrupted turn's token
|
|
3676
|
+
* count; the caller reports a finished turn's once its reply is sent.
|
|
3677
|
+
*/
|
|
3619
3678
|
async runWithTurnUsage(runTurn) {
|
|
3620
|
-
let result;
|
|
3621
3679
|
try {
|
|
3622
|
-
|
|
3680
|
+
return await runTurn();
|
|
3623
3681
|
} catch (error) {
|
|
3624
3682
|
this.reportTurnUsage(error?.usage, { failed: true });
|
|
3625
3683
|
throw error;
|
|
3626
3684
|
}
|
|
3627
|
-
this.reportTurnUsage(result?.usage);
|
|
3628
|
-
return result;
|
|
3629
3685
|
}
|
|
3630
3686
|
|
|
3631
3687
|
/**
|
|
3632
|
-
* Best-effort: report a turn's token
|
|
3633
|
-
*
|
|
3688
|
+
* Best-effort: report a turn's token usage to the server, tied to its reply
|
|
3689
|
+
* message when there is one. A failed turn with unknown usage reports `null`
|
|
3690
|
+
* so the task card drops the previous turn's count.
|
|
3634
3691
|
*/
|
|
3635
|
-
reportTurnUsage(usage, { failed = false } = {}) {
|
|
3636
|
-
const
|
|
3637
|
-
if ((
|
|
3692
|
+
reportTurnUsage(usage, { failed = false, messageId } = {}) {
|
|
3693
|
+
const summary = summarizeTurnUsage(usage);
|
|
3694
|
+
if ((!summary && !failed) || typeof this.conductor?.sendTurnUsage !== "function") {
|
|
3638
3695
|
return;
|
|
3639
3696
|
}
|
|
3640
3697
|
Promise.resolve()
|
|
3641
|
-
.then(() =>
|
|
3698
|
+
.then(() =>
|
|
3699
|
+
this.conductor.sendTurnUsage(this.taskId, {
|
|
3700
|
+
tokens: summary?.tokens ?? null,
|
|
3701
|
+
...(summary?.inputTokens !== undefined
|
|
3702
|
+
? { input_tokens: summary.inputTokens, cached_input_tokens: summary.cachedInputTokens }
|
|
3703
|
+
: {}),
|
|
3704
|
+
...(messageId ? { message_id: messageId } : {}),
|
|
3705
|
+
}),
|
|
3706
|
+
)
|
|
3642
3707
|
.catch((error) => {
|
|
3643
3708
|
log(`Failed to report turn usage: ${error?.message || error}`);
|
|
3644
3709
|
});
|
|
@@ -3689,6 +3754,85 @@ export class BridgeRunner {
|
|
|
3689
3754
|
};
|
|
3690
3755
|
}
|
|
3691
3756
|
|
|
3757
|
+
/**
|
|
3758
|
+
* `/clear`: drop the AI context. Backends that advertise
|
|
3759
|
+
* `capabilities.clear` run their own native clear (claude's `/clear` slash
|
|
3760
|
+
* command, kimi's built-in `/clear`, …) so the process and its session file
|
|
3761
|
+
* survive; everyone else falls back to closing the session and swapping in a
|
|
3762
|
+
* fresh one. Either way the task's chat history is kept and the task is
|
|
3763
|
+
* rebound to whatever session id the backend ends up on, so a later fire
|
|
3764
|
+
* restart resumes the cleared conversation instead of the old one.
|
|
3765
|
+
*/
|
|
3766
|
+
async runClearCommand({ onProgress, replyTo = "" } = {}) {
|
|
3767
|
+
onProgress?.({
|
|
3768
|
+
phase: "context_clear",
|
|
3769
|
+
reply_in_progress: true,
|
|
3770
|
+
status_line: `${this.backendName} clearing context`,
|
|
3771
|
+
});
|
|
3772
|
+
const snapshot =
|
|
3773
|
+
typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
|
|
3774
|
+
const clearCapable =
|
|
3775
|
+
snapshot?.capabilities?.clear === true && typeof this.backendSession?.runClear === "function";
|
|
3776
|
+
if (clearCapable) {
|
|
3777
|
+
const result = await this.runWithTurnUsage(() => this.backendSession.runClear({}, { onProgress }));
|
|
3778
|
+
// A native clear may land on a new session id (claude does); rebind so a
|
|
3779
|
+
// restart resumes the cleared conversation.
|
|
3780
|
+
this.runtimeContextSnapshot = null;
|
|
3781
|
+
await this.syncBackendSessionBinding();
|
|
3782
|
+
const cleared = result?.clear?.status !== "noop";
|
|
3783
|
+
const text = cleared
|
|
3784
|
+
? `${this.backendName} 上下文已清除。`
|
|
3785
|
+
: `${this.backendName} 当前没有可清除的上下文。`;
|
|
3786
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
3787
|
+
log(`[clear] backend=${this.backendName} native status=${result?.clear?.status || "cleared"}`);
|
|
3788
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
3789
|
+
try {
|
|
3790
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
3791
|
+
} catch (error) {
|
|
3792
|
+
log(`[clear] failed to post confirmation: ${error?.message || error}`);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
return {
|
|
3796
|
+
text,
|
|
3797
|
+
items: [],
|
|
3798
|
+
usage: result?.usage || null,
|
|
3799
|
+
provider: this.backendName,
|
|
3800
|
+
events: [],
|
|
3801
|
+
metadata: { ...(result?.metadata || {}), clear: result?.clear || null },
|
|
3802
|
+
};
|
|
3803
|
+
}
|
|
3804
|
+
try {
|
|
3805
|
+
await this.backendSession?.close?.();
|
|
3806
|
+
} catch (error) {
|
|
3807
|
+
log(`[clear] failed to close previous backend session: ${error?.message || error}`);
|
|
3808
|
+
}
|
|
3809
|
+
if (this.stopped) {
|
|
3810
|
+
// The task is shutting down; don't spawn a session nobody will close.
|
|
3811
|
+
throw Object.assign(new Error("backend session closed during /clear"), { reason: "session_closed" });
|
|
3812
|
+
}
|
|
3813
|
+
this.backendSession = this.createFreshBackendSession();
|
|
3814
|
+
this.resumeSessionId = "";
|
|
3815
|
+
this.sessionAnnouncementSent = false;
|
|
3816
|
+
this.sessionAnnouncementSubscribed = false;
|
|
3817
|
+
this.runtimeContextSnapshot = null;
|
|
3818
|
+
this.attachSessionStreamHandlers();
|
|
3819
|
+
await (this.withFreshSessionBootstrap
|
|
3820
|
+
? this.withFreshSessionBootstrap(() => this.announceBackendSession())
|
|
3821
|
+
: this.announceBackendSession());
|
|
3822
|
+
|
|
3823
|
+
const text = `${this.backendName} 上下文已清除,已开始新会话。`;
|
|
3824
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
3825
|
+
log(`[clear] backend=${this.backendName} started a fresh session`);
|
|
3826
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
3827
|
+
try {
|
|
3828
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
log(`[clear] failed to post confirmation: ${error?.message || error}`);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3692
3836
|
async handlePrePromptMessage(content) {
|
|
3693
3837
|
const text = typeof content === "string" ? content.trim() : "";
|
|
3694
3838
|
if (!text) {
|
|
@@ -3726,6 +3870,8 @@ export class BridgeRunner {
|
|
|
3726
3870
|
}) {
|
|
3727
3871
|
this.lastRuntimeStatusSignature = null;
|
|
3728
3872
|
this.runningTurn = true;
|
|
3873
|
+
// Only this turn's own streamed replies may carry its usage.
|
|
3874
|
+
this.lastStreamedReplyIds.clear();
|
|
3729
3875
|
const startedAt = Date.now();
|
|
3730
3876
|
this.turnStartedAt = startedAt;
|
|
3731
3877
|
if (
|
|
@@ -3776,7 +3922,7 @@ export class BridgeRunner {
|
|
|
3776
3922
|
usage: result.usage,
|
|
3777
3923
|
replyTo: replyTarget,
|
|
3778
3924
|
});
|
|
3779
|
-
await this.conductor.sendMessage(this.taskId, text, {
|
|
3925
|
+
const sent = await this.conductor.sendMessage(this.taskId, text, {
|
|
3780
3926
|
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3781
3927
|
backend: this.backendName,
|
|
3782
3928
|
usage: result.usage || null,
|
|
@@ -3785,8 +3931,10 @@ export class BridgeRunner {
|
|
|
3785
3931
|
synthetic: true,
|
|
3786
3932
|
...(replyMetadata || {}),
|
|
3787
3933
|
});
|
|
3934
|
+
this.reportTurnUsage(result.usage, { messageId: sent?.message_id });
|
|
3788
3935
|
this.copilotLog(`${logTag} sdk_message sent responseLen=${text.length}`);
|
|
3789
3936
|
} else {
|
|
3937
|
+
this.reportTurnUsage(result.usage, { messageId: this.takeLastStreamedReplyId(replyTarget) });
|
|
3790
3938
|
this.copilotLog(`${logTag} session_file turn settled`);
|
|
3791
3939
|
}
|
|
3792
3940
|
await this.syncBackendSessionBinding();
|
|
@@ -3872,24 +4020,6 @@ function log(message) {
|
|
|
3872
4020
|
appendFireLocalLog(line);
|
|
3873
4021
|
}
|
|
3874
4022
|
|
|
3875
|
-
/**
|
|
3876
|
-
* Tokens one turn consumed: everything the model read (fresh input plus cache
|
|
3877
|
-
* reads/writes) and wrote. Claude reports per-turn usage; the Codex provider
|
|
3878
|
-
* derives `turnTotalTokens` from thread totals. Other shapes → null.
|
|
3879
|
-
*/
|
|
3880
|
-
export function countTurnTokens(usage) {
|
|
3881
|
-
if (!usage || typeof usage !== "object") {
|
|
3882
|
-
return null;
|
|
3883
|
-
}
|
|
3884
|
-
if (Number.isFinite(usage.turnTotalTokens)) {
|
|
3885
|
-
return usage.turnTotalTokens;
|
|
3886
|
-
}
|
|
3887
|
-
const counts = ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens"]
|
|
3888
|
-
.map((key) => Number(usage[key]))
|
|
3889
|
-
.filter(Number.isFinite);
|
|
3890
|
-
return counts.length > 0 ? counts.reduce((sum, count) => sum + count, 0) : null;
|
|
3891
|
-
}
|
|
3892
|
-
|
|
3893
4023
|
function logBackendReply(backend, text, { usage, replyTo }) {
|
|
3894
4024
|
const usageSuffix = usage?.total_tokens ? ` (tokens: ${usage.total_tokens})` : "";
|
|
3895
4025
|
log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"gitCommitId": "6def94c",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"test": "node --test test/*.test.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@love-moon/ai-sdk": "0.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.
|
|
27
|
+
"@love-moon/ai-sdk": "0.14.0",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.14.0",
|
|
29
29
|
"@github/copilot-sdk": "^0.3.0",
|
|
30
30
|
"chrome-launcher": "^1.2.1",
|
|
31
31
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@roamhq/wrtc": "^0.10.0",
|
|
41
|
-
"@love-moon/chat-web": "0.
|
|
41
|
+
"@love-moon/chat-web": "0.14.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
package/src/daemon-update.js
CHANGED
|
@@ -272,17 +272,6 @@ function buildInstallCommand(packageManager, pkgSpec) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
function buildUninstallCommand(packageManager, packageName) {
|
|
276
|
-
switch (packageManager) {
|
|
277
|
-
case "pnpm":
|
|
278
|
-
return { command: "pnpm", args: ["remove", "-g", packageName] };
|
|
279
|
-
case "yarn":
|
|
280
|
-
return { command: "yarn", args: ["global", "remove", packageName] };
|
|
281
|
-
default:
|
|
282
|
-
return { command: "npm", args: ["uninstall", "-g", packageName] };
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
275
|
/**
|
|
287
276
|
* `npm install -g` targets whichever npm wins the PATH lookup, which is not
|
|
288
277
|
* necessarily the one that installed us. Pin it to the prefix the running
|
|
@@ -320,6 +309,7 @@ export async function runDaemonUpdate(params = {}, deps = {}) {
|
|
|
320
309
|
const killFn = deps.kill || process.kill.bind(process);
|
|
321
310
|
const readFileSync = deps.readFileSync || fs.readFileSync;
|
|
322
311
|
const rmSync = deps.rmSync || fs.rmSync;
|
|
312
|
+
const renameSync = deps.renameSync || fs.renameSync;
|
|
323
313
|
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
324
314
|
const now = deps.now || (() => Date.now());
|
|
325
315
|
const writeStatus = deps.writeStatus || writeDaemonUpdateStatus;
|
|
@@ -422,25 +412,43 @@ export async function runDaemonUpdate(params = {}, deps = {}) {
|
|
|
422
412
|
|
|
423
413
|
log(`${install.command} ${install.args.join(" ")}`);
|
|
424
414
|
let installResult = await runInstall();
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
415
|
+
// A half-removed global install makes npm fail with ENOTEMPTY forever. Move
|
|
416
|
+
// it aside and try once more. Any other failure (timeout, network, build)
|
|
417
|
+
// leaves the install untouched: the daemon and every fire it spawns run
|
|
418
|
+
// from that directory.
|
|
419
|
+
if (!installResult.success && /ENOTEMPTY/.test(`${installResult.stderr}${installResult.stdout}`)) {
|
|
420
|
+
log(`install failed (exit ${installResult.code}); moving the broken install aside and retrying`);
|
|
429
421
|
log(String(installResult.stderr || installResult.stdout || "").trim().slice(-800));
|
|
430
|
-
|
|
431
|
-
|
|
422
|
+
let packageDirectory = null;
|
|
423
|
+
let backupDirectory = null;
|
|
432
424
|
try {
|
|
433
|
-
|
|
425
|
+
packageDirectory = await resolveGlobalPackageDirectory({
|
|
434
426
|
packageManager,
|
|
435
427
|
packageName,
|
|
436
428
|
runCommand: (command, args, options) => runCommand(command, args, { env: installEnv, ...options }),
|
|
437
429
|
});
|
|
438
|
-
|
|
439
|
-
|
|
430
|
+
const backup = path.join(path.dirname(packageDirectory), `.${path.basename(packageDirectory)}-update-backup`);
|
|
431
|
+
rmSync(backup, { recursive: true, force: true });
|
|
432
|
+
renameSync(packageDirectory, backup);
|
|
433
|
+
backupDirectory = backup;
|
|
434
|
+
log(`moved ${packageDirectory} aside`);
|
|
440
435
|
} catch (error) {
|
|
441
|
-
log(`could not
|
|
436
|
+
log(`could not move the global package directory aside: ${error?.message || error}`);
|
|
442
437
|
}
|
|
443
438
|
installResult = await runInstall();
|
|
439
|
+
if (backupDirectory) {
|
|
440
|
+
try {
|
|
441
|
+
if (installResult.success) {
|
|
442
|
+
rmSync(backupDirectory, { recursive: true, force: true });
|
|
443
|
+
} else {
|
|
444
|
+
rmSync(packageDirectory, { recursive: true, force: true });
|
|
445
|
+
renameSync(backupDirectory, packageDirectory);
|
|
446
|
+
log(`restored ${packageDirectory}`);
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
log(`could not clean up ${backupDirectory}: ${error?.message || error}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
444
452
|
}
|
|
445
453
|
if (!installResult.success) {
|
|
446
454
|
return fail(
|
package/src/daemon.js
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
} from "./custom-command-handlers.js";
|
|
41
41
|
import {
|
|
42
42
|
REMOTE_EXEC_CAPABILITY,
|
|
43
|
+
REMOTE_EXEC_RUN_ID_CAPABILITY,
|
|
43
44
|
createRemoteExecHandlers,
|
|
44
45
|
handleRemoteExecRequest,
|
|
45
46
|
} from "./remote-exec-handlers.js";
|
|
@@ -703,7 +704,9 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
|
|
|
703
704
|
const prefill = typeof initialContent === "string" ? initialContent : "";
|
|
704
705
|
const effectivePrefill = prefill || goalObjective;
|
|
705
706
|
if (effectivePrefill) {
|
|
706
|
-
|
|
707
|
+
// `=` form: as a separate argv entry, yargs reads content that starts with
|
|
708
|
+
// "-" (a markdown list, "--model ...") as a new flag and drops it.
|
|
709
|
+
args.push(`--prefill=${effectivePrefill}`);
|
|
707
710
|
}
|
|
708
711
|
const resumeSessionId =
|
|
709
712
|
normalizeOptionalString(launchConfig?.resumeSessionId) ||
|
|
@@ -3883,6 +3886,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3883
3886
|
"project_agents_registry",
|
|
3884
3887
|
"restart_daemon",
|
|
3885
3888
|
"refresh_session_inplace",
|
|
3889
|
+
"restart_first_message",
|
|
3886
3890
|
"persistent_round_v1",
|
|
3887
3891
|
"task_attachments_v1",
|
|
3888
3892
|
"backend_session_list",
|
|
@@ -3890,7 +3894,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3890
3894
|
UPDATE_DAEMON_CAPABILITY,
|
|
3891
3895
|
];
|
|
3892
3896
|
if (remoteExecEnabled) {
|
|
3893
|
-
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
|
|
3897
|
+
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY, REMOTE_EXEC_RUN_ID_CAPABILITY);
|
|
3894
3898
|
} else {
|
|
3895
3899
|
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
3896
3900
|
}
|
|
@@ -8101,6 +8105,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8101
8105
|
source_session_file_path: sourceSessionFilePath,
|
|
8102
8106
|
target_backend_type: targetBackendType,
|
|
8103
8107
|
resume_context_url: resumeContextUrlRaw,
|
|
8108
|
+
initial_content: initialContentRaw,
|
|
8104
8109
|
request_id: requestIdRaw,
|
|
8105
8110
|
} = payload || {};
|
|
8106
8111
|
|
|
@@ -8538,9 +8543,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8538
8543
|
if (isForkMode) {
|
|
8539
8544
|
// Fork mode starts a brand-new session on the target backend; no --resume.
|
|
8540
8545
|
// The prior conversation is delivered as a plain-text URL via the prompt
|
|
8541
|
-
// so the target backend fetches it on its own
|
|
8542
|
-
|
|
8543
|
-
|
|
8546
|
+
// so the target backend fetches it on its own — unless the user set their
|
|
8547
|
+
// own first message, which replaces it. That one goes through --prefill
|
|
8548
|
+
// (string-typed) so fire does not coerce a numeric message like "0.10".
|
|
8549
|
+
const customFirstMessage = normalizeOptionalString(initialContentRaw);
|
|
8550
|
+
if (customFirstMessage) {
|
|
8551
|
+
args.push(`--prefill=${customFirstMessage}`, "--");
|
|
8552
|
+
} else {
|
|
8553
|
+
args.push("--", resumeHandoffPrompt);
|
|
8554
|
+
}
|
|
8544
8555
|
} else {
|
|
8545
8556
|
if (shouldResumeSession) {
|
|
8546
8557
|
args.push("--resume", resolvedResumeSessionId);
|
package/src/remote/client.js
CHANGED
|
@@ -112,7 +112,7 @@ export async function withRetry(fn, {
|
|
|
112
112
|
} catch (error) {
|
|
113
113
|
lastError = error;
|
|
114
114
|
// An aborted transfer is the caller's own deadline firing, not a blip.
|
|
115
|
-
if (error?.name === "AbortError" || !retryable(error)) throw error;
|
|
115
|
+
if (error?.name === "AbortError" || !(await retryable(error))) throw error;
|
|
116
116
|
if (attempt === attempts - 1) break;
|
|
117
117
|
await sleep(baseDelayMs * 2 ** attempt);
|
|
118
118
|
}
|
package/src/remote/exec.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* or globs are actually wanted.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
9
10
|
import process from "node:process";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
UsageError,
|
|
14
15
|
callApi,
|
|
15
16
|
delay,
|
|
17
|
+
isRetryable,
|
|
16
18
|
loadCliConfig,
|
|
17
19
|
parseTimeoutMs as parseSharedTimeoutMs,
|
|
18
20
|
withRetry,
|
|
@@ -25,12 +27,10 @@ const POLL_INTERVAL_MS = 1_000;
|
|
|
25
27
|
/** How long a single request may block server-side before we switch to polling. */
|
|
26
28
|
const POST_WAIT_MS = 10_000;
|
|
27
29
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* one saturating POST can block (POST_WAIT_MS). Nothing else is retried here:
|
|
31
|
-
* a 5xx may have started the command already, and a second POST would run it twice.
|
|
30
|
+
* Six attempts at 500ms doubling wait out ~15s, longer than one saturating POST
|
|
31
|
+
* can block (POST_WAIT_MS). What is safe to retry is decided in `execRemote`.
|
|
32
32
|
*/
|
|
33
|
-
const
|
|
33
|
+
const POST_ATTEMPTS = 6;
|
|
34
34
|
const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
35
35
|
|
|
36
36
|
const VALUE_FLAGS = new Map([
|
|
@@ -233,16 +233,31 @@ export async function execRemote(config, target, command, options = {}) {
|
|
|
233
233
|
const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
|
|
234
234
|
const deadline = now() + timeoutMs;
|
|
235
235
|
|
|
236
|
+
// Sent on every attempt: a daemon that already started this run returns it
|
|
237
|
+
// instead of running the command again.
|
|
238
|
+
const runId = randomUUID();
|
|
239
|
+
let dedupes;
|
|
240
|
+
const retryable = async (error) => {
|
|
241
|
+
// The per-user in-flight cap (8) is full: nothing was started.
|
|
242
|
+
if (error?.status === 429) return true;
|
|
243
|
+
// A 502 is the daemon's own answer (bad workspace, …); a retry gets the same one.
|
|
244
|
+
if (!isRetryable(error) || error.status === 502 || now() >= deadline) return false;
|
|
245
|
+
// A network error or other 5xx may have started the command already.
|
|
246
|
+
dedupes ??= await dedupesRunId(config, basePath, fetchImpl, sleep);
|
|
247
|
+
return dedupes;
|
|
248
|
+
};
|
|
249
|
+
|
|
236
250
|
const started = await withRetry(() => callApi(config, "POST", basePath, {
|
|
237
251
|
command,
|
|
238
252
|
args,
|
|
253
|
+
runId,
|
|
239
254
|
...(workspace ? { workspace } : {}),
|
|
240
255
|
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
241
256
|
// Deliberately short, and independent of the overall deadline: that is
|
|
242
257
|
// owned by the poll loop below. Handing the daemon the full budget would
|
|
243
258
|
// make one HTTP request block for it and leave the loop unreachable.
|
|
244
259
|
timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
|
|
245
|
-
}, fetchImpl), {
|
|
260
|
+
}, fetchImpl), { attempts: POST_ATTEMPTS, retryable, sleep });
|
|
246
261
|
|
|
247
262
|
const waited = await waitForRun(config, basePath, started, {
|
|
248
263
|
deadline, fetchImpl, sleep, now, killOnTimeout, signal,
|
|
@@ -250,6 +265,21 @@ export async function execRemote(config, target, command, options = {}) {
|
|
|
250
265
|
return { ...waited, basePath };
|
|
251
266
|
}
|
|
252
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Whether the target dedupes exec on `runId`, which is what makes re-sending a
|
|
270
|
+
* POST that failed mid-flight safe. Asked only after such a failure, so the happy
|
|
271
|
+
* path pays nothing. Anything short of a clear yes — an old server answers 405,
|
|
272
|
+
* an old daemon `false` — keeps the POST to 429-only retries.
|
|
273
|
+
*/
|
|
274
|
+
async function dedupesRunId(config, basePath, fetchImpl, sleep) {
|
|
275
|
+
try {
|
|
276
|
+
const answer = await withRetry(() => callApi(config, "GET", basePath, null, fetchImpl), { sleep });
|
|
277
|
+
return answer?.dedupesRunId === true;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
/**
|
|
254
284
|
* `execRemote` for callers that only care whether it worked. Throws with the
|
|
255
285
|
* remote's own stderr, which is what the user needs to see.
|
|
@@ -18,6 +18,8 @@ const KILL_GRACE_MS = 5_000;
|
|
|
18
18
|
const NUL = String.fromCharCode(0);
|
|
19
19
|
|
|
20
20
|
export const REMOTE_EXEC_CAPABILITY = "remote_exec";
|
|
21
|
+
/** `exec` honours a caller-chosen `runId`, so a retried request cannot spawn twice. */
|
|
22
|
+
export const REMOTE_EXEC_RUN_ID_CAPABILITY = "remote_exec_run_id";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Run arbitrary commands on this daemon's host on behalf of the account that
|
|
@@ -51,6 +53,15 @@ export function createRemoteExecHandlers(opts = {}) {
|
|
|
51
53
|
// never learned.
|
|
52
54
|
const waitMs = clampWaitMs(args.timeoutMs ?? args.timeout_ms);
|
|
53
55
|
const waitDeadline = Date.now() + waitMs;
|
|
56
|
+
const attach = async (runState) => {
|
|
57
|
+
await waitForSettle(runState, Math.max(0, waitDeadline - Date.now()));
|
|
58
|
+
return toPublicRun(runState);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A retry of a request that already started gets that run back rather than
|
|
62
|
+
// a second copy of the command.
|
|
63
|
+
const runId = typeof args.runId === "string" ? args.runId.trim() : "";
|
|
64
|
+
if (runId && runs.has(runId)) return attach(runs.get(runId));
|
|
54
65
|
|
|
55
66
|
const command = normalizeCommand(args.command);
|
|
56
67
|
if (!command) {
|
|
@@ -65,9 +76,11 @@ export function createRemoteExecHandlers(opts = {}) {
|
|
|
65
76
|
}
|
|
66
77
|
const cwd = await resolveWorkspace(args.workspace ?? args.workspace_path, defaultWorkspace);
|
|
67
78
|
const env = buildExecEnv(args.env);
|
|
79
|
+
// ...including one that landed while `stat` above was still pending.
|
|
80
|
+
if (runId && runs.has(runId)) return attach(runs.get(runId));
|
|
68
81
|
|
|
69
82
|
const runState = {
|
|
70
|
-
runId: randomUUID(),
|
|
83
|
+
runId: runId || randomUUID(),
|
|
71
84
|
command,
|
|
72
85
|
args: argv,
|
|
73
86
|
workspace: cwd,
|
|
@@ -126,8 +139,7 @@ export function createRemoteExecHandlers(opts = {}) {
|
|
|
126
139
|
});
|
|
127
140
|
});
|
|
128
141
|
|
|
129
|
-
|
|
130
|
-
return toPublicRun(runState);
|
|
142
|
+
return attach(runState);
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
async function status(args = {}) {
|