@love-moon/conductor-cli 0.13.3 → 0.15.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 +114 -0
- package/bin/conductor-fire.js +209 -60
- package/bin/conductor-remote.js +5 -1
- package/bin/conductor.js +1 -1
- package/package.json +5 -5
- package/src/daemon.js +38 -11
- package/src/remote/client.js +1 -1
- package/src/remote/cp.js +2 -2
- package/src/remote/exec.js +36 -6
- package/src/remote/mcp-launch.js +167 -0
- package/src/remote/mcp.js +849 -0
- package/src/remote-exec-handlers.js +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,119 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b61c881: Global AI backend tasks (RFC 0041) that work directly in another daemon's
|
|
8
|
+
project directory (`launch_config.remoteWorkspace`) now get the same
|
|
9
|
+
`conductor remote mcp` tools as remote-worktree tasks, bound to that
|
|
10
|
+
repository and starting in the project directory.
|
|
11
|
+
|
|
12
|
+
The daemon advertises `global_backend_v1` so the web app only offers a daemon
|
|
13
|
+
as a global AI backend when its CLI can drive another daemon.
|
|
14
|
+
|
|
15
|
+
- cc1e290: New Terminal and Resume Session previews in the create-task dialog.
|
|
16
|
+
|
|
17
|
+
- `@love-moon/ai-sdk`: `listSessions` (claude, codex) returns `preview` with
|
|
18
|
+
the first user message, the assistant reply to it, and the session's last
|
|
19
|
+
message.
|
|
20
|
+
- The daemon passes the preview to `list_backend_sessions` as
|
|
21
|
+
`first_user_message`, `first_reply`, `last_message` and `last_message_role`.
|
|
22
|
+
- A PTY task with no project path now starts its shell in `$HOME`. The
|
|
23
|
+
terminal log stays in the dated workspace directory.
|
|
24
|
+
|
|
25
|
+
- 55b65db: `conductor remote mcp`: structured tools for a task whose worktree lives on
|
|
26
|
+
another daemon (RFC 0040).
|
|
27
|
+
|
|
28
|
+
- New `conductor remote mcp --host <daemon> --root <dir> [--cwd <dir>]`, a
|
|
29
|
+
stdio MCP server with `remote_read`, `remote_edit`, `remote_write`,
|
|
30
|
+
`remote_grep`, `remote_glob` and `remote_bash`, bound to that directory.
|
|
31
|
+
- For a `launch_config.remoteWorktree` task the daemon passes the binding to
|
|
32
|
+
fire (`CONDUCTOR_REMOTE_WORKTREE`), and fire attaches the server to Claude
|
|
33
|
+
(`mcpServers`) and Codex (`-c mcp_servers.*`). The agent token is forwarded
|
|
34
|
+
by env only, never on argv.
|
|
35
|
+
- `@love-moon/ai-sdk`: the Codex app-server session accepts `configOverrides`,
|
|
36
|
+
passed to the app-server as `-c key=value`.
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- Updated dependencies [cc1e290]
|
|
41
|
+
- Updated dependencies [55b65db]
|
|
42
|
+
- @love-moon/ai-sdk@0.15.0
|
|
43
|
+
- @love-moon/conductor-sdk@0.15.0
|
|
44
|
+
|
|
45
|
+
## 0.14.0
|
|
46
|
+
|
|
47
|
+
### Minor Changes
|
|
48
|
+
|
|
49
|
+
- e168077: Support `/clear` in task chats. Fire detects a bare `/clear` message (like
|
|
50
|
+
`/compact`) and drops the AI's context instead of sending the text to the model.
|
|
51
|
+
Sessions advertise `capabilities.clear` and implement the new optional
|
|
52
|
+
`runClear()` on top of each backend's own native reset, so the backend process,
|
|
53
|
+
browser or RPC connection stays up:
|
|
54
|
+
|
|
55
|
+
- claude: native `/clear` slash command (the SDK's `conversation_reset` reports
|
|
56
|
+
the new conversation id)
|
|
57
|
+
- codex app-server: `thread/start` on the live transport
|
|
58
|
+
- codex exec: drops the replayed history, which is the whole context for a
|
|
59
|
+
stateless CLI
|
|
60
|
+
- copilot: `createSession()` on the same client; the old conversation is
|
|
61
|
+
detached, not deleted
|
|
62
|
+
- kimi wire and legacy print: the CLI's built-in `/clear` (prompt mode opts out)
|
|
63
|
+
- opencode: `session.create` on the same `opencode serve`
|
|
64
|
+
- dsh: rotates onto a fresh wire session id and keeps the runtime subprocess
|
|
65
|
+
- chat-web: `newChat()` in the same browser and profile
|
|
66
|
+
|
|
67
|
+
Backends without the capability (external providers) fall back to closing the
|
|
68
|
+
session and continuing on a brand-new one. Either way the task's chat history is
|
|
69
|
+
kept and the task is rebound to the session the backend ends up on, so a later
|
|
70
|
+
restart resumes the cleared conversation rather than the old one. The codex
|
|
71
|
+
fresh-session bootstrap lock now covers that fallback too, and the kimi wire
|
|
72
|
+
transport drops configured `--continue`/`--resume`/`--session` flags (like kimi
|
|
73
|
+
print already did) so they cannot reattach a cleared session to the old
|
|
74
|
+
conversation. Two caveats: the configured `pre_prompt` is not re-sent to the
|
|
75
|
+
cleared session, and a chat-web task running with
|
|
76
|
+
`CONDUCTOR_AI_SDK_DISABLE_WORKER=1` can only clear natively — its fallback would
|
|
77
|
+
hit the browser profile lock, because in that mode the lock owner is fire itself.
|
|
78
|
+
|
|
79
|
+
- 12a829c: Show each AI reply's token usage beside its timestamp: the turn's tokens, the
|
|
80
|
+
task total after it, and the input cache share (the part of the turn's input
|
|
81
|
+
served from the prompt cache).
|
|
82
|
+
|
|
83
|
+
- `@love-moon/ai-sdk`: new `summarizeTurnUsage(usage)` normalizes a turn's usage
|
|
84
|
+
into `{ tokens, inputTokens, cachedInputTokens }` (Claude: fresh input + cache
|
|
85
|
+
writes + cache reads; Codex: input already includes cached). The Codex
|
|
86
|
+
app-server provider's turn/goal `usage` now also carries `turnInputTokens` and
|
|
87
|
+
`turnCachedInputTokens`.
|
|
88
|
+
- `@love-moon/conductor-sdk`: `sendTurnUsage` accepts `input_tokens`,
|
|
89
|
+
`cached_input_tokens` and the reply's `message_id`.
|
|
90
|
+
- `conductor fire` reports a finished turn's usage after its reply, naming that
|
|
91
|
+
reply (for streamed sessions, the turn's last streamed reply), so the server
|
|
92
|
+
records it on the message (`metadata.turn_usage`) and pushes it to open chats.
|
|
93
|
+
|
|
94
|
+
### Patch Changes
|
|
95
|
+
|
|
96
|
+
- b06e80e: Fix new tasks never answering when their first message starts with "-" (a
|
|
97
|
+
markdown list, `--model ...`, `-i ...`). The daemon passed the message to Fire
|
|
98
|
+
as `--prefill <message>`, and Fire's argument parser read the leading dash as a
|
|
99
|
+
new flag and dropped the message. It is now passed as `--prefill=<message>`.
|
|
100
|
+
- 4141981: `conductor remote exec` now retries a POST lost to a network error or 5xx
|
|
101
|
+
without running the command twice. The CLI sends a `runId`, and an upgraded
|
|
102
|
+
daemon (capability `remote_exec_run_id`) returns the existing run for a repeated
|
|
103
|
+
id. Before retrying, the CLI asks the server whether the daemon dedupes. Old
|
|
104
|
+
servers and daemons keep the 429-only retry.
|
|
105
|
+
- b06e80e: "New task from this" can start the new task with a first message you write
|
|
106
|
+
yourself. When one is set, the daemon sends it to the successor AI instead of
|
|
107
|
+
the default prompt that loads the source task's conversation as background. The
|
|
108
|
+
daemon advertises this as the `restart_first_message` capability; for older
|
|
109
|
+
daemons the dialog locks the field (the new task still starts with the default
|
|
110
|
+
background) and the server rejects a custom first message.
|
|
111
|
+
- Updated dependencies [e168077]
|
|
112
|
+
- Updated dependencies [d4f6fb9]
|
|
113
|
+
- Updated dependencies [12a829c]
|
|
114
|
+
- @love-moon/ai-sdk@0.14.0
|
|
115
|
+
- @love-moon/conductor-sdk@0.14.0
|
|
116
|
+
|
|
3
117
|
## 0.13.3
|
|
4
118
|
|
|
5
119
|
### 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,
|
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
resolveSessionRunDirectory as resolveCliSessionRunDirectory,
|
|
27
27
|
resumeProviderForBackend as resumeProviderForCliBackend,
|
|
28
28
|
} from "../src/fire/resume.js";
|
|
29
|
+
import {
|
|
30
|
+
REMOTE_WORKTREE_ENV,
|
|
31
|
+
buildRemoteWorktreeSessionOptions,
|
|
32
|
+
readRemoteWorktreeBinding,
|
|
33
|
+
} from "../src/remote/mcp-launch.js";
|
|
29
34
|
import {
|
|
30
35
|
filterRuntimeSupportedAllowCliList,
|
|
31
36
|
inferBuiltInRuntimeBackendFromCommand,
|
|
@@ -616,6 +621,13 @@ async function main() {
|
|
|
616
621
|
delete process.env.CONDUCTOR_BACKEND_WS_URL;
|
|
617
622
|
process.env.CONDUCTOR_CONFIG = cliArgs.configFile;
|
|
618
623
|
}
|
|
624
|
+
// RFC 0040: the daemon's binding for a remote-worktree task. Consumed here so
|
|
625
|
+
// it does not leak into the AI's shell (and from there into a nested fire).
|
|
626
|
+
const remoteWorktreeBinding = readRemoteWorktreeBinding(process.env);
|
|
627
|
+
delete process.env[REMOTE_WORKTREE_ENV];
|
|
628
|
+
if (remoteWorktreeBinding) {
|
|
629
|
+
log(`Remote worktree tools bound to ${remoteWorktreeBinding.host}:${remoteWorktreeBinding.cwd}`);
|
|
630
|
+
}
|
|
619
631
|
let runtimeProjectPath = process.cwd();
|
|
620
632
|
let backendSession = null;
|
|
621
633
|
|
|
@@ -1024,18 +1036,27 @@ async function main() {
|
|
|
1024
1036
|
.trim()
|
|
1025
1037
|
.toLowerCase();
|
|
1026
1038
|
const enableGoalsForBackend = GOAL_CAPABLE_BACKENDS.includes(backendForGoalCheck);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
+
const openBackendSession = (resumeSessionId, { fresh = false } = {}) =>
|
|
1040
|
+
(backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
|
|
1041
|
+
initialImages: cliArgs.initialImages,
|
|
1042
|
+
cwd: runtimeProjectPath,
|
|
1043
|
+
resumeSessionId,
|
|
1044
|
+
configFile: cliArgs.configFile,
|
|
1045
|
+
...(cliArgs.sessionOptions || {}),
|
|
1046
|
+
...buildRemoteWorktreeSessionOptions({
|
|
1047
|
+
backend: cliArgs.sessionBackend || cliArgs.backend,
|
|
1048
|
+
binding: remoteWorktreeBinding,
|
|
1049
|
+
sessionOptions: cliArgs.sessionOptions || {},
|
|
1050
|
+
commandLine: sessionCommandLine,
|
|
1051
|
+
configFile: cliArgs.configFile,
|
|
1052
|
+
}),
|
|
1053
|
+
...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
|
|
1054
|
+
...(enableGoalsForBackend ? { goalMode: true } : {}),
|
|
1055
|
+
logger: { log },
|
|
1056
|
+
sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
|
|
1057
|
+
resumePersistedSession: Boolean(!fresh && !resumeSessionId && taskContext.taskId),
|
|
1058
|
+
}));
|
|
1059
|
+
openBackendSession(nextResumeSessionId);
|
|
1039
1060
|
|
|
1040
1061
|
const runner = new BridgeRunner({
|
|
1041
1062
|
backendSession,
|
|
@@ -1051,6 +1072,11 @@ async function main() {
|
|
|
1051
1072
|
daemonName: resolvedDaemonName,
|
|
1052
1073
|
prePrompt: resolvedPrePrompt || "",
|
|
1053
1074
|
shouldProcessPrePrompt: Boolean(resolvedPrePrompt) && nextShouldProcessPrePrompt,
|
|
1075
|
+
// `/clear` swaps in a brand-new session (no resume) in-process, and
|
|
1076
|
+
// bootstraps it under the same lock the fresh-session boot path uses.
|
|
1077
|
+
createFreshBackendSession: () => openBackendSession(undefined, { fresh: true }),
|
|
1078
|
+
withFreshSessionBootstrap: (fn) =>
|
|
1079
|
+
withFreshSessionBootstrapLock(cliArgs.sessionBackend || cliArgs.backend, runtimeProjectPath, fn),
|
|
1054
1080
|
});
|
|
1055
1081
|
reconnectRunner = runner;
|
|
1056
1082
|
if (pendingRemoteStopEvent) {
|
|
@@ -1618,6 +1644,14 @@ export function formatCompactReply(backendName, compact, instructions = "") {
|
|
|
1618
1644
|
return `${backendName} 上下文已压缩${detail}。${ignored}`;
|
|
1619
1645
|
}
|
|
1620
1646
|
|
|
1647
|
+
/**
|
|
1648
|
+
* Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
|
|
1649
|
+
* counts: anything after it is more likely an instruction for the model.
|
|
1650
|
+
*/
|
|
1651
|
+
export function isClearCommand(content) {
|
|
1652
|
+
return typeof content === "string" && /^\/clear$/i.test(content.trim());
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1621
1655
|
export function injectResolvedTaskId(taskId, env = process.env) {
|
|
1622
1656
|
const normalizedTaskId = normalizeTaskId(taskId);
|
|
1623
1657
|
if (!normalizedTaskId) {
|
|
@@ -2060,8 +2094,12 @@ export class BridgeRunner {
|
|
|
2060
2094
|
daemonName,
|
|
2061
2095
|
prePrompt,
|
|
2062
2096
|
shouldProcessPrePrompt,
|
|
2097
|
+
createFreshBackendSession,
|
|
2098
|
+
withFreshSessionBootstrap,
|
|
2063
2099
|
}) {
|
|
2064
2100
|
this.backendSession = backendSession;
|
|
2101
|
+
this.createFreshBackendSession = createFreshBackendSession;
|
|
2102
|
+
this.withFreshSessionBootstrap = withFreshSessionBootstrap;
|
|
2065
2103
|
this.conductor = conductor;
|
|
2066
2104
|
this.taskId = taskId;
|
|
2067
2105
|
this.pollIntervalMs = pollIntervalMs;
|
|
@@ -2093,6 +2131,8 @@ export class BridgeRunner {
|
|
|
2093
2131
|
typeof prePrompt === "string" && prePrompt.trim() ? prePrompt.trim() : "";
|
|
2094
2132
|
this.shouldProcessPrePrompt = Boolean(shouldProcessPrePrompt) && Boolean(this.prePrompt);
|
|
2095
2133
|
this.sessionStreamReplyCounts = new Map();
|
|
2134
|
+
// Last streamed reply per reply target: the message a streamed turn's usage is recorded on.
|
|
2135
|
+
this.lastStreamedReplyIds = new Map();
|
|
2096
2136
|
this.lastRuntimeStatusSignature = null;
|
|
2097
2137
|
this.lastRuntimeStatusPayload = null;
|
|
2098
2138
|
this.runtimeContextSnapshot = null;
|
|
@@ -2150,6 +2190,10 @@ export class BridgeRunner {
|
|
|
2150
2190
|
2,
|
|
2151
2191
|
20,
|
|
2152
2192
|
);
|
|
2193
|
+
this.attachSessionStreamHandlers();
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
attachSessionStreamHandlers() {
|
|
2153
2197
|
if (
|
|
2154
2198
|
this.useSessionFileReplyStream &&
|
|
2155
2199
|
typeof this.backendSession?.setSessionMessageHandler === "function"
|
|
@@ -2326,6 +2370,13 @@ export class BridgeRunner {
|
|
|
2326
2370
|
this.copilotLog(`session binding sync skipped: ${sanitizeForLog(error?.message || error, 160)}`);
|
|
2327
2371
|
return false;
|
|
2328
2372
|
}
|
|
2373
|
+
// A deferred id is a local placeholder (chat-web before its first reply,
|
|
2374
|
+
// and right after a native /clear) — binding it would point the task at
|
|
2375
|
+
// something that can never be resumed. announceBackendSession holds off for
|
|
2376
|
+
// the same reason; the real id lands with the next completed turn.
|
|
2377
|
+
if (sessionInfo?.sessionIdDeferred === true) {
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2329
2380
|
return this.persistTaskSessionBinding({
|
|
2330
2381
|
sessionId: sessionInfo?.sessionId,
|
|
2331
2382
|
sessionFilePath: sessionInfo?.sessionFilePath,
|
|
@@ -3145,7 +3196,7 @@ export class BridgeRunner {
|
|
|
3145
3196
|
usage: null,
|
|
3146
3197
|
replyTo: replyTo || "latest",
|
|
3147
3198
|
});
|
|
3148
|
-
await this.conductor.sendMessage(this.taskId, normalizedText, {
|
|
3199
|
+
const sent = await this.conductor.sendMessage(this.taskId, normalizedText, {
|
|
3149
3200
|
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3150
3201
|
backend: this.backendName,
|
|
3151
3202
|
thread_id: sessionId || this.backendSession.threadId,
|
|
@@ -3161,6 +3212,9 @@ export class BridgeRunner {
|
|
|
3161
3212
|
replyKey,
|
|
3162
3213
|
Number(this.sessionStreamReplyCounts.get(replyKey) || 0) + 1,
|
|
3163
3214
|
);
|
|
3215
|
+
if (sent?.message_id) {
|
|
3216
|
+
this.lastStreamedReplyIds.set(replyKey, sent.message_id);
|
|
3217
|
+
}
|
|
3164
3218
|
this.copilotLog(
|
|
3165
3219
|
`session_file sdk_message sent replyTo=${replyTo || "latest"} responseLen=${normalizedText.length}`,
|
|
3166
3220
|
);
|
|
@@ -3175,6 +3229,14 @@ export class BridgeRunner {
|
|
|
3175
3229
|
return normalizedReplyTo || "__latest__";
|
|
3176
3230
|
}
|
|
3177
3231
|
|
|
3232
|
+
/** A streamed turn's last reply, read once so a later turn never reuses it. */
|
|
3233
|
+
takeLastStreamedReplyId(replyTo) {
|
|
3234
|
+
const replyKey = this.normalizeSessionStreamReplyKey(replyTo);
|
|
3235
|
+
const messageId = this.lastStreamedReplyIds.get(replyKey);
|
|
3236
|
+
this.lastStreamedReplyIds.delete(replyKey);
|
|
3237
|
+
return messageId;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3178
3240
|
getSessionStreamReplyCount(replyTo) {
|
|
3179
3241
|
return Number(
|
|
3180
3242
|
this.sessionStreamReplyCounts.get(this.normalizeSessionStreamReplyKey(replyTo)) || 0,
|
|
@@ -3333,6 +3395,8 @@ export class BridgeRunner {
|
|
|
3333
3395
|
}
|
|
3334
3396
|
this.lastRuntimeStatusSignature = null;
|
|
3335
3397
|
this.runningTurn = true;
|
|
3398
|
+
// Only this turn's own streamed replies may carry its usage.
|
|
3399
|
+
this.lastStreamedReplyIds.clear();
|
|
3336
3400
|
this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
|
|
3337
3401
|
const turnStartedAt = Date.now();
|
|
3338
3402
|
this.turnStartedAt = turnStartedAt;
|
|
@@ -3403,22 +3467,30 @@ export class BridgeRunner {
|
|
|
3403
3467
|
);
|
|
3404
3468
|
}
|
|
3405
3469
|
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
usage: result.usage ||
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3470
|
+
// The usage report names the reply it belongs to, so it is sent after that
|
|
3471
|
+
// reply — but a turn that failed to post its reply still spent its tokens.
|
|
3472
|
+
let replyMessageId = this.takeLastStreamedReplyId(replyTo);
|
|
3473
|
+
try {
|
|
3474
|
+
if (!this.useSessionFileReplyStream) {
|
|
3475
|
+
const responseText =
|
|
3476
|
+
result.text ||
|
|
3477
|
+
extractAgentTextFromItems(result.items) ||
|
|
3478
|
+
extractAgentTextFromMetadata(result.metadata) ||
|
|
3479
|
+
`(${this.backendName} 未返回任何文本)`;
|
|
3480
|
+
logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
|
|
3481
|
+
const sent = await this.conductor.sendMessage(this.taskId, responseText, {
|
|
3482
|
+
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3483
|
+
backend: this.backendName,
|
|
3484
|
+
usage: result.usage || null,
|
|
3485
|
+
thread_id: this.backendSession.threadId,
|
|
3486
|
+
items: result.items,
|
|
3487
|
+
reply_to: replyTo,
|
|
3488
|
+
cli_args: this.cliArgs,
|
|
3489
|
+
});
|
|
3490
|
+
replyMessageId = sent?.message_id;
|
|
3491
|
+
}
|
|
3492
|
+
} finally {
|
|
3493
|
+
this.reportTurnUsage(result.usage, { messageId: replyMessageId });
|
|
3422
3494
|
}
|
|
3423
3495
|
await this.syncBackendSessionBinding();
|
|
3424
3496
|
if (replyTo) {
|
|
@@ -3589,6 +3661,9 @@ export class BridgeRunner {
|
|
|
3589
3661
|
if (compactDirective) {
|
|
3590
3662
|
return this.runCompactCommand(compactDirective, options);
|
|
3591
3663
|
}
|
|
3664
|
+
if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
|
|
3665
|
+
return this.runClearCommand(options);
|
|
3666
|
+
}
|
|
3592
3667
|
|
|
3593
3668
|
if (willRunGoal) {
|
|
3594
3669
|
const goalResult = await this.runWithTurnUsage(() =>
|
|
@@ -3615,30 +3690,39 @@ export class BridgeRunner {
|
|
|
3615
3690
|
return this.runWithTurnUsage(() => this.backendSession.runTurn(content, options));
|
|
3616
3691
|
}
|
|
3617
3692
|
|
|
3618
|
-
/**
|
|
3693
|
+
/**
|
|
3694
|
+
* Runs one backend turn and reports a failed or interrupted turn's token
|
|
3695
|
+
* count; the caller reports a finished turn's once its reply is sent.
|
|
3696
|
+
*/
|
|
3619
3697
|
async runWithTurnUsage(runTurn) {
|
|
3620
|
-
let result;
|
|
3621
3698
|
try {
|
|
3622
|
-
|
|
3699
|
+
return await runTurn();
|
|
3623
3700
|
} catch (error) {
|
|
3624
3701
|
this.reportTurnUsage(error?.usage, { failed: true });
|
|
3625
3702
|
throw error;
|
|
3626
3703
|
}
|
|
3627
|
-
this.reportTurnUsage(result?.usage);
|
|
3628
|
-
return result;
|
|
3629
3704
|
}
|
|
3630
3705
|
|
|
3631
3706
|
/**
|
|
3632
|
-
* Best-effort: report a turn's token
|
|
3633
|
-
*
|
|
3707
|
+
* Best-effort: report a turn's token usage to the server, tied to its reply
|
|
3708
|
+
* message when there is one. A failed turn with unknown usage reports `null`
|
|
3709
|
+
* so the task card drops the previous turn's count.
|
|
3634
3710
|
*/
|
|
3635
|
-
reportTurnUsage(usage, { failed = false } = {}) {
|
|
3636
|
-
const
|
|
3637
|
-
if ((
|
|
3711
|
+
reportTurnUsage(usage, { failed = false, messageId } = {}) {
|
|
3712
|
+
const summary = summarizeTurnUsage(usage);
|
|
3713
|
+
if ((!summary && !failed) || typeof this.conductor?.sendTurnUsage !== "function") {
|
|
3638
3714
|
return;
|
|
3639
3715
|
}
|
|
3640
3716
|
Promise.resolve()
|
|
3641
|
-
.then(() =>
|
|
3717
|
+
.then(() =>
|
|
3718
|
+
this.conductor.sendTurnUsage(this.taskId, {
|
|
3719
|
+
tokens: summary?.tokens ?? null,
|
|
3720
|
+
...(summary?.inputTokens !== undefined
|
|
3721
|
+
? { input_tokens: summary.inputTokens, cached_input_tokens: summary.cachedInputTokens }
|
|
3722
|
+
: {}),
|
|
3723
|
+
...(messageId ? { message_id: messageId } : {}),
|
|
3724
|
+
}),
|
|
3725
|
+
)
|
|
3642
3726
|
.catch((error) => {
|
|
3643
3727
|
log(`Failed to report turn usage: ${error?.message || error}`);
|
|
3644
3728
|
});
|
|
@@ -3689,6 +3773,85 @@ export class BridgeRunner {
|
|
|
3689
3773
|
};
|
|
3690
3774
|
}
|
|
3691
3775
|
|
|
3776
|
+
/**
|
|
3777
|
+
* `/clear`: drop the AI context. Backends that advertise
|
|
3778
|
+
* `capabilities.clear` run their own native clear (claude's `/clear` slash
|
|
3779
|
+
* command, kimi's built-in `/clear`, …) so the process and its session file
|
|
3780
|
+
* survive; everyone else falls back to closing the session and swapping in a
|
|
3781
|
+
* fresh one. Either way the task's chat history is kept and the task is
|
|
3782
|
+
* rebound to whatever session id the backend ends up on, so a later fire
|
|
3783
|
+
* restart resumes the cleared conversation instead of the old one.
|
|
3784
|
+
*/
|
|
3785
|
+
async runClearCommand({ onProgress, replyTo = "" } = {}) {
|
|
3786
|
+
onProgress?.({
|
|
3787
|
+
phase: "context_clear",
|
|
3788
|
+
reply_in_progress: true,
|
|
3789
|
+
status_line: `${this.backendName} clearing context`,
|
|
3790
|
+
});
|
|
3791
|
+
const snapshot =
|
|
3792
|
+
typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
|
|
3793
|
+
const clearCapable =
|
|
3794
|
+
snapshot?.capabilities?.clear === true && typeof this.backendSession?.runClear === "function";
|
|
3795
|
+
if (clearCapable) {
|
|
3796
|
+
const result = await this.runWithTurnUsage(() => this.backendSession.runClear({}, { onProgress }));
|
|
3797
|
+
// A native clear may land on a new session id (claude does); rebind so a
|
|
3798
|
+
// restart resumes the cleared conversation.
|
|
3799
|
+
this.runtimeContextSnapshot = null;
|
|
3800
|
+
await this.syncBackendSessionBinding();
|
|
3801
|
+
const cleared = result?.clear?.status !== "noop";
|
|
3802
|
+
const text = cleared
|
|
3803
|
+
? `${this.backendName} 上下文已清除。`
|
|
3804
|
+
: `${this.backendName} 当前没有可清除的上下文。`;
|
|
3805
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
3806
|
+
log(`[clear] backend=${this.backendName} native status=${result?.clear?.status || "cleared"}`);
|
|
3807
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
3808
|
+
try {
|
|
3809
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
3810
|
+
} catch (error) {
|
|
3811
|
+
log(`[clear] failed to post confirmation: ${error?.message || error}`);
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
return {
|
|
3815
|
+
text,
|
|
3816
|
+
items: [],
|
|
3817
|
+
usage: result?.usage || null,
|
|
3818
|
+
provider: this.backendName,
|
|
3819
|
+
events: [],
|
|
3820
|
+
metadata: { ...(result?.metadata || {}), clear: result?.clear || null },
|
|
3821
|
+
};
|
|
3822
|
+
}
|
|
3823
|
+
try {
|
|
3824
|
+
await this.backendSession?.close?.();
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
log(`[clear] failed to close previous backend session: ${error?.message || error}`);
|
|
3827
|
+
}
|
|
3828
|
+
if (this.stopped) {
|
|
3829
|
+
// The task is shutting down; don't spawn a session nobody will close.
|
|
3830
|
+
throw Object.assign(new Error("backend session closed during /clear"), { reason: "session_closed" });
|
|
3831
|
+
}
|
|
3832
|
+
this.backendSession = this.createFreshBackendSession();
|
|
3833
|
+
this.resumeSessionId = "";
|
|
3834
|
+
this.sessionAnnouncementSent = false;
|
|
3835
|
+
this.sessionAnnouncementSubscribed = false;
|
|
3836
|
+
this.runtimeContextSnapshot = null;
|
|
3837
|
+
this.attachSessionStreamHandlers();
|
|
3838
|
+
await (this.withFreshSessionBootstrap
|
|
3839
|
+
? this.withFreshSessionBootstrap(() => this.announceBackendSession())
|
|
3840
|
+
: this.announceBackendSession());
|
|
3841
|
+
|
|
3842
|
+
const text = `${this.backendName} 上下文已清除,已开始新会话。`;
|
|
3843
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
3844
|
+
log(`[clear] backend=${this.backendName} started a fresh session`);
|
|
3845
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
3846
|
+
try {
|
|
3847
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
log(`[clear] failed to post confirmation: ${error?.message || error}`);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3692
3855
|
async handlePrePromptMessage(content) {
|
|
3693
3856
|
const text = typeof content === "string" ? content.trim() : "";
|
|
3694
3857
|
if (!text) {
|
|
@@ -3726,6 +3889,8 @@ export class BridgeRunner {
|
|
|
3726
3889
|
}) {
|
|
3727
3890
|
this.lastRuntimeStatusSignature = null;
|
|
3728
3891
|
this.runningTurn = true;
|
|
3892
|
+
// Only this turn's own streamed replies may carry its usage.
|
|
3893
|
+
this.lastStreamedReplyIds.clear();
|
|
3729
3894
|
const startedAt = Date.now();
|
|
3730
3895
|
this.turnStartedAt = startedAt;
|
|
3731
3896
|
if (
|
|
@@ -3776,7 +3941,7 @@ export class BridgeRunner {
|
|
|
3776
3941
|
usage: result.usage,
|
|
3777
3942
|
replyTo: replyTarget,
|
|
3778
3943
|
});
|
|
3779
|
-
await this.conductor.sendMessage(this.taskId, text, {
|
|
3944
|
+
const sent = await this.conductor.sendMessage(this.taskId, text, {
|
|
3780
3945
|
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
3781
3946
|
backend: this.backendName,
|
|
3782
3947
|
usage: result.usage || null,
|
|
@@ -3785,8 +3950,10 @@ export class BridgeRunner {
|
|
|
3785
3950
|
synthetic: true,
|
|
3786
3951
|
...(replyMetadata || {}),
|
|
3787
3952
|
});
|
|
3953
|
+
this.reportTurnUsage(result.usage, { messageId: sent?.message_id });
|
|
3788
3954
|
this.copilotLog(`${logTag} sdk_message sent responseLen=${text.length}`);
|
|
3789
3955
|
} else {
|
|
3956
|
+
this.reportTurnUsage(result.usage, { messageId: this.takeLastStreamedReplyId(replyTarget) });
|
|
3790
3957
|
this.copilotLog(`${logTag} session_file turn settled`);
|
|
3791
3958
|
}
|
|
3792
3959
|
await this.syncBackendSessionBinding();
|
|
@@ -3872,24 +4039,6 @@ function log(message) {
|
|
|
3872
4039
|
appendFireLocalLog(line);
|
|
3873
4040
|
}
|
|
3874
4041
|
|
|
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
4042
|
function logBackendReply(backend, text, { usage, replyTo }) {
|
|
3894
4043
|
const usageSuffix = usage?.total_tokens ? ` (tokens: ${usage.total_tokens})` : "";
|
|
3895
4044
|
log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
|
package/bin/conductor-remote.js
CHANGED
|
@@ -20,6 +20,7 @@ import { EXIT } from "../src/remote/client.js";
|
|
|
20
20
|
import { runRemoteExec, showHelp as showExecHelp } from "../src/remote/exec.js";
|
|
21
21
|
import { runRemoteCp, showHelp as showCpHelp } from "../src/remote/cp.js";
|
|
22
22
|
import { runRemoteWait, showHelp as showWaitHelp } from "../src/remote/wait.js";
|
|
23
|
+
import { runRemoteMcp, showHelp as showMcpHelp } from "../src/remote/mcp.js";
|
|
23
24
|
|
|
24
25
|
export { EXIT };
|
|
25
26
|
|
|
@@ -27,6 +28,7 @@ const VERBS = new Map([
|
|
|
27
28
|
["exec", runRemoteExec],
|
|
28
29
|
["cp", runRemoteCp],
|
|
29
30
|
["wait", runRemoteWait],
|
|
31
|
+
["mcp", runRemoteMcp],
|
|
30
32
|
]);
|
|
31
33
|
|
|
32
34
|
const isMainModule = (() => {
|
|
@@ -45,6 +47,7 @@ Verbs:
|
|
|
45
47
|
exec Run a command on another daemon's host
|
|
46
48
|
cp Copy a file to or from another daemon's host
|
|
47
49
|
wait Wait for a command that \`exec\` left running (by run id)
|
|
50
|
+
mcp Serve MCP tools bound to a directory on another daemon's host
|
|
48
51
|
|
|
49
52
|
Options:
|
|
50
53
|
-h, --help Show this help
|
|
@@ -59,6 +62,7 @@ For verb-specific help:
|
|
|
59
62
|
conductor remote exec --help
|
|
60
63
|
conductor remote cp --help
|
|
61
64
|
conductor remote wait --help
|
|
65
|
+
conductor remote mcp --help
|
|
62
66
|
`);
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -89,7 +93,7 @@ export async function runRemote(argv, deps = {}) {
|
|
|
89
93
|
return handler(argv.slice(1), deps);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
export { showExecHelp, showCpHelp, showWaitHelp };
|
|
96
|
+
export { showExecHelp, showCpHelp, showWaitHelp, showMcpHelp };
|
|
93
97
|
|
|
94
98
|
if (isMainModule) {
|
|
95
99
|
// `process.exitCode` rather than `process.exit()`: writes to a pipe are async,
|
package/bin/conductor.js
CHANGED
|
@@ -137,7 +137,7 @@ Subcommands:
|
|
|
137
137
|
project Manage Conductor projects (list/show/create/...)
|
|
138
138
|
issue Manage issues (list/show/create/update/start/done)
|
|
139
139
|
task Manage tasks (create/list/show/send/messages/schedule)
|
|
140
|
-
remote Run commands and copy files on another daemon's host (exec/cp/wait)
|
|
140
|
+
remote Run commands and copy files on another daemon's host (exec/cp/wait/mcp)
|
|
141
141
|
|
|
142
142
|
Options:
|
|
143
143
|
-h, --help Show this help message
|
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.15.0",
|
|
4
|
+
"gitCommitId": "a097cd6",
|
|
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.15.0",
|
|
28
|
+
"@love-moon/conductor-sdk": "0.15.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.15.0"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|