@love-moon/conductor-cli 0.11.0 → 0.11.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/src/daemon.js CHANGED
@@ -24,6 +24,13 @@ import {
24
24
  resolveConductorConfigPath,
25
25
  resolveConductorHome,
26
26
  } from "./conductor-paths.js";
27
+ import {
28
+ deleteFireSessionRecord,
29
+ pruneFireSessionRecords,
30
+ readFireSessionRecord,
31
+ resolveFireSessionRegistryDir,
32
+ writeFireSessionRecord,
33
+ } from "./fire-session-registry.js";
27
34
  import { createAiManagerHandlers, handleAiManagerRequest } from "./ai-manager-handlers.js";
28
35
  import {
29
36
  CUSTOM_COMMANDS_CAPABILITY,
@@ -35,6 +42,12 @@ import {
35
42
  createRemoteExecHandlers,
36
43
  handleRemoteExecRequest,
37
44
  } from "./remote-exec-handlers.js";
45
+ import {
46
+ UPDATE_DAEMON_CAPABILITY,
47
+ createDaemonUpdateHandlers,
48
+ handleUpdateDaemonRequest,
49
+ resolveDaemonUpdatePaths,
50
+ } from "./daemon-update.js";
38
51
  import { resolveResumeContext } from "./fire/resume.js";
39
52
  import {
40
53
  filterRuntimeSupportedAllowCliList,
@@ -106,6 +119,7 @@ const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
106
119
  // the host daemon, so guests always match the installed version and there is
107
120
  // no second update path to keep in sync.
108
121
  const DAEMON_LAUNCHER_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon.js");
122
+ const DAEMON_UPDATE_SCRIPT_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon-update.js");
109
123
  const CLI_VERSION = (() => {
110
124
  try {
111
125
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
@@ -1187,6 +1201,16 @@ export function startDaemon(config = {}, deps = {}) {
1187
1201
  log("Fire tmux mode enabled: each Fire process will run in a detached tmux session");
1188
1202
  }
1189
1203
 
1204
+ // Shared by every Fire tmux session name. Also the discriminator that lets
1205
+ // startup adoption tell our sessions apart from the user's own tmux work.
1206
+ const FIRE_TMUX_SESSION_NAMESPACE = "conductor-fire-";
1207
+
1208
+ // Where the per-session hand-off records live. See fire-session-registry.js
1209
+ // for why a fresh daemon cannot reconstruct them from the session alone.
1210
+ const FIRE_SESSION_REGISTRY_DIR = resolveFireSessionRegistryDir(
1211
+ materializedConductorPathEnv.CONDUCTOR_HOME,
1212
+ );
1213
+
1190
1214
  // Single-quote a value so it can be embedded inside a `bash -c '...'`
1191
1215
  // command. Embedded single-quotes are escaped via the standard `'\\''`
1192
1216
  // sequence.
@@ -1211,7 +1235,7 @@ export function startDaemon(config = {}, deps = {}) {
1211
1235
  .replace(/[^a-zA-Z0-9_-]/g, "_")
1212
1236
  .replace(/^_+|_+$/g, "")
1213
1237
  .slice(0, 32) || "task";
1214
- return `conductor-fire-${safe}-`;
1238
+ return `${FIRE_TMUX_SESSION_NAMESPACE}${safe}-`;
1215
1239
  }
1216
1240
 
1217
1241
  // Build a unique tmux session name for a Fire spawn.
@@ -1754,7 +1778,14 @@ export function startDaemon(config = {}, deps = {}) {
1754
1778
  // would be misread as "died hard" — reporting every clean finish as
1755
1779
  // KILLED. Staying silent restores the old blind spot for this task only,
1756
1780
  // which is strictly better than manufacturing a wrong cause of death.
1757
- if (!record.logPath) {
1781
+ //
1782
+ // The one exception is a Fire adopted without a hand-off record. There
1783
+ // the silence is not a blind spot we are preserving but one we would be
1784
+ // creating: nothing else watches an adopted session, so staying quiet
1785
+ // parks the task at `running` forever. Speak, and let the backend
1786
+ // pre-check below stop us from downgrading a status fire already
1787
+ // published for itself.
1788
+ if (!record.logPath && !record.adoptedWithoutMetadata) {
1758
1789
  logError(
1759
1790
  `Cannot classify the end of task ${taskId}: no log path on the active record, so no exit marker exists`,
1760
1791
  );
@@ -1767,7 +1798,7 @@ export function startDaemon(config = {}, deps = {}) {
1767
1798
  // that absence as "no exit marker" would report a green task as KILLED
1768
1799
  // with an invented "killed or OOM" cause. An empty-but-present file is
1769
1800
  // different: the fire really did die before writing anything.
1770
- if (readLogFileSize(record.logPath) === null) {
1801
+ if (!record.adoptedWithoutMetadata && readLogFileSize(record.logPath) === null) {
1771
1802
  logError(
1772
1803
  `Cannot classify the end of task ${taskId}: log file ${record.logPath} is missing, ` +
1773
1804
  `so the absence of an exit marker proves nothing`,
@@ -1775,7 +1806,9 @@ export function startDaemon(config = {}, deps = {}) {
1775
1806
  return;
1776
1807
  }
1777
1808
 
1778
- const rawTail = readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
1809
+ const rawTail = record.adoptedWithoutMetadata
1810
+ ? ""
1811
+ : readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
1779
1812
  // Search the file for the marker rather than hoping it is still inside
1780
1813
  // the tail window — see `findFireExitCode` for why a shared log makes the
1781
1814
  // tail unreliable. The tail is still what feeds the human-readable
@@ -1799,8 +1832,9 @@ export function startDaemon(config = {}, deps = {}) {
1799
1832
  // `latest_status_summary` with a word that says nothing. Naming *how* we
1800
1833
  // found out keeps the event worth its row: it tells the next reader that
1801
1834
  // fire never published its own status.
1802
- const baseSummary =
1803
- exitCode === null
1835
+ const baseSummary = record.adoptedWithoutMetadata
1836
+ ? "fire tmux session ended; it was adopted from a previous daemon without a hand-off record, so the exit could not be classified"
1837
+ : exitCode === null
1804
1838
  ? "fire tmux session disappeared without an exit marker (killed or OOM)"
1805
1839
  : exitCode === 0
1806
1840
  ? "completed; detected by the daemon after the tmux session ended (fire never reported it)"
@@ -1811,6 +1845,19 @@ export function startDaemon(config = {}, deps = {}) {
1811
1845
  // Ask the backend before overwriting anything: fire normally reports its
1812
1846
  // own terminal status and we must not downgrade a success.
1813
1847
  const backendStatus = await fetchBackendTaskStatus(taskId);
1848
+ // For a record adopted without metadata there is no exit marker to read,
1849
+ // so the verdict below is unconditionally KILLED and this probe is the
1850
+ // ONLY thing standing between a task that finished cleanly and a
1851
+ // fabricated failure. `fetchBackendTaskStatus` returns null both for "not
1852
+ // terminal" and for "could not ask", so a backend blip during a daemon
1853
+ // upgrade would mark a green task killed. Require a real answer.
1854
+ if (!backendStatus && record.adoptedWithoutMetadata) {
1855
+ logError(
1856
+ `Cannot classify the end of adopted task ${taskId}: the backend did not answer, ` +
1857
+ `and without a hand-off record its exit code is unknowable. Staying silent.`,
1858
+ );
1859
+ return;
1860
+ }
1814
1861
  if (backendStatus && TERMINAL_BACKEND_TASK_STATUSES.has(backendStatus)) {
1815
1862
  log(
1816
1863
  `Tmux session ${record.tmuxSession} for task ${taskId} ended; backend already ${backendStatus}, no report needed`,
@@ -1925,6 +1972,7 @@ export function startDaemon(config = {}, deps = {}) {
1925
1972
  record.stopForceKillTimer = null;
1926
1973
  }
1927
1974
  activeTaskProcesses.delete(taskId);
1975
+ forgetFireSessionRecord(record);
1928
1976
  // Report *after* dropping the local entry so a slow backend round
1929
1977
  // trip can't make a concurrent stop_task wait on us.
1930
1978
  await reportDeadTmuxSessionStatus(taskId, record);
@@ -1956,22 +2004,27 @@ export function startDaemon(config = {}, deps = {}) {
1956
2004
  }
1957
2005
  }
1958
2006
 
1959
- // List every tmux session name visible to the current user. Returns `[]`
1960
- // on any spawn error, non-zero exit, or timeout (e.g. tmux not available
1961
- // or the server not running). Subject to TMUX_PROBE_TIMEOUT_MS.
2007
+ // List every tmux session name visible to the current user.
2008
+ //
2009
+ // Returns `{ sessions, conclusive }`. The flag is not decoration: "tmux says
2010
+ // there are no sessions" and "we could not ask tmux" are the same empty
2011
+ // array, and callers that kill on the strength of an empty list would turn
2012
+ // one flaky probe into a mass kill of live Fires — the very regression the
2013
+ // adoption path exists to prevent. `probeTmuxSession` draws the same
2014
+ // distinction for the same reason. Subject to TMUX_PROBE_TIMEOUT_MS.
1962
2015
  function listAllTmuxSessions() {
1963
2016
  return new Promise((resolve) => {
1964
2017
  let settled = false;
1965
2018
  let stdout = "";
1966
2019
  let timer = null;
1967
- const settle = (lines) => {
2020
+ const settle = (lines, conclusive) => {
1968
2021
  if (settled) return;
1969
2022
  settled = true;
1970
2023
  if (timer) {
1971
2024
  clearTimeout(timer);
1972
2025
  timer = null;
1973
2026
  }
1974
- resolve(lines);
2027
+ resolve({ sessions: lines, conclusive });
1975
2028
  };
1976
2029
  try {
1977
2030
  const probe = spawnFn("tmux", ["list-sessions", "-F", "#{session_name}"], {
@@ -1989,14 +2042,16 @@ export function startDaemon(config = {}, deps = {}) {
1989
2042
  .split("\n")
1990
2043
  .map((s) => s.trim())
1991
2044
  .filter(Boolean),
2045
+ true,
1992
2046
  );
1993
2047
  } else {
1994
- // Non-zero exit usually means "no server running" or "no
1995
- // sessions" — both fine, just return empty.
1996
- settle([]);
2048
+ // Non-zero exit means "no server running" / "no sessions". tmux
2049
+ // answered, so this is a real, conclusive empty list.
2050
+ settle([], true);
1997
2051
  }
1998
2052
  });
1999
- probe.on("error", () => settle([]));
2053
+ // Could not run tmux at all — no information either way.
2054
+ probe.on("error", () => settle([], false));
2000
2055
  timer = setTimeout(() => {
2001
2056
  try {
2002
2057
  if (typeof probe.kill === "function") {
@@ -2006,13 +2061,13 @@ export function startDaemon(config = {}, deps = {}) {
2006
2061
  // ignore; we already settled
2007
2062
  }
2008
2063
  logError(`tmux list-sessions probe timed out after ${TMUX_PROBE_TIMEOUT_MS}ms`);
2009
- settle([]);
2064
+ settle([], false);
2010
2065
  }, TMUX_PROBE_TIMEOUT_MS);
2011
2066
  if (typeof timer.unref === "function") {
2012
2067
  timer.unref();
2013
2068
  }
2014
2069
  } catch {
2015
- settle([]);
2070
+ settle([], false);
2016
2071
  }
2017
2072
  });
2018
2073
  }
@@ -2027,17 +2082,288 @@ export function startDaemon(config = {}, deps = {}) {
2027
2082
  async function killTmuxSessionsForDeletedTask(taskId) {
2028
2083
  if (!FIRE_TMUX_MODE_ACTIVE || !taskId) return 0;
2029
2084
  const prefix = buildFireTmuxSessionPrefix(taskId);
2030
- const sessions = await listAllTmuxSessions();
2085
+ const { sessions } = await listAllTmuxSessions();
2031
2086
  let killed = 0;
2032
2087
  for (const name of sessions) {
2033
2088
  if (name.startsWith(prefix)) {
2034
2089
  log(`Killing orphaned tmux session ${name} for deleted task ${taskId}`);
2035
2090
  killFireTmuxSession(name);
2091
+ forgetFireSessionRecord({ tmuxSession: name });
2036
2092
  killed += 1;
2037
2093
  }
2038
2094
  }
2039
2095
  return killed;
2040
2096
  }
2097
+
2098
+ // --- tmux Fire hand-off across daemon restarts ---------------------------
2099
+ //
2100
+ // Shutdown in tmux mode deliberately leaves Fires running, but a fresh
2101
+ // daemon starts with an empty `activeTaskProcesses` by definition. Both
2102
+ // stale-task sweeps decide "this task is dead" from that map alone, so
2103
+ // without the adoption path below every survivor of the hand-off is
2104
+ // PATCHed to `killed` seconds after the new daemon connects — destroying
2105
+ // the exact work the hand-off exists to preserve. Regression history:
2106
+ // 2026-07-23, 2026-07-31, 2026-08-31.
2107
+
2108
+ // Persist what a successor daemon needs to adopt this session. Best-effort:
2109
+ // failing here costs post-restart fidelity, never the running Fire.
2110
+ function rememberFireSessionRecord(taskId, record) {
2111
+ if (!FIRE_TMUX_MODE_ACTIVE || !record?.tmuxSession) return;
2112
+ try {
2113
+ writeFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, {
2114
+ taskId,
2115
+ projectId: record.projectId,
2116
+ tmuxSession: record.tmuxSession,
2117
+ logPath: record.logPath,
2118
+ logStartOffset: record.logStartOffset,
2119
+ exitMarkerToken: record.exitMarkerToken,
2120
+ spawnedAtMs: record.spawnedAtMs,
2121
+ daemonName: AGENT_NAME,
2122
+ });
2123
+ } catch (error) {
2124
+ logError(
2125
+ `Failed to persist tmux hand-off record for task ${taskId}: ${error?.message || error}`,
2126
+ );
2127
+ }
2128
+ }
2129
+
2130
+ // Drop the hand-off record once the session it describes is provably gone.
2131
+ // `pruneFireSessionRecords` at startup is the backstop for the paths that
2132
+ // never get to run (SIGKILL, power loss).
2133
+ function forgetFireSessionRecord(record) {
2134
+ if (!record?.tmuxSession) return;
2135
+ try {
2136
+ deleteFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, record.tmuxSession);
2137
+ } catch {
2138
+ // best effort; the startup prune will collect it
2139
+ }
2140
+ }
2141
+
2142
+ function readFireSessionRecordSafe(sessionName) {
2143
+ try {
2144
+ return readFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, sessionName);
2145
+ } catch {
2146
+ return null;
2147
+ }
2148
+ }
2149
+
2150
+ // A session can outlive the Fire inside it (a wrapper shell that never
2151
+ // exited, a pane held open by `remain-on-exit`). Adopting one of those is
2152
+ // worse than killing it: the reaper only ever speaks when a session
2153
+ // disappears, so the task would sit at `running` with nobody left to report
2154
+ // its death. The hand-off record's exit marker is the only evidence that
2155
+ // distinguishes the two, which is why this can only be checked for sessions
2156
+ // we have a record for. Returns the fire's exit code, or null when it is
2157
+ // still running (or when we cannot tell).
2158
+ function readAdoptedFireExitCode(sidecar) {
2159
+ if (!sidecar?.logPath || !sidecar?.exitMarkerToken) return null;
2160
+ return findFireExitCode(sidecar.logPath, {
2161
+ minOffset: sidecar.logStartOffset,
2162
+ markerToken: sidecar.exitMarkerToken,
2163
+ });
2164
+ }
2165
+
2166
+ // `FIRE_TMUX_MODE_ACTIVE` is a one-shot startup snapshot (`tmux -V` via
2167
+ // spawnSync). If that probe failed transiently — fork pressure, a PATH blip
2168
+ // — this process spends its ENTIRE life believing tmux is absent, so both
2169
+ // stale sweeps would skip the tmux lookup entirely and kill every Fire the
2170
+ // previous daemon left in a tmux server that is running perfectly well.
2171
+ // That is the same mass kill by a different door.
2172
+ //
2173
+ // Re-probe before killing. We deliberately do NOT adopt in this state: this
2174
+ // process spawns fires directly and never started the tmux liveness reaper,
2175
+ // so an adopted record would have no watcher. Declining to destroy is
2176
+ // enough — the next restart with a healthy probe adopts them properly.
2177
+ function tmuxFiresMayExistUnseen() {
2178
+ return FIRE_TMUX_MODE_ENABLED && !FIRE_TMUX_MODE_ACTIVE && isTmuxAvailable();
2179
+ }
2180
+
2181
+ // Sessions already identified as orphaned shells and killed. `tmux
2182
+ // kill-session` is asynchronous, so a sweep running moments later still
2183
+ // sees the name in `list-sessions` — and by then the hand-off record is
2184
+ // gone, which would make it look like a live Fire with no metadata and put
2185
+ // the task straight back to `running`.
2186
+ const retiredOrphanFireSessions = new Set();
2187
+
2188
+ // Shared by both adoption entry points: kill the shell, drop the record and
2189
+ // report "not alive" so the caller falls through to its normal dead-task
2190
+ // handling.
2191
+ function retireOrphanedFireSession(sessionName, taskId, exitCode) {
2192
+ retiredOrphanFireSessions.add(sessionName);
2193
+ log(
2194
+ `Tmux session ${sessionName} outlived its Fire for task ${taskId} ` +
2195
+ `(exit marker code=${exitCode}); killing the orphaned shell`,
2196
+ );
2197
+ killFireTmuxSession(sessionName);
2198
+ forgetFireSessionRecord({ tmuxSession: sessionName });
2199
+ }
2200
+
2201
+ // Build an `activeTaskProcesses` entry for a tmux session this daemon did
2202
+ // not spawn. `child` is deliberately absent: the `tmux new-session` client
2203
+ // belonged to the previous daemon and exited long ago.
2204
+ //
2205
+ // This is the one record shape where a truthy `child` does NOT mean "a fire
2206
+ // is running here" — so any code asking that question must go through
2207
+ // `recordHasLiveFire`, never `record.child`. Getting it wrong is not
2208
+ // cosmetic: the restart/create gates read as "nothing running" and spawn a
2209
+ // second fire into a worktree that already has one.
2210
+ function buildAdoptedTmuxRecord({ sessionName, projectId, sidecar }) {
2211
+ return {
2212
+ child: null,
2213
+ projectId: sidecar?.projectId || String(projectId || ""),
2214
+ logPath: sidecar?.logPath || "",
2215
+ logStartOffset: Number(sidecar?.logStartOffset) || 0,
2216
+ exitMarkerToken: sidecar?.exitMarkerToken || "",
2217
+ // Without a record we have no real spawn time. Anchoring at "now" costs
2218
+ // one reaper grace period of delay and is far safer than a 0, which
2219
+ // would make the record instantly reapable.
2220
+ spawnedAtMs: Number(sidecar?.spawnedAtMs) || Date.now(),
2221
+ stopForceKillTimer: null,
2222
+ managedByFireBridge: true,
2223
+ tmuxSession: sessionName,
2224
+ tmuxMode: true,
2225
+ adopted: true,
2226
+ // No record => no logPath and no marker nonce, so the reaper cannot
2227
+ // classify this fire's exit from evidence. Flagged so it reports an
2228
+ // honest KILLED instead of taking the "cannot classify" silent exit,
2229
+ // which would strand the task at `running` with nobody left to speak
2230
+ // for it.
2231
+ adoptedWithoutMetadata: !sidecar,
2232
+ };
2233
+ }
2234
+
2235
+ // Adopt the live tmux Fire belonging to `task`, if there is one. Returns
2236
+ // true when the task must NOT be treated as stale. Called from both kill
2237
+ // paths, which is also the only place a session whose hand-off record is
2238
+ // missing can be adopted at all: the task id is not recoverable from a
2239
+ // session name, but the backend just told us which ids to look for.
2240
+ function adoptLiveTmuxFireForTask(task, sessionNames) {
2241
+ const taskId = String(task?.id || "");
2242
+ if (!taskId) return false;
2243
+ if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) return true;
2244
+ if (!FIRE_TMUX_MODE_ACTIVE) return false;
2245
+ const prefix = buildFireTmuxSessionPrefix(taskId);
2246
+ const candidates = (sessionNames || []).filter(
2247
+ (name) => name.startsWith(prefix) && !retiredOrphanFireSessions.has(name),
2248
+ );
2249
+ if (candidates.length === 0) return false;
2250
+ // Session names truncate the task id to 32 chars, so a record could in
2251
+ // principle have been filed for a different task sharing that prefix.
2252
+ // Trust it only when it names the task we are actually rescuing.
2253
+ const recordFor = (name) => {
2254
+ const stored = readFireSessionRecordSafe(name);
2255
+ return stored && stored.taskId === taskId ? stored : null;
2256
+ };
2257
+ // With more than one candidate (an abandoned session left beside a
2258
+ // respawn) prefer the one we have a record for: adopting blind would pick
2259
+ // by tmux's list order and could watch the wrong session's exit marker.
2260
+ const sessionName = candidates.find((name) => recordFor(name)) || candidates[0];
2261
+ const sidecar = recordFor(sessionName);
2262
+ const exitCode = readAdoptedFireExitCode(sidecar);
2263
+ if (exitCode !== null) {
2264
+ retireOrphanedFireSession(sessionName, taskId, exitCode);
2265
+ return false;
2266
+ }
2267
+ activeTaskProcesses.set(
2268
+ taskId,
2269
+ buildAdoptedTmuxRecord({ sessionName, projectId: task?.project_id, sidecar }),
2270
+ );
2271
+ log(
2272
+ `Task ${taskId} still has a live tmux Fire (session=${sessionName}); adopted instead of killed` +
2273
+ (sidecar ? "" : " — no hand-off record, so its exit can only be classified as killed"),
2274
+ );
2275
+ return true;
2276
+ }
2277
+
2278
+ // Resolves once the startup adoption pass has finished. Both stale sweeps
2279
+ // await it so they can never judge a task before its Fire has had the
2280
+ // chance to be reclaimed.
2281
+ let adoptOrphanedTmuxFiresPromise = null;
2282
+
2283
+ // Startup pass: adopt every Fire session the previous daemon left behind,
2284
+ // then discard hand-off records whose session is gone.
2285
+ async function adoptOrphanedTmuxFires() {
2286
+ if (!FIRE_TMUX_MODE_ACTIVE) return 0;
2287
+ const { sessions, conclusive } = await listAllTmuxSessions();
2288
+ if (!conclusive) {
2289
+ // We could not ask tmux. Adopting nothing is survivable — both stale
2290
+ // sweeps re-probe and adopt whatever they find. Pruning is not: the
2291
+ // prune below deletes every record whose session is absent from this
2292
+ // list, so running it on a blind listing would wipe the
2293
+ // `exitMarkerToken`s of every live Fire on the host, permanently
2294
+ // downgrading them all to `adoptedWithoutMetadata`.
2295
+ logError(
2296
+ "Could not list tmux sessions for Fire adoption; adopting nothing and keeping every hand-off record",
2297
+ );
2298
+ return 0;
2299
+ }
2300
+
2301
+ let adopted = 0;
2302
+ for (const sessionName of sessions) {
2303
+ if (!sessionName.startsWith(FIRE_TMUX_SESSION_NAMESPACE)) continue;
2304
+ if (retiredOrphanFireSessions.has(sessionName)) continue;
2305
+ const sidecar = readFireSessionRecordSafe(sessionName);
2306
+ // No record: we cannot learn the task id from the session name (it is
2307
+ // truncated to 32 chars). Leave it — the kill paths adopt it later,
2308
+ // once the backend supplies the id.
2309
+ if (!sidecar) continue;
2310
+
2311
+ if (pendingTaskStarts.has(sidecar.taskId)) {
2312
+ // A `create_task` for this task is mid-flight and will install its own
2313
+ // record when the spawn completes. Adopting now just gets overwritten
2314
+ // moments later, which is how a session ends up orphaned with its
2315
+ // record still on disk.
2316
+ continue;
2317
+ }
2318
+
2319
+ const tracked = activeTaskProcesses.get(sidecar.taskId);
2320
+ if (tracked) {
2321
+ // A `create_task`/`restart_task` redelivered into the adoption window
2322
+ // can spawn a second session for the same task before we get here.
2323
+ // Keeping both records is the dangerous part: on the NEXT restart
2324
+ // adoption would pick between them by tmux's arbitrary list order and
2325
+ // could end up watching the abandoned session's exit marker while the
2326
+ // live fire's death goes unreported. Drop the loser's record so the
2327
+ // task has exactly one, and say so loudly — two fires in one worktree
2328
+ // is a real problem, just not one we can safely resolve by killing.
2329
+ if (tracked.tmuxSession !== sessionName) {
2330
+ logError(
2331
+ `Task ${sidecar.taskId} has a second tmux session ${sessionName} besides the tracked ` +
2332
+ `${tracked.tmuxSession || "(none)"}; discarding its hand-off record. Two fires may be ` +
2333
+ `sharing one worktree — inspect and kill the stale session manually.`,
2334
+ );
2335
+ forgetFireSessionRecord({ tmuxSession: sessionName });
2336
+ }
2337
+ continue;
2338
+ }
2339
+
2340
+ const exitCode = readAdoptedFireExitCode(sidecar);
2341
+ if (exitCode !== null) {
2342
+ retireOrphanedFireSession(sessionName, sidecar.taskId, exitCode);
2343
+ continue;
2344
+ }
2345
+
2346
+ activeTaskProcesses.set(
2347
+ sidecar.taskId,
2348
+ buildAdoptedTmuxRecord({ sessionName, projectId: sidecar.projectId, sidecar }),
2349
+ );
2350
+ adopted += 1;
2351
+ log(
2352
+ `Adopted tmux Fire task ${sidecar.taskId} (session=${sessionName}) left running by a previous daemon`,
2353
+ );
2354
+ }
2355
+
2356
+ try {
2357
+ pruneFireSessionRecords(FIRE_SESSION_REGISTRY_DIR, sessions);
2358
+ } catch (error) {
2359
+ logError(`Failed to prune tmux hand-off records: ${error?.message || error}`);
2360
+ }
2361
+
2362
+ if (adopted > 0) {
2363
+ log(`Adopted ${adopted} tmux Fire task(s) from a previous daemon`);
2364
+ }
2365
+ return adopted;
2366
+ }
2041
2367
  // -------------------------------------------------------------------------
2042
2368
 
2043
2369
  function buildTaskWorktreeRoot(projectWorkspacePath, worktreeBranch) {
@@ -3338,6 +3664,7 @@ export function startDaemon(config = {}, deps = {}) {
3338
3664
  "refresh_session_inplace",
3339
3665
  "task_attachments_v1",
3340
3666
  CUSTOM_COMMANDS_CAPABILITY,
3667
+ UPDATE_DAEMON_CAPABILITY,
3341
3668
  ];
3342
3669
  if (remoteExecEnabled) {
3343
3670
  advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
@@ -3371,8 +3698,43 @@ export function startDaemon(config = {}, deps = {}) {
3371
3698
  if (effectiveCapabilities.length > 0) {
3372
3699
  extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
3373
3700
  }
3701
+ // Built-in "Update Daemon" is a global `npm install -g` followed by a
3702
+ // restart. Refuse up front — with a reason the UI can show — wherever that
3703
+ // is the wrong way to upgrade this install.
3704
+ function resolveDaemonUpdateRefusal() {
3705
+ if (IS_GUEST_DAEMON) {
3706
+ return "a shared guest daemon cannot install a new CLI version on the host machine";
3707
+ }
3708
+ if (installMethod === "homebrew") {
3709
+ return `conductor was installed with Homebrew; run \`${buildUpgradeCommand({ env: process.env })}\` on ${AGENT_NAME} instead`;
3710
+ }
3711
+ if (!autoUpdateSupportedInstall) {
3712
+ return `conductor runs from ${installedPackageRoot}, which is not a global install; upgrade it the same way it was installed`;
3713
+ }
3714
+ return null;
3715
+ }
3716
+
3374
3717
  const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
3375
3718
  const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
3719
+ const daemonUpdatePaths = resolveDaemonUpdatePaths(process.env);
3720
+ const daemonUpdateHandlers = createDaemonUpdateHandlers({
3721
+ statusPath: daemonUpdatePaths.statusPath,
3722
+ logPath: daemonUpdatePaths.logPath,
3723
+ updaterScript: DAEMON_UPDATE_SCRIPT_PATH,
3724
+ refuseReason: resolveDaemonUpdateRefusal(),
3725
+ env: { ...process.env, ...materializedConductorPathEnv },
3726
+ updaterParams: {
3727
+ packageRoot: installedPackageRoot,
3728
+ launcherScript: restartLauncherScript,
3729
+ launcherArgs: restartLauncherArgs,
3730
+ versionCheckScript,
3731
+ versionCheckArgs,
3732
+ daemonPid: process.pid,
3733
+ lockFile: LOCK_FILE,
3734
+ daemonLogPath: resolveDaemonLogPaths().logPath,
3735
+ currentVersion: cliVersion,
3736
+ },
3737
+ });
3376
3738
  const remoteExecHandlers = remoteExecEnabled
3377
3739
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3378
3740
  : null;
@@ -3501,6 +3863,16 @@ export function startDaemon(config = {}, deps = {}) {
3501
3863
  log(`Daemon Name: ${AGENT_NAME}`);
3502
3864
  log(`Supported Backends: ${SUPPORTED_BACKENDS.join(", ")}`);
3503
3865
 
3866
+ // Reclaim the Fires the previous daemon left running. Started here rather
3867
+ // than awaited: connecting must not wait on a wedged tmux server. Both
3868
+ // stale sweeps await `adoptOrphanedTmuxFiresPromise` before judging
3869
+ // anything, which is the ordering that actually matters — they decide
3870
+ // liveness from `activeTaskProcesses`, so a survivor not yet adopted when
3871
+ // they run is a survivor that gets killed.
3872
+ adoptOrphanedTmuxFiresPromise = adoptOrphanedTmuxFires().catch((error) => {
3873
+ logError(`adoptOrphanedTmuxFires failed: ${error?.message || error}`);
3874
+ });
3875
+
3504
3876
  client.connect().catch((err) => {
3505
3877
  logError(`Failed to connect: ${err}`);
3506
3878
  });
@@ -4284,6 +4656,8 @@ export function startDaemon(config = {}, deps = {}) {
4284
4656
 
4285
4657
  async function recoverStaleTasks() {
4286
4658
  try {
4659
+ // Never judge a task before startup adoption has had its say.
4660
+ await adoptOrphanedTmuxFiresPromise;
4287
4661
  const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
4288
4662
  method: "GET",
4289
4663
  headers: {
@@ -4316,8 +4690,48 @@ export function startDaemon(config = {}, deps = {}) {
4316
4690
  return;
4317
4691
  }
4318
4692
 
4693
+ // Startup is the main case the tmux hand-off exists for: the previous
4694
+ // daemon deliberately left its Fires running and this fresh process has
4695
+ // an empty `activeTaskProcesses` by definition, so "not in memory"
4696
+ // proves nothing here. `adoptOrphanedTmuxFires` has already claimed
4697
+ // every session with a hand-off record; this second pass catches the
4698
+ // ones without a record, which only become identifiable now that the
4699
+ // backend has told us which task ids to look for.
4700
+ if (tmuxFiresMayExistUnseen()) {
4701
+ logError(
4702
+ `tmux is reachable but this daemon started without tmux mode active; skipping stale ` +
4703
+ `recovery for ${staleTasks.length} task(s) rather than killing Fires it cannot see`,
4704
+ );
4705
+ return;
4706
+ }
4707
+ const tmuxSessions = FIRE_TMUX_MODE_ACTIVE ? await listAllTmuxSessions() : null;
4708
+ if (tmuxSessions && !tmuxSessions.conclusive) {
4709
+ // An unanswerable `tmux list-sessions` is not evidence that nothing is
4710
+ // running — and this is the one sweep with no second chance, since it
4711
+ // runs once per process. Killing on a blind probe is precisely how a
4712
+ // single wedged tmux server reproduces the 2026-08-31 mass kill.
4713
+ logError(
4714
+ `Could not list tmux sessions; skipping stale recovery for ${staleTasks.length} task(s) rather than killing on a blind probe`,
4715
+ );
4716
+ return;
4717
+ }
4718
+ const deadTasks = staleTasks.filter((task) => {
4719
+ // A spawn already in flight has no record yet either; killing it
4720
+ // would shoot down a Fire this daemon is in the middle of starting.
4721
+ if (pendingTaskStarts.has(String(task?.id || ""))) return false;
4722
+ return !adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || []);
4723
+ });
4724
+
4725
+ if (deadTasks.length === 0) {
4726
+ return;
4727
+ }
4728
+
4729
+ // Count what the backend actually accepted, not what we attempted: a
4730
+ // 409/500 leaves the task running, and reporting it as recovered has
4731
+ // repeatedly sent readers of this log looking in the wrong place.
4732
+ let killedCount = 0;
4319
4733
  await Promise.all(
4320
- staleTasks.map(async (task) => {
4734
+ deadTasks.map(async (task) => {
4321
4735
  const taskId = task?.id;
4322
4736
  if (!taskId) return;
4323
4737
  const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
@@ -4332,12 +4746,13 @@ export function startDaemon(config = {}, deps = {}) {
4332
4746
  if (!patchResp.ok) {
4333
4747
  logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
4334
4748
  } else {
4749
+ killedCount += 1;
4335
4750
  markBackendHttpSuccess();
4336
4751
  }
4337
4752
  }),
4338
4753
  );
4339
4754
 
4340
- log(`Recovered ${staleTasks.length} stale task(s) to killed`);
4755
+ log(`Recovered ${killedCount}/${deadTasks.length} stale task(s) to killed`);
4341
4756
  } catch (error) {
4342
4757
  logError(`recoverStaleTasks error: ${error?.message || error}`);
4343
4758
  }
@@ -4349,6 +4764,7 @@ export function startDaemon(config = {}, deps = {}) {
4349
4764
 
4350
4765
  async function reconcileAssignedTasks() {
4351
4766
  try {
4767
+ await adoptOrphanedTmuxFiresPromise;
4352
4768
  const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
4353
4769
  method: "GET",
4354
4770
  headers: {
@@ -4383,10 +4799,36 @@ export function startDaemon(config = {}, deps = {}) {
4383
4799
  });
4384
4800
 
4385
4801
  let killedCount = 0;
4802
+ // Shutdown deliberately leaves tmux-detached Fires running, and a ws
4803
+ // reconnect does not repopulate `activeTaskProcesses`, so this map
4804
+ // under-reports what is still alive on this host. Ask tmux.
4805
+ if (assigned.length && tmuxFiresMayExistUnseen()) {
4806
+ logError(
4807
+ `tmux is reachable but this daemon started without tmux mode active; skipping reconcile ` +
4808
+ `for ${assigned.length} assigned task(s)`,
4809
+ );
4810
+ return;
4811
+ }
4812
+ const tmuxSessions =
4813
+ FIRE_TMUX_MODE_ACTIVE && assigned.length ? await listAllTmuxSessions() : null;
4814
+ if (tmuxSessions && !tmuxSessions.conclusive) {
4815
+ // Same rule as the startup sweep. Here we do get another chance on the
4816
+ // next reconnect, which is all the more reason not to guess.
4817
+ logError(
4818
+ `Could not list tmux sessions; skipping reconcile for ${assigned.length} assigned task(s)`,
4819
+ );
4820
+ return;
4821
+ }
4386
4822
  for (const task of assigned) {
4387
4823
  const taskId = String(task?.id || "");
4388
4824
  if (!taskId) continue;
4389
- if (localTaskIds.has(taskId)) {
4825
+ if (localTaskIds.has(taskId) || pendingTaskStarts.has(taskId)) {
4826
+ continue;
4827
+ }
4828
+ // Adopts on the spot rather than merely skipping: a survivor that
4829
+ // dodges the kill but gains no watcher would sit at `running`
4830
+ // forever, because nothing else reports a tmux Fire's death.
4831
+ if (adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || [])) {
4390
4832
  continue;
4391
4833
  }
4392
4834
  const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
@@ -4422,7 +4864,12 @@ export function startDaemon(config = {}, deps = {}) {
4422
4864
  // half-close). Fires raise their own ws to backend independently — this is
4423
4865
  // the daemon's *own* belief about which tasks it is hosting. The backend
4424
4866
  // arbitrates between this push and the persisted task state.
4425
- function pushAgentAliveTasks(reason) {
4867
+ async function pushAgentAliveTasks(reason) {
4868
+ // Adoption may still be running when the ws handshake completes. This is
4869
+ // the only channel that revokes a `killed_reason=daemon_disconnected`
4870
+ // flag, so pushing before the adopted tasks are in the map would leave
4871
+ // them showing as killed in the UI even though this daemon rescued them.
4872
+ await adoptOrphanedTmuxFiresPromise;
4426
4873
  const aliveTaskIds = getActiveTaskIds();
4427
4874
  if (aliveTaskIds.length === 0) {
4428
4875
  return Promise.resolve();
@@ -5795,6 +6242,14 @@ export function startDaemon(config = {}, deps = {}) {
5795
6242
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
5796
6243
  });
5797
6244
  }
6245
+ if (event.type === "update_daemon_request") {
6246
+ if (event?.payload?.action === "start") {
6247
+ log(`[update_daemon] Update requested (current=${cliVersion}); log: ${daemonUpdatePaths.logPath}`);
6248
+ }
6249
+ handleUpdateDaemonRequest(client, daemonUpdateHandlers, event.payload).catch((error) => {
6250
+ logError(`Unhandled update_daemon_request failure: ${error?.message || error}`);
6251
+ });
6252
+ }
5798
6253
  }
5799
6254
 
5800
6255
  function markWatchdogHealthy(signal, at = Date.now()) {
@@ -6162,6 +6617,7 @@ export function startDaemon(config = {}, deps = {}) {
6162
6617
  }
6163
6618
 
6164
6619
  activeTaskProcesses.delete(taskId);
6620
+ forgetFireSessionRecord(processRecord);
6165
6621
  return true;
6166
6622
  }
6167
6623
 
@@ -6236,6 +6692,15 @@ export function startDaemon(config = {}, deps = {}) {
6236
6692
  return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
6237
6693
  }
6238
6694
 
6695
+ // "Does this record stand for a live fire?" `child` alone is not the answer:
6696
+ // an ADOPTED tmux record has none — the `tmux new-session` client belonged
6697
+ // to the daemon that spawned it — while the fire it points at is very much
6698
+ // alive. Every already-active gate must ask this instead, or it will spawn
6699
+ // a second fire into a worktree that already has one.
6700
+ function recordHasLiveFire(record) {
6701
+ return Boolean(record?.child) || Boolean(record?.tmuxMode && record?.tmuxSession);
6702
+ }
6703
+
6239
6704
  // RFC 0029: confirm that a task the backend believes is dead is actually
6240
6705
  // still hosting a live fire on this daemon. The check is intentionally
6241
6706
  // conservative:
@@ -6334,6 +6799,7 @@ export function startDaemon(config = {}, deps = {}) {
6334
6799
  entry.stopForceKillTimer = null;
6335
6800
  }
6336
6801
  activeTaskProcesses.delete(taskId);
6802
+ forgetFireSessionRecord(entry);
6337
6803
  }
6338
6804
  entry = undefined;
6339
6805
  }
@@ -6391,7 +6857,12 @@ export function startDaemon(config = {}, deps = {}) {
6391
6857
 
6392
6858
  const processRecord = activeTaskProcesses.get(taskId);
6393
6859
  const ptyRecord = activePtySessions.get(taskId);
6394
- if ((!processRecord || !processRecord.child) && !ptyRecord) {
6860
+ // `recordHasLiveFire`, not `child`: an adopted tmux record has no child,
6861
+ // and reading it as "nothing to stop" would report the task KILLED to the
6862
+ // backend while leaving the fire running in its session, still writing to
6863
+ // the worktree. Nothing would ever notice — the reaper only speaks when a
6864
+ // session disappears.
6865
+ if (!recordHasLiveFire(processRecord) && !ptyRecord) {
6395
6866
  log(`Stop requested for task ${taskId}, but no active process found`);
6396
6867
  sendStopAck(false);
6397
6868
  // "Nothing to stop" IS the terminal answer, and the server cannot infer
@@ -6769,9 +7240,12 @@ export function startDaemon(config = {}, deps = {}) {
6769
7240
  }
6770
7241
 
6771
7242
  const existingTaskRecord = activeTaskProcesses.get(taskId);
6772
- if (existingTaskRecord?.child || pendingTaskStarts.has(taskId)) {
7243
+ if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
6773
7244
  log(
6774
- `Duplicate create_task ignored for ${taskId}: task already active (pid=${existingTaskRecord?.child?.pid ?? "unknown"})`,
7245
+ `Duplicate create_task ignored for ${taskId}: task already active (` +
7246
+ `${existingTaskRecord?.tmuxSession
7247
+ ? `tmux=${existingTaskRecord.tmuxSession}`
7248
+ : `pid=${existingTaskRecord?.child?.pid ?? "unknown"}`})`,
6775
7249
  );
6776
7250
  sendAgentCommandAck({
6777
7251
  requestId,
@@ -6975,7 +7449,7 @@ export function startDaemon(config = {}, deps = {}) {
6975
7449
  const outputCapture = createChildOutputCapture({ logPath, logStartOffset });
6976
7450
  const spawnedAtMs = Date.now();
6977
7451
 
6978
- activeTaskProcesses.set(taskId, {
7452
+ const activeProcessRecord = {
6979
7453
  child,
6980
7454
  projectId,
6981
7455
  logPath,
@@ -6993,7 +7467,12 @@ export function startDaemon(config = {}, deps = {}) {
6993
7467
  managedByFireBridge: true,
6994
7468
  tmuxSession: tmuxSession || null,
6995
7469
  tmuxMode: Boolean(tmuxSession),
6996
- });
7470
+ };
7471
+ activeTaskProcesses.set(taskId, activeProcessRecord);
7472
+ // Mirror the parts of the record that only exist in this process's
7473
+ // memory, so a successor daemon can adopt this Fire instead of
7474
+ // mistaking the hand-off for a dead task.
7475
+ rememberFireSessionRecord(taskId, activeProcessRecord);
6997
7476
 
6998
7477
  client
6999
7478
  .sendJson({
@@ -7099,6 +7578,7 @@ export function startDaemon(config = {}, deps = {}) {
7099
7578
  clearTimeout(active.stopForceKillTimer);
7100
7579
  }
7101
7580
  activeTaskProcesses.delete(taskId);
7581
+ forgetFireSessionRecord(active);
7102
7582
  const suppressExitStatusReport = suppressedExitStatusReports.has(taskId);
7103
7583
  suppressedExitStatusReports.delete(taskId);
7104
7584
  if (logStream) {
@@ -7289,12 +7769,17 @@ export function startDaemon(config = {}, deps = {}) {
7289
7769
  activeTarget.stopForceKillTimer = null;
7290
7770
  }
7291
7771
  activeTaskProcesses.delete(normalizedTargetTaskId);
7772
+ forgetFireSessionRecord(activeTarget);
7292
7773
  activeTarget = undefined;
7293
7774
  }
7294
7775
  }
7295
7776
 
7777
+ // The probe above already cleared the record if the session was
7778
+ // conclusively gone, so a surviving tmuxMode entry means a live session.
7779
+ const targetHasLiveFire = recordHasLiveFire(activeTarget);
7780
+
7296
7781
  if (isRefreshSessionInplace) {
7297
- if (!activeTarget?.child) {
7782
+ if (!targetHasLiveFire) {
7298
7783
  reportRestartFailure({
7299
7784
  taskId: normalizedTargetTaskId,
7300
7785
  projectId: normalizedProjectId,
@@ -7305,7 +7790,7 @@ export function startDaemon(config = {}, deps = {}) {
7305
7790
  });
7306
7791
  return;
7307
7792
  }
7308
- } else if (activeTarget?.child) {
7793
+ } else if (targetHasLiveFire) {
7309
7794
  // tmux-mode failures point at the tmux session, not a meaningless pid.
7310
7795
  const description = activeTarget.tmuxMode
7311
7796
  ? `task already active in tmux session ${activeTarget.tmuxSession || "(unknown)"} — stop the task before restarting`
@@ -7690,6 +8175,9 @@ export function startDaemon(config = {}, deps = {}) {
7690
8175
  tmuxMode: Boolean(tmuxSession),
7691
8176
  };
7692
8177
  activeTaskProcesses.set(normalizedTargetTaskId, activeProcessRecord);
8178
+ // See the create_task path: this is the only copy of `exitMarkerToken`
8179
+ // that survives the current process.
8180
+ rememberFireSessionRecord(normalizedTargetTaskId, activeProcessRecord);
7693
8181
 
7694
8182
  // In tmux mode the Fire process writes directly to logPath via shell
7695
8183
  // redirection inside the tmux session, so the daemon does not pipe
@@ -7780,6 +8268,7 @@ export function startDaemon(config = {}, deps = {}) {
7780
8268
  clearTimeout(active.stopForceKillTimer);
7781
8269
  }
7782
8270
  activeTaskProcesses.delete(normalizedTargetTaskId);
8271
+ forgetFireSessionRecord(active);
7783
8272
  const suppressExitStatusReport = suppressedExitStatusReports.has(normalizedTargetTaskId);
7784
8273
  suppressedExitStatusReports.delete(normalizedTargetTaskId);
7785
8274
  if (logStream) {