@love-moon/conductor-cli 0.12.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,44 @@
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
+
18
+ ## 0.13.0
19
+
20
+ ### Minor Changes
21
+
22
+ - 8edbed5: `conductor remote exec` is safer to drive from an agent, and gains a `wait` verb.
23
+
24
+ - New `conductor remote wait -t <daemon> <runId>` re-attaches to a command that
25
+ `exec` left running past its `--timeout`, polling and reporting it exactly as
26
+ `exec` would have. `exec` now prints that command in its "still running" hint
27
+ instead of a raw API path.
28
+ - `exec` retries the initial request on `429 too many concurrent remote exec
29
+ requests` with backoff (up to ~15 s), so a burst of parallel short commands no
30
+ longer fails outright. Other errors are still not retried: a 5xx may already
31
+ have started the command.
32
+ - `exec` and `wait` handle SIGINT/SIGTERM: the run id is printed before exiting,
33
+ and with `--kill-on-timeout` the remote command is cancelled as well. Before,
34
+ a tool timeout that killed the CLI silently orphaned the remote process.
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [80ab13e]
39
+ - @love-moon/conductor-sdk@0.13.0
40
+ - @love-moon/ai-sdk@0.13.0
41
+
3
42
  ## 0.12.0
4
43
 
5
44
  ### 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
 
@@ -19,12 +19,14 @@ import { fileURLToPath } from "node:url";
19
19
  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
+ import { runRemoteWait, showHelp as showWaitHelp } from "../src/remote/wait.js";
22
23
 
23
24
  export { EXIT };
24
25
 
25
26
  const VERBS = new Map([
26
27
  ["exec", runRemoteExec],
27
28
  ["cp", runRemoteCp],
29
+ ["wait", runRemoteWait],
28
30
  ]);
29
31
 
30
32
  const isMainModule = (() => {
@@ -42,6 +44,7 @@ Usage:
42
44
  Verbs:
43
45
  exec Run a command on another daemon's host
44
46
  cp Copy a file to or from another daemon's host
47
+ wait Wait for a command that \`exec\` left running (by run id)
45
48
 
46
49
  Options:
47
50
  -h, --help Show this help
@@ -50,10 +53,12 @@ Examples:
50
53
  conductor remote exec --target ubuntu --workspace /srv/app -- git log --oneline -5
51
54
  conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
52
55
  conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
56
+ conductor remote wait --target ubuntu <runId>
53
57
 
54
58
  For verb-specific help:
55
59
  conductor remote exec --help
56
60
  conductor remote cp --help
61
+ conductor remote wait --help
57
62
  `);
58
63
  }
59
64
 
@@ -84,7 +89,7 @@ export async function runRemote(argv, deps = {}) {
84
89
  return handler(argv.slice(1), deps);
85
90
  }
86
91
 
87
- export { showExecHelp, showCpHelp };
92
+ export { showExecHelp, showCpHelp, showWaitHelp };
88
93
 
89
94
  if (isMainModule) {
90
95
  // `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)
140
+ remote Run commands and copy files on another daemon's host (exec/cp/wait)
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.12.0",
4
- "gitCommitId": "c1047e8",
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.12.0",
28
- "@love-moon/conductor-sdk": "0.12.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.12.0"
41
+ "@love-moon/chat-web": "0.13.1"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from "./conductor-paths.js";
27
27
  import {
28
28
  deleteFireSessionRecord,
29
+ listFireSessionTaskIds,
29
30
  pruneFireSessionRecords,
30
31
  readFireSessionRecord,
31
32
  resolveFireSessionRegistryDir,
@@ -890,6 +891,27 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
890
891
  "CONDUCTOR_RESUME_CWD",
891
892
  ];
892
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
+
893
915
  function stripPtyTaskScopedEnv(source) {
894
916
  const env = {
895
917
  ...(source && typeof source === "object" ? source : {}),
@@ -1691,6 +1713,17 @@ export function startDaemon(config = {}, deps = {}) {
1691
1713
  tmuxEnvFlags.push("-e", `${key}=${stringValue}`);
1692
1714
  }
1693
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
+
1694
1727
  // Use a non-login `bash -c` here: node and the CLI script are passed by
1695
1728
  // absolute path, so we don't need PATH from a login shell, and `-l`
1696
1729
  // would slow startup and could leak unexpected stderr from user shell
@@ -2000,7 +2033,7 @@ export function startDaemon(config = {}, deps = {}) {
2000
2033
  // *live* replacement as killed, using the previous run's log tail. Worse,
2001
2034
  // the daemon's own map would say running, so reconcile would never repair
2002
2035
  // it. Re-check that the task is still absent before speaking.
2003
- if (activeTaskProcesses.has(taskId)) {
2036
+ if (activeTaskProcesses.has(taskId) || pendingTaskStarts.has(taskId)) {
2004
2037
  log(
2005
2038
  `Task ${taskId} was restarted while its dead tmux session was being reported; dropping the stale terminal status`,
2006
2039
  );
@@ -2306,6 +2339,30 @@ export function startDaemon(config = {}, deps = {}) {
2306
2339
  return FIRE_TMUX_MODE_ENABLED && !FIRE_TMUX_MODE_ACTIVE && isTmuxAvailable();
2307
2340
  }
2308
2341
 
2342
+ // The third door into the same mass kill: `tmux` genuinely absent from PATH.
2343
+ // A daemon restarted by launchd/systemd/cron — or during a `brew upgrade
2344
+ // tmux` — logs the direct-spawn fallback and then cannot enumerate sessions
2345
+ // by any means, so the guard above (which needs tmux to answer) declines and
2346
+ // every Fire the predecessor deliberately left running gets killed.
2347
+ //
2348
+ // The hand-off records on disk are the remaining evidence: they name exactly
2349
+ // the tasks whose liveness this process is unable to answer for. Scoped to
2350
+ // those rather than skipping the sweep wholesale, because a host that simply
2351
+ // never installs tmux spawns its Fires directly and those DO die with their
2352
+ // daemon — blanket-skipping would strand them at `running` with nobody left
2353
+ // to report their death, which is worse than killing them. Nothing prunes
2354
+ // the registry in this state (the prune needs a conclusive listing), so a
2355
+ // later start with a healthy tmux still adopts or reaps them properly.
2356
+ function taskIdsWithUnverifiableTmuxFire() {
2357
+ if (!FIRE_TMUX_MODE_ENABLED || FIRE_TMUX_MODE_ACTIVE) return new Set();
2358
+ try {
2359
+ return listFireSessionTaskIds(FIRE_SESSION_REGISTRY_DIR);
2360
+ } catch (error) {
2361
+ logError(`Failed to read tmux hand-off records: ${error?.message || error}`);
2362
+ return new Set();
2363
+ }
2364
+ }
2365
+
2309
2366
  // Sessions already identified as orphaned shells and killed. `tmux
2310
2367
  // kill-session` is asynchronous, so a sweep running moments later still
2311
2368
  // sees the name in `list-sessions` — and by then the hand-off record is
@@ -3790,6 +3847,7 @@ export function startDaemon(config = {}, deps = {}) {
3790
3847
  "project_agents_registry",
3791
3848
  "restart_daemon",
3792
3849
  "refresh_session_inplace",
3850
+ "persistent_round_v1",
3793
3851
  "task_attachments_v1",
3794
3852
  "backend_session_list",
3795
3853
  CUSTOM_COMMANDS_CAPABILITY,
@@ -4856,12 +4914,26 @@ export function startDaemon(config = {}, deps = {}) {
4856
4914
  );
4857
4915
  return;
4858
4916
  }
4917
+ const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
4918
+ const skippedUnverifiable = [];
4859
4919
  const deadTasks = staleTasks.filter((task) => {
4920
+ const taskId = String(task?.id || "");
4860
4921
  // A spawn already in flight has no record yet either; killing it
4861
4922
  // would shoot down a Fire this daemon is in the middle of starting.
4862
- if (pendingTaskStarts.has(String(task?.id || ""))) return false;
4923
+ if (pendingTaskStarts.has(taskId)) return false;
4924
+ if (unverifiableTmuxTaskIds.has(taskId)) {
4925
+ skippedUnverifiable.push(taskId);
4926
+ return false;
4927
+ }
4863
4928
  return !adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || []);
4864
4929
  });
4930
+ if (skippedUnverifiable.length) {
4931
+ logError(
4932
+ `tmux is not on PATH, so the Fires a previous daemon left for ${skippedUnverifiable.length} ` +
4933
+ `task(s) cannot be checked; skipping stale recovery for them rather than killing what this ` +
4934
+ `daemon cannot see. Restart with tmux available to adopt or reap them.`,
4935
+ );
4936
+ }
4865
4937
 
4866
4938
  if (deadTasks.length === 0) {
4867
4939
  return;
@@ -4960,12 +5032,23 @@ export function startDaemon(config = {}, deps = {}) {
4960
5032
  );
4961
5033
  return;
4962
5034
  }
5035
+ const unverifiableTmuxTaskIds = taskIdsWithUnverifiableTmuxFire();
4963
5036
  for (const task of assigned) {
4964
5037
  const taskId = String(task?.id || "");
4965
5038
  if (!taskId) continue;
4966
5039
  if (localTaskIds.has(taskId) || pendingTaskStarts.has(taskId)) {
4967
5040
  continue;
4968
5041
  }
5042
+ // No tmux binary to ask, but a predecessor filed a hand-off record for
5043
+ // this task: we cannot see its Fire, which is not the same as it being
5044
+ // dead. See taskIdsWithUnverifiableTmuxFire.
5045
+ if (unverifiableTmuxTaskIds.has(taskId)) {
5046
+ logError(
5047
+ `Task ${taskId} has a tmux hand-off record but tmux is not on PATH; skipping reconcile ` +
5048
+ `rather than killing a Fire this daemon cannot see`,
5049
+ );
5050
+ continue;
5051
+ }
4969
5052
  // Adopts on the spot rather than merely skipping: a survivor that
4970
5053
  // dodges the kill but gains no watcher would sit at `running`
4971
5054
  // forever, because nothing else reports a tmux Fire's death.
@@ -6897,6 +6980,42 @@ export function startDaemon(config = {}, deps = {}) {
6897
6980
  return true;
6898
6981
  }
6899
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
+
6900
7019
  async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
6901
7020
  const deadline = Date.now() + Math.max(timeoutMs, 0);
6902
7021
 
@@ -7487,6 +7606,23 @@ export function startDaemon(config = {}, deps = {}) {
7487
7606
  return;
7488
7607
  }
7489
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
+
7490
7626
  const existingTaskRecord = activeTaskProcesses.get(taskId);
7491
7627
  if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
7492
7628
  log(
@@ -7629,7 +7765,13 @@ export function startDaemon(config = {}, deps = {}) {
7629
7765
  // even when the daemon was named through the config file.
7630
7766
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
7631
7767
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
7632
- ...(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,
7633
7775
  };
7634
7776
  if (AGENT_TOKEN) {
7635
7777
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
@@ -7638,6 +7780,12 @@ export function startDaemon(config = {}, deps = {}) {
7638
7780
  env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
7639
7781
  }
7640
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
+
7641
7789
  // Sampled before the spawn so it is a true "everything after this is
7642
7790
  // ours" watermark.
7643
7791
  const logStartOffset = readLogSize(logPath);
@@ -8372,7 +8520,10 @@ export function startDaemon(config = {}, deps = {}) {
8372
8520
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
8373
8521
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
8374
8522
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
8375
- ...(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,
8376
8527
  };
8377
8528
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
8378
8529
  if (AGENT_TOKEN) {
@@ -134,3 +134,22 @@ export function pruneFireSessionRecords(dir, liveSessionNames, { fsImpl = fs } =
134
134
  }
135
135
  return pruned;
136
136
  }
137
+
138
+ // Task ids a previous daemon left a tmux Fire running for. This is the only
139
+ // evidence available to a successor that has no `tmux` binary to enumerate
140
+ // sessions with, so it is what scopes that daemon's refusal to kill.
141
+ export function listFireSessionTaskIds(dir, { fsImpl = fs } = {}) {
142
+ let entries;
143
+ try {
144
+ entries = fsImpl.readdirSync(dir);
145
+ } catch {
146
+ return new Set();
147
+ }
148
+ const taskIds = new Set();
149
+ for (const entry of entries) {
150
+ if (!entry.endsWith(".json")) continue;
151
+ const record = readFireSessionRecord(dir, entry.slice(0, -".json".length), { fsImpl });
152
+ if (record?.taskId) taskIds.add(record.taskId);
153
+ }
154
+ return taskIds;
155
+ }
@@ -97,8 +97,14 @@ export function isRetryable(error) {
97
97
  return status >= 500 || status === 429;
98
98
  }
99
99
 
100
- /** Retry `fn` on transient failures, handing it the attempt number. */
101
- export async function withRetry(fn, { attempts = 4, baseDelayMs = 500, sleep = delay } = {}) {
100
+ /**
101
+ * Retry `fn` on transient failures, handing it the attempt number. `retryable`
102
+ * narrows what counts as transient: a caller whose request is not idempotent
103
+ * (starting a command) must not retry a 5xx that may already have taken effect.
104
+ */
105
+ export async function withRetry(fn, {
106
+ attempts = 4, baseDelayMs = 500, sleep = delay, retryable = isRetryable,
107
+ } = {}) {
102
108
  let lastError;
103
109
  for (let attempt = 0; attempt < attempts; attempt += 1) {
104
110
  try {
@@ -106,7 +112,7 @@ export async function withRetry(fn, { attempts = 4, baseDelayMs = 500, sleep = d
106
112
  } catch (error) {
107
113
  lastError = error;
108
114
  // An aborted transfer is the caller's own deadline firing, not a blip.
109
- if (error?.name === "AbortError" || !isRetryable(error)) throw error;
115
+ if (error?.name === "AbortError" || !retryable(error)) throw error;
110
116
  if (attempt === attempts - 1) break;
111
117
  await sleep(baseDelayMs * 2 ** attempt);
112
118
  }
@@ -15,6 +15,7 @@ import {
15
15
  delay,
16
16
  loadCliConfig,
17
17
  parseTimeoutMs as parseSharedTimeoutMs,
18
+ withRetry,
18
19
  } from "./client.js";
19
20
 
20
21
  export { EXIT, UsageError };
@@ -23,6 +24,14 @@ const DEFAULT_TIMEOUT_MS = 60_000;
23
24
  const POLL_INTERVAL_MS = 1_000;
24
25
  /** How long a single request may block server-side before we switch to polling. */
25
26
  const POST_WAIT_MS = 10_000;
27
+ /**
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.
32
+ */
33
+ const POST_RETRY = { attempts: 6, retryable: (error) => error?.status === 429 };
34
+ const INTERRUPT_SIGNALS = ["SIGINT", "SIGTERM"];
26
35
 
27
36
  const VALUE_FLAGS = new Map([
28
37
  ["--target", "target"],
@@ -120,7 +129,8 @@ Options:
120
129
  --timeout <dur> Overall deadline, e.g. 30s, 2m, 500ms (default: 60s)
121
130
  -e, --env KEY=VALUE Extra environment variable (repeatable)
122
131
  --json Print the raw run result as JSON
123
- --kill-on-timeout Stop the remote command when --timeout is reached
132
+ --kill-on-timeout Stop the remote command when --timeout is reached or
133
+ this CLI is interrupted (Ctrl-C, SIGTERM)
124
134
  (default: it keeps running on the target)
125
135
  --config-file <p> Conductor config file to authenticate with
126
136
  -h, --help Show this help
@@ -132,6 +142,9 @@ Notes:
132
142
  Exit codes follow ssh: the remote command's own code is passed through, and
133
143
  255 means this CLI failed (bad usage, daemon offline, network error).
134
144
 
145
+ A command that outlives --timeout keeps running on the target; the run id is
146
+ printed and \`conductor remote wait -t <daemon> <runId>\` picks it up again.
147
+
135
148
  Examples:
136
149
  conductor remote exec --target ubuntu --workspace /home/duino/ws/holomotion ls .
137
150
  conductor remote exec -t ubuntu -w /srv/app -- git log --oneline -5
@@ -139,40 +152,23 @@ Examples:
139
152
  }
140
153
 
141
154
  /**
142
- * Run one command on `target` and return the final run record.
143
- *
144
- * This is the programmatic half of `runRemoteExec`: same two-phase POST-then-poll
145
- * behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
146
- * `tar` on the target.
155
+ * Poll `run` until it leaves `running`, the deadline passes, or `signal`
156
+ * aborts. With `killOnTimeout` a run that is still going at that point is
157
+ * cancelled on the target. Shared by `exec` (after its POST) and `wait`.
147
158
  */
148
- export async function execRemote(config, target, command, options = {}) {
159
+ export async function waitForRun(config, basePath, initialRun, options = {}) {
149
160
  const {
150
- args = [],
151
- workspace,
152
- env,
153
- timeoutMs = DEFAULT_TIMEOUT_MS,
161
+ deadline,
154
162
  fetchImpl = globalThis.fetch,
155
163
  sleep = delay,
156
164
  now = () => Date.now(),
157
165
  killOnTimeout = false,
166
+ signal,
158
167
  } = options;
159
168
 
160
- const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
161
- const deadline = now() + timeoutMs;
162
-
163
- let run = await callApi(config, "POST", basePath, {
164
- command,
165
- args,
166
- ...(workspace ? { workspace } : {}),
167
- ...(env && Object.keys(env).length > 0 ? { env } : {}),
168
- // Deliberately short, and independent of the overall deadline: that is
169
- // owned by the poll loop below. Handing the daemon the full budget would
170
- // make one HTTP request block for it and leave the loop unreachable.
171
- timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
172
- }, fetchImpl);
173
-
169
+ let run = initialRun;
174
170
  let pollError = null;
175
- while (run?.status === "running" && now() < deadline) {
171
+ while (run?.status === "running" && now() < deadline && !signal?.aborted) {
176
172
  if (!run.runId) {
177
173
  pollError = new Error("daemon reported a running command but returned no runId");
178
174
  break;
@@ -211,7 +207,47 @@ export async function execRemote(config, target, command, options = {}) {
211
207
  }
212
208
  }
213
209
 
214
- return { run, pollError, killError, basePath };
210
+ return { run, pollError, killError };
211
+ }
212
+
213
+ /**
214
+ * Run one command on `target` and return the final run record.
215
+ *
216
+ * This is the programmatic half of `runRemoteExec`: same two-phase POST-then-poll
217
+ * behaviour, no argv parsing and no printing. `remote cp -r` uses it to drive
218
+ * `tar` on the target.
219
+ */
220
+ export async function execRemote(config, target, command, options = {}) {
221
+ const {
222
+ args = [],
223
+ workspace,
224
+ env,
225
+ timeoutMs = DEFAULT_TIMEOUT_MS,
226
+ fetchImpl = globalThis.fetch,
227
+ sleep = delay,
228
+ now = () => Date.now(),
229
+ killOnTimeout = false,
230
+ signal,
231
+ } = options;
232
+
233
+ const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
234
+ const deadline = now() + timeoutMs;
235
+
236
+ const started = await withRetry(() => callApi(config, "POST", basePath, {
237
+ command,
238
+ args,
239
+ ...(workspace ? { workspace } : {}),
240
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
241
+ // Deliberately short, and independent of the overall deadline: that is
242
+ // owned by the poll loop below. Handing the daemon the full budget would
243
+ // make one HTTP request block for it and leave the loop unreachable.
244
+ timeoutMs: Math.min(timeoutMs, POST_WAIT_MS),
245
+ }, fetchImpl), { ...POST_RETRY, sleep });
246
+
247
+ const waited = await waitForRun(config, basePath, started, {
248
+ deadline, fetchImpl, sleep, now, killOnTimeout, signal,
249
+ });
250
+ return { ...waited, basePath };
215
251
  }
216
252
 
217
253
  /**
@@ -229,6 +265,60 @@ export async function execRemoteOrThrow(config, target, command, options = {}) {
229
265
  throw new Error(detail ? `${what} on ${target}: ${detail}` : `${what} on ${target}`);
230
266
  }
231
267
 
268
+ /**
269
+ * An AbortSignal that fires on Ctrl-C / SIGTERM. Installing a listener also
270
+ * stops Node from exiting on the signal, which is the point: the CLI gets to
271
+ * print the run id (and cancel the run with --kill-on-timeout) before it goes.
272
+ * Callers must `release()` so the default behaviour comes back.
273
+ */
274
+ export function interruptSignal(proc = process) {
275
+ const controller = new AbortController();
276
+ const onSignal = () => controller.abort();
277
+ for (const name of INTERRUPT_SIGNALS) proc.on(name, onSignal);
278
+ return {
279
+ signal: controller.signal,
280
+ release: () => {
281
+ for (const name of INTERRUPT_SIGNALS) proc.off(name, onSignal);
282
+ },
283
+ };
284
+ }
285
+
286
+ /** Print a finished (or abandoned) run the way `exec` and `wait` both do, and pick the exit code. */
287
+ export function reportRun(run, { consoleImpl, json, target, pollError, waitedMs, interrupted }) {
288
+ if (json) {
289
+ consoleImpl.log(JSON.stringify(run, null, 2));
290
+ } else {
291
+ if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
292
+ if (run?.stderrTail) process.stderr.write(run.stderrTail);
293
+ if (run?.truncated) {
294
+ consoleImpl.error(`[conductor] output truncated; showing the tail only`);
295
+ }
296
+ if (run?.error) {
297
+ consoleImpl.error(`Error: ${run.error}`);
298
+ }
299
+ }
300
+
301
+ if (run?.status === "running") {
302
+ if (pollError) {
303
+ consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
304
+ }
305
+ const why = interrupted ? "interrupted" : `still running on ${target} after ${waitedMs}ms`;
306
+ consoleImpl.error(
307
+ `[conductor] ${why}; it keeps going on ${target} — ` +
308
+ `resume with: conductor remote wait -t ${target} ${run.runId} ` +
309
+ `(add --kill-on-timeout to stop it instead)`,
310
+ );
311
+ return EXIT.CLI_ERROR;
312
+ }
313
+ if (run?.status === "cancelled") {
314
+ return EXIT.CLI_ERROR;
315
+ }
316
+ if (typeof run?.exitCode === "number") {
317
+ return run.exitCode;
318
+ }
319
+ return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
320
+ }
321
+
232
322
  export async function runRemoteExec(argv, deps = {}) {
233
323
  const consoleImpl = deps.console || console;
234
324
  const fetchImpl = deps.fetch || globalThis.fetch;
@@ -278,12 +368,12 @@ export async function runRemoteExec(argv, deps = {}) {
278
368
  return EXIT.CLI_ERROR;
279
369
  }
280
370
 
371
+ const interrupt = interruptSignal(deps.process);
281
372
  let run;
282
373
  let pollError = null;
283
- let basePath;
284
374
  try {
285
375
  let killError;
286
- ({ run, pollError, killError, basePath } = await execRemote(config, target, command[0], {
376
+ ({ run, pollError, killError } = await execRemote(config, target, command[0], {
287
377
  args: command.slice(1),
288
378
  workspace: options.workspace,
289
379
  env: options.env,
@@ -292,46 +382,27 @@ export async function runRemoteExec(argv, deps = {}) {
292
382
  sleep,
293
383
  now,
294
384
  killOnTimeout: options.killOnTimeout,
385
+ signal: interrupt.signal,
295
386
  }));
296
387
  if (killError) {
297
388
  consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
298
389
  } else if (options.killOnTimeout && run?.status === "cancelled") {
299
- consoleImpl.error(`[conductor] deadline reached; stopped the command on ${target}`);
390
+ const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
391
+ consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
300
392
  }
301
393
  } catch (error) {
302
394
  consoleImpl.error(`Error: ${error.message}`);
303
395
  return EXIT.CLI_ERROR;
396
+ } finally {
397
+ interrupt.release();
304
398
  }
305
399
 
306
- if (options.json) {
307
- consoleImpl.log(JSON.stringify(run, null, 2));
308
- } else {
309
- if (run?.stdoutTail) process.stdout.write(run.stdoutTail);
310
- if (run?.stderrTail) process.stderr.write(run.stderrTail);
311
- if (run?.truncated) {
312
- consoleImpl.error(`[conductor] output truncated; showing the tail only`);
313
- }
314
- if (run?.error) {
315
- consoleImpl.error(`Error: ${run.error}`);
316
- }
317
- }
318
-
319
- if (run?.status === "running") {
320
- if (pollError) {
321
- consoleImpl.error(`[conductor] last status poll failed: ${pollError.message}`);
322
- }
323
- consoleImpl.error(
324
- `[conductor] still running on ${target} after ${timeoutMs}ms; ` +
325
- `it keeps going there — poll GET ${basePath}/runs/${run.runId}, ` +
326
- `stop it with DELETE on the same path, or use --kill-on-timeout`,
327
- );
328
- return EXIT.CLI_ERROR;
329
- }
330
- if (run?.status === "cancelled") {
331
- return EXIT.CLI_ERROR;
332
- }
333
- if (typeof run?.exitCode === "number") {
334
- return run.exitCode;
335
- }
336
- return run?.status === "completed" ? EXIT.OK : EXIT.CLI_ERROR;
400
+ return reportRun(run, {
401
+ consoleImpl,
402
+ json: options.json,
403
+ target,
404
+ pollError,
405
+ waitedMs: timeoutMs,
406
+ interrupted: interrupt.signal.aborted,
407
+ });
337
408
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `conductor remote wait` — pick up a command started by `remote exec` that
3
+ * outlived its deadline (or the process that started it).
4
+ *
5
+ * `exec` prints the run id whenever it leaves a command behind; this verb
6
+ * polls that run the same way `exec` would have and reports it identically,
7
+ * so a caller can always finish what it started.
8
+ */
9
+
10
+ import process from "node:process";
11
+
12
+ import { EXIT, callApi, delay, loadCliConfig } from "./client.js";
13
+ import {
14
+ interruptSignal,
15
+ parseArgs,
16
+ parseTimeoutMs,
17
+ reportRun,
18
+ waitForRun,
19
+ } from "./exec.js";
20
+
21
+ export function showHelp(consoleImpl = console) {
22
+ consoleImpl.log(`conductor remote wait - wait for a command left running by \`remote exec\`
23
+
24
+ Usage:
25
+ conductor remote wait --target <daemon> [options] <runId>
26
+
27
+ Options:
28
+ -t, --target <daemon> Daemon the command is running on (required)
29
+ --timeout <dur> How long to wait this time, e.g. 30s, 2m (default: 60s)
30
+ --json Print the raw run result as JSON
31
+ --kill-on-timeout Stop the remote command when --timeout is reached or
32
+ this CLI is interrupted (default: it keeps running)
33
+ --config-file <p> Conductor config file to authenticate with
34
+ -h, --help Show this help
35
+
36
+ Output and exit codes match \`conductor remote exec\`: the command's own exit
37
+ code is passed through, 255 means this CLI failed or the command is still running.
38
+
39
+ Example:
40
+ conductor remote exec -t ubuntu --timeout 30s -- make build
41
+ # ... [conductor] still running on ubuntu after 30000ms; resume with:
42
+ conductor remote wait -t ubuntu --timeout 20m 3f9c1b2e-...
43
+ `);
44
+ }
45
+
46
+ export async function runRemoteWait(argv, deps = {}) {
47
+ const consoleImpl = deps.console || console;
48
+ const fetchImpl = deps.fetch || globalThis.fetch;
49
+ const env = deps.env || process.env;
50
+ const sleep = deps.sleep || delay;
51
+ const now = deps.now || (() => Date.now());
52
+
53
+ let parsed;
54
+ try {
55
+ parsed = parseArgs(argv);
56
+ } catch (error) {
57
+ consoleImpl.error(`Error: ${error.message}`);
58
+ return EXIT.CLI_ERROR;
59
+ }
60
+ const { options, command } = parsed;
61
+
62
+ if (options.help) {
63
+ showHelp(consoleImpl);
64
+ return EXIT.OK;
65
+ }
66
+
67
+ const target = typeof options.target === "string" ? options.target.trim() : "";
68
+ if (!target) {
69
+ consoleImpl.error("Error: --target <daemon> is required");
70
+ showHelp(consoleImpl);
71
+ return EXIT.CLI_ERROR;
72
+ }
73
+ if (command.length !== 1) {
74
+ consoleImpl.error(command.length === 0 ? "Error: no runId given" : "Error: expected exactly one runId");
75
+ showHelp(consoleImpl);
76
+ return EXIT.CLI_ERROR;
77
+ }
78
+ const runId = command[0];
79
+
80
+ let timeoutMs;
81
+ try {
82
+ timeoutMs = parseTimeoutMs(options.timeout);
83
+ } catch (error) {
84
+ consoleImpl.error(`Error: ${error.message}`);
85
+ return EXIT.CLI_ERROR;
86
+ }
87
+
88
+ let config;
89
+ try {
90
+ config = deps.config || loadCliConfig(options.configFile, env);
91
+ } catch (error) {
92
+ consoleImpl.error(`Error: ${error.message}`);
93
+ return EXIT.CLI_ERROR;
94
+ }
95
+
96
+ const basePath = `/api/agents/${encodeURIComponent(target)}/exec`;
97
+ const interrupt = interruptSignal(deps.process);
98
+ let run;
99
+ let pollError = null;
100
+ try {
101
+ const deadline = now() + timeoutMs;
102
+ run = await callApi(config, "GET", `${basePath}/runs/${encodeURIComponent(runId)}`, null, fetchImpl);
103
+ let killError;
104
+ ({ run, pollError, killError } = await waitForRun(config, basePath, run, {
105
+ deadline,
106
+ fetchImpl,
107
+ sleep,
108
+ now,
109
+ killOnTimeout: options.killOnTimeout,
110
+ signal: interrupt.signal,
111
+ }));
112
+ if (killError) {
113
+ consoleImpl.error(`[conductor] failed to stop the run on ${target}: ${killError.message}`);
114
+ } else if (options.killOnTimeout && run?.status === "cancelled") {
115
+ const why = interrupt.signal.aborted ? "interrupted" : "deadline reached";
116
+ consoleImpl.error(`[conductor] ${why}; stopped the command on ${target}`);
117
+ }
118
+ } catch (error) {
119
+ consoleImpl.error(`Error: ${error.message}`);
120
+ return EXIT.CLI_ERROR;
121
+ } finally {
122
+ interrupt.release();
123
+ }
124
+
125
+ return reportRun(run, {
126
+ consoleImpl,
127
+ json: options.json,
128
+ target,
129
+ pollError,
130
+ waitedMs: timeoutMs,
131
+ interrupted: interrupt.signal.aborted,
132
+ });
133
+ }
@@ -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
  }