@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 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
@@ -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
- backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
1028
- initialImages: cliArgs.initialImages,
1029
- cwd: runtimeProjectPath,
1030
- resumeSessionId: nextResumeSessionId,
1031
- configFile: cliArgs.configFile,
1032
- ...(cliArgs.sessionOptions || {}),
1033
- ...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
1034
- ...(enableGoalsForBackend ? { goalMode: true } : {}),
1035
- logger: { log },
1036
- sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
1037
- resumePersistedSession: Boolean(!nextResumeSessionId && taskContext.taskId),
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
- if (!this.useSessionFileReplyStream) {
3407
- const responseText =
3408
- result.text ||
3409
- extractAgentTextFromItems(result.items) ||
3410
- extractAgentTextFromMetadata(result.metadata) ||
3411
- `(${this.backendName} 未返回任何文本)`;
3412
- logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
3413
- await this.conductor.sendMessage(this.taskId, responseText, {
3414
- model: this.backendSession.threadOptions?.model || this.backendName,
3415
- backend: this.backendName,
3416
- usage: result.usage || null,
3417
- thread_id: this.backendSession.threadId,
3418
- items: result.items,
3419
- reply_to: replyTo,
3420
- cli_args: this.cliArgs,
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
- /** Runs one backend turn and reports its token count, including failed or interrupted turns. */
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
- result = await runTurn();
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 count to the server. A failed turn with
3633
- * unknown usage reports `null` so the task card drops the previous turn's count.
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 tokens = countTurnTokens(usage);
3637
- if ((tokens === null && !failed) || typeof this.conductor?.sendTurnUsage !== "function") {
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(() => this.conductor.sendTurnUsage(this.taskId, { tokens }))
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}`);
@@ -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.13.3",
4
- "gitCommitId": "d61c070",
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.13.3",
28
- "@love-moon/conductor-sdk": "0.13.3",
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.13.3"
41
+ "@love-moon/chat-web": "0.15.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [