@love-moon/conductor-cli 0.13.0 → 0.13.1

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,20 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.13.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3948125: Support persistent task rounds (RFC 0039). A `create_task` carrying
8
+ `replace_existing_fire: true` releases the task's previous fire before starting
9
+ the new round instead of ignoring it as a duplicate: tmux sessions are probed
10
+ (and killed if still alive), child processes are stopped and awaited, the old
11
+ fire's terminal status is suppressed, and its undelivered KILLED/COMPLETED
12
+ events are purged from the reused directory. The tmux liveness reaper also stops
13
+ reporting a dead session for a task whose new round is still starting. The
14
+ daemon advertises this as the `persistent_round_v1` capability.
15
+ - @love-moon/conductor-sdk@0.13.1
16
+ - @love-moon/ai-sdk@0.13.1
17
+
3
18
  ## 0.13.0
4
19
 
5
20
  ### Minor Changes
@@ -28,6 +28,7 @@ import {
28
28
  } from "../src/fire/resume.js";
29
29
  import {
30
30
  filterRuntimeSupportedAllowCliList,
31
+ inferBuiltInRuntimeBackendFromCommand,
31
32
  isBuiltInRuntimeBackend,
32
33
  isCommandOptionalBuiltInRuntimeBackend,
33
34
  listAdvertisedBackends,
@@ -218,7 +219,22 @@ export function resolveAiSessionCommandLine(backend, allowCliList, env = process
218
219
  const daemonCommand =
219
220
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
220
221
 
221
- const resolvedCommand = configuredCommand || daemonCommand;
222
+ // CONDUCTOR_CLI_COMMAND may be stale: a tmux-mode Fire inherits the tmux
223
+ // SERVER's global environment for variables the daemon did not explicitly
224
+ // override, so a value left by another backend's task (e.g. `claude --model
225
+ // opus` reaching a dsh task) can leak in. Only honor the fallback when the
226
+ // command does not provably belong to a different built-in backend. Commands
227
+ // whose executable is not a known backend name (custom wrappers) are kept.
228
+ const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
229
+ const usableDaemonCommand =
230
+ daemonCommand &&
231
+ (!inferredDaemonBackend ||
232
+ inferredDaemonBackend === normalizedSessionBackend ||
233
+ inferredDaemonBackend === normalizedBackend)
234
+ ? daemonCommand
235
+ : "";
236
+
237
+ const resolvedCommand = configuredCommand || usableDaemonCommand;
222
238
  if (!resolvedCommand) {
223
239
  return "";
224
240
  }
@@ -246,6 +262,12 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
246
262
  const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
247
263
  const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
248
264
  const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
265
+ // A running turn that has sent no runtime status for this long reports the
266
+ // provider's current tool so the app does not mistake a long tool for a hang.
267
+ const DEFAULT_RUNTIME_HEARTBEAT_MS = 60 * 1000;
268
+ const RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS = 5000;
269
+ // Keep the line within the web chat status pill.
270
+ const RUNTIME_HEARTBEAT_LINE_MAX_CHARS = 100;
249
271
  // Runtime backend tokens (first word of the resolved command line) that mean
250
272
  // "this task drives a chat-web Chromium browser". Mirrors ai-sdk's chat-web
251
273
  // aliases; user-facing aliases like `web-chatgpt` resolve to one of these.
@@ -738,6 +760,11 @@ async function main() {
738
760
  return await pendingRemoteInterruptQueue.enqueue(event);
739
761
  };
740
762
 
763
+ const handleReportRuntimeStatusCommand = async (event) => {
764
+ fireWatchdog.onInbound();
765
+ await reconnectRunner?.requestRuntimeStatusFromRemote?.(event || {});
766
+ };
767
+
741
768
  const rememberCompletedRefreshSessionRequest = (requestId, accepted) => {
742
769
  if (!requestId) {
743
770
  return accepted;
@@ -828,6 +855,7 @@ async function main() {
828
855
  onStopTask: handleStopTaskCommand,
829
856
  onInterruptTurn: handleInterruptTurnCommand,
830
857
  onRefreshSession: handleRefreshSessionCommand,
858
+ onReportRuntimeStatus: handleReportRuntimeStatusCommand,
831
859
  });
832
860
 
833
861
  const taskContext = await ensureTaskContext(conductor, {
@@ -1806,15 +1834,28 @@ async function resolveDefaultProjectId(conductor) {
1806
1834
  }
1807
1835
  }
1808
1836
 
1809
- function deriveTaskTitle(prompt, explicit, backend = "codex") {
1837
+ // Keep in sync with web/src/features/tasks/utils/default-task-title.ts: first clause,
1838
+ // capped at 20 display columns (CJK/full-width/emoji = 2) so it fits the mobile header.
1839
+ export function deriveTaskTitle(prompt, explicit, backend = "codex") {
1810
1840
  if (explicit && explicit.trim()) {
1811
1841
  return explicit.trim();
1812
1842
  }
1813
- if (prompt) {
1814
- const compact = prompt.replace(/\s+/g, " ").trim();
1815
- if (compact) {
1816
- return compact.slice(0, 80);
1843
+ const text = (prompt || "").trim();
1844
+ const breakIndex = text.search(/[,。!?;\n]|[,.!?;](?=\s|$)/);
1845
+ const clause = (breakIndex > 0 ? text.slice(0, breakIndex) : text).replace(/\s+/g, " ");
1846
+ let title = "";
1847
+ let width = 0;
1848
+ for (const char of clause) {
1849
+ width += char.codePointAt(0) >= 0x2e80 ? 2 : 1;
1850
+ if (width > 20) {
1851
+ // Never cut an English word in half; drop the partial word unless it is the only one.
1852
+ if (/\w$/.test(title) && /\w/.test(char)) title = title.replace(/\s*\w+$/, "") || title;
1853
+ break;
1817
1854
  }
1855
+ title += char;
1856
+ }
1857
+ if (title.trim()) {
1858
+ return title.trim();
1818
1859
  }
1819
1860
  const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
1820
1861
  return `${backendName} Task`;
@@ -1917,6 +1958,14 @@ function isTruthyEnv(value) {
1917
1958
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1918
1959
  }
1919
1960
 
1961
+ function formatElapsedShort(ms) {
1962
+ const seconds = Math.max(0, Math.floor(ms / 1000));
1963
+ if (seconds < 60) return `${seconds}s`;
1964
+ const minutes = Math.floor(seconds / 60);
1965
+ if (minutes < 60) return `${minutes}m`;
1966
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
1967
+ }
1968
+
1920
1969
  function isSessionClosedError(error) {
1921
1970
  return Boolean(error && typeof error === "object" && error.reason === "session_closed");
1922
1971
  }
@@ -2018,6 +2067,14 @@ export class BridgeRunner {
2018
2067
  500,
2019
2068
  60 * 1000,
2020
2069
  );
2070
+ this.runtimeHeartbeatMs = getBoundedEnvInt(
2071
+ "CONDUCTOR_RUNTIME_HEARTBEAT_MS",
2072
+ DEFAULT_RUNTIME_HEARTBEAT_MS,
2073
+ 1000,
2074
+ 10 * 60 * 1000,
2075
+ );
2076
+ this.runtimeHeartbeatTimer = null;
2077
+ this.turnStartedAt = 0;
2021
2078
  this.useSessionFileReplyStream =
2022
2079
  Boolean(this.backendSession) &&
2023
2080
  typeof this.backendSession.usesSessionFileReplyStream === "function" &&
@@ -2853,7 +2910,7 @@ export class BridgeRunner {
2853
2910
  };
2854
2911
  }
2855
2912
 
2856
- async reportRuntimeStatus(payload, replyTo) {
2913
+ async reportRuntimeStatus(payload, replyTo, { force = false } = {}) {
2857
2914
  const runtimeContext = await this.resolveRuntimeContext();
2858
2915
  const runtime = this.createRuntimeStatus(payload, replyTo, runtimeContext);
2859
2916
  if (!runtime) {
@@ -2861,10 +2918,14 @@ export class BridgeRunner {
2861
2918
  }
2862
2919
 
2863
2920
  const signature = JSON.stringify(runtime);
2864
- if (signature === this.lastRuntimeStatusSignature) {
2921
+ if (!force && signature === this.lastRuntimeStatusSignature) {
2865
2922
  return;
2866
2923
  }
2867
- this.lastRuntimeStatusSignature = signature;
2924
+ // Heartbeats keep the provider's last signature, so its unchanged periodic
2925
+ // status (e.g. claude tool_progress) does not replace the heartbeat line.
2926
+ if (!force) {
2927
+ this.lastRuntimeStatusSignature = signature;
2928
+ }
2868
2929
  this.lastRuntimeStatusPayload = {
2869
2930
  ...runtime,
2870
2931
  };
@@ -2882,6 +2943,109 @@ export class BridgeRunner {
2882
2943
  } catch (error) {
2883
2944
  log(`Failed to report runtime status: ${error?.message || error}`);
2884
2945
  }
2946
+ this.scheduleRuntimeHeartbeat();
2947
+ }
2948
+
2949
+ /** (Re)start the silence timer; a no-op that just clears it outside a turn. */
2950
+ scheduleRuntimeHeartbeat() {
2951
+ clearTimeout(this.runtimeHeartbeatTimer);
2952
+ this.runtimeHeartbeatTimer = null;
2953
+ if (!this.runningTurn || this.stopped) {
2954
+ return;
2955
+ }
2956
+ this.runtimeHeartbeatTimer = setTimeout(() => {
2957
+ this.runtimeHeartbeatTimer = null;
2958
+ void this.reportRuntimeHeartbeat();
2959
+ }, this.runtimeHeartbeatMs);
2960
+ this.runtimeHeartbeatTimer.unref?.();
2961
+ }
2962
+
2963
+ async fetchBackendTurnStatus() {
2964
+ const session = this.backendSession;
2965
+ if (typeof session?.fetchCurrentTurnStatus === "function") {
2966
+ let timer = null;
2967
+ try {
2968
+ return await Promise.race([
2969
+ session.fetchCurrentTurnStatus(),
2970
+ new Promise((_, reject) => {
2971
+ timer = setTimeout(() => reject(new Error("turn status query timed out")), RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS);
2972
+ }),
2973
+ ]);
2974
+ } catch {
2975
+ // fall back to the last status the session pushed
2976
+ } finally {
2977
+ clearTimeout(timer);
2978
+ }
2979
+ }
2980
+ try {
2981
+ return session?.getCurrentTurnStatus?.() ?? null;
2982
+ } catch {
2983
+ return null;
2984
+ }
2985
+ }
2986
+
2987
+ formatRuntimeHeartbeatLine(status, now = Date.now()) {
2988
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
2989
+ const tool = status?.active_tool;
2990
+ const startedAt = Date.parse(tool?.started_at || "") || this.turnStartedAt || now;
2991
+ const elapsed = formatElapsedShort(now - startedAt);
2992
+ // Elapsed time goes before the tool input so it survives truncation.
2993
+ if (tool?.name) {
2994
+ const summary = tool.summary ? `: ${tool.summary}` : "";
2995
+ return clip(`${this.backendName} running ${tool.name} (${elapsed})${summary}`, RUNTIME_HEARTBEAT_LINE_MAX_CHARS);
2996
+ }
2997
+ const base = status?.status_line || `${this.backendName} is working`;
2998
+ return `${clip(base, RUNTIME_HEARTBEAT_LINE_MAX_CHARS - elapsed.length - 3)} (${elapsed})`;
2999
+ }
3000
+
3001
+ async reportRuntimeHeartbeat() {
3002
+ if (!this.runningTurn || this.stopped) {
3003
+ return;
3004
+ }
3005
+ const lastSent = this.lastRuntimeStatusPayload;
3006
+ const status = await this.fetchBackendTurnStatus();
3007
+ // Skip if the turn ended, the provider settled it, or a newer frame (e.g.
3008
+ // the terminal one) went out while querying: never re-open a settled turn.
3009
+ if (
3010
+ !this.runningTurn ||
3011
+ this.stopped ||
3012
+ status?.reply_in_progress === false ||
3013
+ this.lastRuntimeStatusPayload !== lastSent
3014
+ ) {
3015
+ return;
3016
+ }
3017
+ await this.reportRuntimeStatus(
3018
+ {
3019
+ state: status?.state,
3020
+ phase: status?.phase || "heartbeat",
3021
+ reply_in_progress: true,
3022
+ status_line: this.formatRuntimeHeartbeatLine(status),
3023
+ },
3024
+ status?.replyTo || this.activeTurnReplyTo,
3025
+ // Forced so an unchanged line still goes out and re-arms the timer.
3026
+ { force: true },
3027
+ );
3028
+ }
3029
+
3030
+ /** App asked (page load / stale composer) for the current runtime status. */
3031
+ async requestRuntimeStatusFromRemote(event = {}) {
3032
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
3033
+ if (taskId && taskId !== this.taskId) {
3034
+ return;
3035
+ }
3036
+ if (this.runningTurn) {
3037
+ await this.reportRuntimeHeartbeat();
3038
+ return;
3039
+ }
3040
+ if (this.lastRuntimeStatusPayload?.reply_in_progress) {
3041
+ // No turn is running, so an in-progress frame is stale; settle it.
3042
+ this.lastRuntimeStatusPayload = {
3043
+ ...this.lastRuntimeStatusPayload,
3044
+ reply_in_progress: false,
3045
+ status_line: undefined,
3046
+ };
3047
+ }
3048
+ await this.replayLastRuntimeStatus();
2885
3049
  }
2886
3050
 
2887
3051
  async replayLastRuntimeStatus() {
@@ -3134,6 +3298,7 @@ export class BridgeRunner {
3134
3298
  this.runningTurn = true;
3135
3299
  this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
3136
3300
  const turnStartedAt = Date.now();
3301
+ this.turnStartedAt = turnStartedAt;
3137
3302
  let turnWatchdog = null;
3138
3303
  if (this.isCopilotBackend) {
3139
3304
  turnWatchdog = setInterval(() => {
@@ -3316,6 +3481,7 @@ export class BridgeRunner {
3316
3481
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
3317
3482
  );
3318
3483
  this.runningTurn = false;
3484
+ this.scheduleRuntimeHeartbeat();
3319
3485
  }
3320
3486
  }
3321
3487
 
@@ -3442,6 +3608,7 @@ export class BridgeRunner {
3442
3608
  this.lastRuntimeStatusSignature = null;
3443
3609
  this.runningTurn = true;
3444
3610
  const startedAt = Date.now();
3611
+ this.turnStartedAt = startedAt;
3445
3612
  if (
3446
3613
  this.useSessionFileReplyStream &&
3447
3614
  typeof this.backendSession?.setSessionReplyTarget === "function"
@@ -3523,6 +3690,7 @@ export class BridgeRunner {
3523
3690
  } finally {
3524
3691
  this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
3525
3692
  this.runningTurn = false;
3693
+ this.scheduleRuntimeHeartbeat();
3526
3694
  }
3527
3695
  }
3528
3696
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.13.0",
4
- "gitCommitId": "e1e507f",
3
+ "version": "0.13.1",
4
+ "gitCommitId": "b82ba1c",
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.0",
28
- "@love-moon/conductor-sdk": "0.13.0",
27
+ "@love-moon/ai-sdk": "0.13.1",
28
+ "@love-moon/conductor-sdk": "0.13.1",
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.0"
41
+ "@love-moon/chat-web": "0.13.1"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -891,6 +891,27 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
891
891
  "CONDUCTOR_RESUME_CWD",
892
892
  ];
893
893
 
894
+ // Task-scoped env vars a Fire must NEVER inherit from anywhere but this
895
+ // daemon's explicit choice. In tmux mode the new session merges the tmux
896
+ // SERVER's global environment (inherited from whatever task first started the
897
+ // server), so a variable the daemon sets only conditionally — e.g.
898
+ // CONDUCTOR_CLI_COMMAND, which command-optional backends (dsh/copilot) leave
899
+ // unset — would leak a stale value from an earlier task (2026-09-17: a dsh
900
+ // task inherited `claude --model opus` and forwarded model=opus to the
901
+ // DeepSeek API).
902
+ //
903
+ // The invariant is enforced at the single choke point `spawnFireProcess`:
904
+ // every key in this list is passed to `tmux new-session` explicitly — either
905
+ // with the daemon's value or cleared as `-e KEY=` (empty is our "unset"
906
+ // convention; every consumer trims/negates before use). RULE: any new
907
+ // task-scoped env var added to a Fire spawn must be registered here.
908
+ const FIRE_TASK_SCOPED_ENV_KEYS = [
909
+ ...PTY_TASK_SCOPED_ENV_KEYS,
910
+ "CONDUCTOR_AGENT_TOKEN",
911
+ "CONDUCTOR_BACKEND_URL",
912
+ "CONDUCTOR_DAEMON_NAME",
913
+ ];
914
+
894
915
  function stripPtyTaskScopedEnv(source) {
895
916
  const env = {
896
917
  ...(source && typeof source === "object" ? source : {}),
@@ -1692,6 +1713,17 @@ export function startDaemon(config = {}, deps = {}) {
1692
1713
  tmuxEnvFlags.push("-e", `${key}=${stringValue}`);
1693
1714
  }
1694
1715
 
1716
+ // Clear every task-scoped variable the daemon did NOT set for this task,
1717
+ // or the session would inherit a stale value from the tmux server's
1718
+ // global environment (see FIRE_TASK_SCOPED_ENV_KEYS). `-e KEY=` (empty)
1719
+ // works on old tmux versions that lack the `-e KEY` unset form, and all
1720
+ // our consumers treat "" as unset.
1721
+ for (const scopedKey of FIRE_TASK_SCOPED_ENV_KEYS) {
1722
+ if (env[scopedKey] === undefined || env[scopedKey] === null) {
1723
+ tmuxEnvFlags.push("-e", `${scopedKey}=`);
1724
+ }
1725
+ }
1726
+
1695
1727
  // Use a non-login `bash -c` here: node and the CLI script are passed by
1696
1728
  // absolute path, so we don't need PATH from a login shell, and `-l`
1697
1729
  // would slow startup and could leak unexpected stderr from user shell
@@ -2001,7 +2033,7 @@ export function startDaemon(config = {}, deps = {}) {
2001
2033
  // *live* replacement as killed, using the previous run's log tail. Worse,
2002
2034
  // the daemon's own map would say running, so reconcile would never repair
2003
2035
  // it. Re-check that the task is still absent before speaking.
2004
- if (activeTaskProcesses.has(taskId)) {
2036
+ if (activeTaskProcesses.has(taskId) || pendingTaskStarts.has(taskId)) {
2005
2037
  log(
2006
2038
  `Task ${taskId} was restarted while its dead tmux session was being reported; dropping the stale terminal status`,
2007
2039
  );
@@ -3815,6 +3847,7 @@ export function startDaemon(config = {}, deps = {}) {
3815
3847
  "project_agents_registry",
3816
3848
  "restart_daemon",
3817
3849
  "refresh_session_inplace",
3850
+ "persistent_round_v1",
3818
3851
  "task_attachments_v1",
3819
3852
  "backend_session_list",
3820
3853
  CUSTOM_COMMANDS_CAPABILITY,
@@ -6947,6 +6980,42 @@ export function startDaemon(config = {}, deps = {}) {
6947
6980
  return true;
6948
6981
  }
6949
6982
 
6983
+ // RFC 0039: make sure the previous round's fire is gone before a new round
6984
+ // reuses its task id. The server has normally stopped it already, so what is
6985
+ // left is a still-exiting child or a tmux record the liveness reaper has not
6986
+ // swept yet; a fire that is somehow still alive is stopped here. Its terminal
6987
+ // status is suppressed because the task row now belongs to the new round.
6988
+ async function releaseReplacedFire(taskId) {
6989
+ const record = activeTaskProcesses.get(taskId);
6990
+ if (!recordHasLiveFire(record)) {
6991
+ return true;
6992
+ }
6993
+ if (record.tmuxMode) {
6994
+ const { alive, conclusive } = await probeTmuxSession(record.tmuxSession);
6995
+ if (activeTaskProcesses.get(taskId) !== record) {
6996
+ return !recordHasLiveFire(activeTaskProcesses.get(taskId));
6997
+ }
6998
+ if (!conclusive) {
6999
+ return false;
7000
+ }
7001
+ if (!alive) {
7002
+ activeTaskProcesses.delete(taskId);
7003
+ forgetFireSessionRecord(record);
7004
+ return true;
7005
+ }
7006
+ stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
7007
+ // The tmux stop path retires the record synchronously and never consumes the flag.
7008
+ suppressedExitStatusReports.delete(taskId);
7009
+ return !activeTaskProcesses.has(taskId);
7010
+ }
7011
+ stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
7012
+ const stopped = await waitForTaskToStop(taskId);
7013
+ if (!stopped) {
7014
+ suppressedExitStatusReports.delete(taskId);
7015
+ }
7016
+ return stopped;
7017
+ }
7018
+
6950
7019
  async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
6951
7020
  const deadline = Date.now() + Math.max(timeoutMs, 0);
6952
7021
 
@@ -7537,6 +7606,23 @@ export function startDaemon(config = {}, deps = {}) {
7537
7606
  return;
7538
7607
  }
7539
7608
 
7609
+ // RFC 0039: a persistent task's new round reuses the task id. Release the
7610
+ // previous round's fire first instead of swallowing the round as a duplicate.
7611
+ const replacesPreviousRound = payload?.replace_existing_fire === true;
7612
+ if (
7613
+ replacesPreviousRound &&
7614
+ !pendingTaskStarts.has(taskId) &&
7615
+ !(await releaseReplacedFire(taskId))
7616
+ ) {
7617
+ reportCreateTaskFailure({
7618
+ taskId,
7619
+ projectId,
7620
+ requestId,
7621
+ error: new Error("the previous round is still running on this daemon"),
7622
+ });
7623
+ return;
7624
+ }
7625
+
7540
7626
  const existingTaskRecord = activeTaskProcesses.get(taskId);
7541
7627
  if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
7542
7628
  log(
@@ -7679,7 +7765,13 @@ export function startDaemon(config = {}, deps = {}) {
7679
7765
  // even when the daemon was named through the config file.
7680
7766
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
7681
7767
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
7682
- ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
7768
+ // Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
7769
+ // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
7770
+ // value in the tmux SERVER's global environment (inherited from whatever
7771
+ // task first started the server). Without the override, a commandless
7772
+ // backend (dsh/copilot) would see e.g. `claude --model opus` here and
7773
+ // mis-apply its flags. Fire treats "" as unset.
7774
+ CONDUCTOR_CLI_COMMAND: cliCommand,
7683
7775
  };
7684
7776
  if (AGENT_TOKEN) {
7685
7777
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
@@ -7688,6 +7780,12 @@ export function startDaemon(config = {}, deps = {}) {
7688
7780
  env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
7689
7781
  }
7690
7782
 
7783
+ // A new round may reuse the previous round's directory, and with it that
7784
+ // fire's undelivered KILLED/COMPLETED event (see restart below).
7785
+ if (replacesPreviousRound) {
7786
+ dropSupersededTerminalStatusEvents(taskDir, taskId);
7787
+ }
7788
+
7691
7789
  // Sampled before the spawn so it is a true "everything after this is
7692
7790
  // ours" watermark.
7693
7791
  const logStartOffset = readLogSize(logPath);
@@ -8422,7 +8520,10 @@ export function startDaemon(config = {}, deps = {}) {
8422
8520
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
8423
8521
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
8424
8522
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
8425
- ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
8523
+ // Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
8524
+ // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
8525
+ // value in the tmux SERVER's global environment (see create_task).
8526
+ CONDUCTOR_CLI_COMMAND: cliCommand,
8426
8527
  };
8427
8528
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
8428
8529
  if (AGENT_TOKEN) {
@@ -11,6 +11,7 @@ import { loadServeAiRuntimeConfig } from "./config.js";
11
11
  import { sendJson, sendOpenAiError } from "./errors.js";
12
12
  import { materializeImageInputs } from "./image-handler.js";
13
13
  import {
14
+ inferBuiltInRuntimeBackendFromCommand,
14
15
  listAdvertisedBackends,
15
16
  normalizeRuntimeBackendAlias,
16
17
  normalizeRuntimeBackendName,
@@ -124,7 +125,7 @@ function extractModelOptionFromCommandLine(commandLine) {
124
125
  return "";
125
126
  }
126
127
 
127
- function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
128
+ export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
128
129
  const normalizedBackend = normalizeRuntimeBackendName(backend);
129
130
  const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
130
131
  const envKeyByBackend = {
@@ -148,7 +149,21 @@ function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, s
148
149
  : "";
149
150
  const daemonCommand =
150
151
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
151
- const resolvedCommand = configuredCommand || daemonCommand;
152
+
153
+ // CONDUCTOR_CLI_COMMAND may be stale (e.g. leaked from a tmux server's
154
+ // global environment, left by another backend's task). Only honor the
155
+ // fallback when the command does not provably belong to a different built-in
156
+ // backend. Mirrors the same guard in bin/conductor-fire.js.
157
+ const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
158
+ const usableDaemonCommand =
159
+ daemonCommand &&
160
+ (!inferredDaemonBackend ||
161
+ inferredDaemonBackend === normalizedSessionBackend ||
162
+ inferredDaemonBackend === normalizedBackend)
163
+ ? daemonCommand
164
+ : "";
165
+
166
+ const resolvedCommand = configuredCommand || usableDaemonCommand;
152
167
  if (!resolvedCommand) {
153
168
  return "";
154
169
  }