@love-moon/conductor-cli 0.13.3 → 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 CHANGED
@@ -1,5 +1,77 @@
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
+
3
75
  ## 0.13.3
4
76
 
5
77
  ### 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,
@@ -1024,18 +1024,20 @@ async function main() {
1024
1024
  .trim()
1025
1025
  .toLowerCase();
1026
1026
  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
- });
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
- 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
- });
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
- /** Runs one backend turn and reports its token count, including failed or interrupted turns. */
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
- result = await runTurn();
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 count to the server. A failed turn with
3633
- * unknown usage reports `null` so the task card drops the previous turn's count.
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 tokens = countTurnTokens(usage);
3637
- if ((tokens === null && !failed) || typeof this.conductor?.sendTurnUsage !== "function") {
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(() => this.conductor.sendTurnUsage(this.taskId, { tokens }))
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.13.3",
4
- "gitCommitId": "d61c070",
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.13.3",
28
- "@love-moon/conductor-sdk": "0.13.3",
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.13.3"
41
+ "@love-moon/chat-web": "0.14.0"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
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
- args.push("--prefill", effectivePrefill);
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
- args.push("--");
8543
- args.push(resumeHandoffPrompt);
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);
@@ -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
  }
@@ -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
- * A 429 means the per-user in-flight cap (8) is full — nothing was started, so
29
- * retrying is safe. Six attempts at 500ms doubling waits out ~15s, longer than
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 POST_RETRY = { attempts: 6, retryable: (error) => error?.status === 429 };
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), { ...POST_RETRY, sleep });
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
- await waitForSettle(runState, Math.max(0, waitDeadline - Date.now()));
130
- return toPublicRun(runState);
142
+ return attach(runState);
131
143
  }
132
144
 
133
145
  async function status(args = {}) {