@love-moon/conductor-cli 0.10.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,9 +42,16 @@ 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,
54
+ inferBuiltInRuntimeBackendFromCommand,
41
55
  listAdvertisedBackends,
42
56
  resolveConfiguredRuntimeBackend,
43
57
  isBuiltInRuntimeBackend,
@@ -46,6 +60,7 @@ import {
46
60
  normalizeRuntimeBackendAlias,
47
61
  normalizeRuntimeBackendName,
48
62
  } from "./runtime-backends.js";
63
+ import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
49
64
  import {
50
65
  PACKAGE_NAME,
51
66
  buildUpgradeCommand,
@@ -67,7 +82,31 @@ import {
67
82
  maskErrorForLogs,
68
83
  redactSecretsForLogs,
69
84
  } from "./handoff-log-mask.js";
85
+ import {
86
+ DAEMON_LOCK_FILE_NAME,
87
+ FORCE_KILL_UNKNOWN_OWNER_ENV_VAR,
88
+ buildDaemonInstanceIdentity,
89
+ compareDaemonLockIdentity,
90
+ describeDaemonLockOwner,
91
+ describeForceRestartRefusal,
92
+ parseDaemonLockState,
93
+ serializeDaemonLock,
94
+ } from "./daemon-lock.js";
70
95
  import { StringDecoder } from "node:string_decoder";
96
+ import {
97
+ MAX_GUEST_DAEMONS,
98
+ buildGuestConfigYaml,
99
+ buildGuestEnv,
100
+ filterGuestCapabilities,
101
+ isGuestAiManagerActionAllowed,
102
+ isGuestRestartAllowed,
103
+ isPathInsideGuestRoot,
104
+ nextRestartDelayMs,
105
+ reconcileGuests,
106
+ startOrphanWatchdog,
107
+ resolveGuestPaths,
108
+ writeGuestConfig,
109
+ } from "./guest-daemon.js";
71
110
 
72
111
  dotenv.config();
73
112
 
@@ -76,6 +115,11 @@ const __dirname = path.dirname(__filename);
76
115
  const PACKAGE_ROOT = path.join(__dirname, "..");
77
116
  const moduleRequire = createRequire(import.meta.url);
78
117
  const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
118
+ // RFC 0035: the launcher a guest daemon child is spawned with. Same binary as
119
+ // the host daemon, so guests always match the installed version and there is
120
+ // no second update path to keep in sync.
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");
79
123
  const CLI_VERSION = (() => {
80
124
  try {
81
125
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
@@ -493,6 +537,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
493
537
  return { helperPath, updated: true };
494
538
  }
495
539
 
540
+ // A tool-preset PTY task runs the configured allow_cli_list command through a
541
+ // login shell, bypassing ai-sdk entirely. claude refuses to start as root with
542
+ // `--dangerously-skip-permissions`, so rewrite the command the same way the
543
+ // ai-sdk session rewrites its permission mode — one shared root check, so the
544
+ // two paths cannot drift.
545
+ export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
546
+ if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
547
+ return cliCommand;
548
+ }
549
+ return resolveClaudeCommandForRoot(cliCommand, env);
550
+ }
551
+
496
552
  export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
497
553
  const normalizedWorkspacePath =
498
554
  typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
@@ -675,6 +731,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
675
731
  projectRepoRoot,
676
732
  projectWorkspacePath,
677
733
  projectRelativePath,
734
+ // Reuse-only members (reviewers in a multi-agent group) share the owner's
735
+ // worktree but must never run `git worktree add` themselves.
736
+ worktreeReuseOnly: normalizeBooleanFlag(
737
+ normalizedLaunchConfig.worktreeReuseOnly ??
738
+ normalizedLaunchConfig.worktree_reuse_only,
739
+ ),
678
740
  };
679
741
  }
680
742
 
@@ -772,6 +834,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
772
834
  };
773
835
  }
774
836
 
837
+ // Module-level so the PTY launch wiring is directly testable: the tool-preset
838
+ // branch has to hand the *child's* env to the root check, and that seam is
839
+ // exactly where a bug hid before (the check read the daemon's own process.env,
840
+ // silently ignoring a per-task IS_SANDBOX=1 opt-out).
841
+ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
842
+ const allowCliList = deps.allowCliList || {};
843
+ const supportedBackends = Array.isArray(deps.supportedBackends) ? deps.supportedBackends : [];
844
+ const existsSyncFn = deps.existsSync || fs.existsSync;
845
+ const baseEnv = deps.baseEnv || process.env;
846
+ const log = typeof deps.log === "function" ? deps.log : () => {};
847
+ const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
848
+ const entrypointType =
849
+ normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
850
+ normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
851
+ (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
852
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset)
853
+ ? "tool_preset"
854
+ : "shell");
855
+ const preferredShell = resolveDefaultPtyShell({
856
+ explicitShell: normalizedLaunchConfig.shell,
857
+ envShell: process.env.SHELL,
858
+ comspec: process.env.COMSPEC,
859
+ platform: process.platform,
860
+ existsSync: existsSyncFn,
861
+ });
862
+ const cwd =
863
+ normalizeOptionalString(normalizedLaunchConfig.cwd) ||
864
+ fallbackCwd;
865
+ const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
866
+ const cols = normalizePositiveInt(
867
+ normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
868
+ DEFAULT_TERMINAL_COLS,
869
+ );
870
+ const rows = normalizePositiveInt(
871
+ normalizedLaunchConfig.rows,
872
+ DEFAULT_TERMINAL_ROWS,
873
+ );
874
+
875
+ if (entrypointType === "tool_preset") {
876
+ const toolPreset =
877
+ normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
878
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
879
+ supportedBackends[0] ||
880
+ "codex";
881
+ const cliCommand = allowCliList[toolPreset];
882
+ if (!cliCommand) {
883
+ throw new Error(`Unsupported tool preset: ${toolPreset}`);
884
+ }
885
+ const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
886
+ if (launchCommand !== cliCommand) {
887
+ log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
888
+ }
889
+ return {
890
+ entrypointType,
891
+ toolPreset,
892
+ command: preferredShell,
893
+ args: ["-lc", launchCommand],
894
+ shell: preferredShell,
895
+ cwd,
896
+ env,
897
+ cols,
898
+ rows,
899
+ };
900
+ }
901
+
902
+ if (entrypointType === "custom") {
903
+ const command = normalizeOptionalString(normalizedLaunchConfig.command);
904
+ if (!command) {
905
+ throw new Error("launch_config.command is required for custom entrypoint");
906
+ }
907
+ const args = Array.isArray(normalizedLaunchConfig.args)
908
+ ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
909
+ : [];
910
+ return {
911
+ entrypointType,
912
+ toolPreset: null,
913
+ command,
914
+ args,
915
+ shell: preferredShell,
916
+ cwd,
917
+ env,
918
+ cols,
919
+ rows,
920
+ };
921
+ }
922
+
923
+ return {
924
+ entrypointType: "shell",
925
+ toolPreset: null,
926
+ command: preferredShell,
927
+ args: ["-l"],
928
+ shell: preferredShell,
929
+ cwd,
930
+ env,
931
+ cols,
932
+ rows,
933
+ };
934
+ }
935
+
936
+
775
937
  export function startDaemon(config = {}, deps = {}) {
776
938
  const exitFn = deps.exit || process.exit;
777
939
  const killFn = deps.kill || process.kill;
@@ -881,6 +1043,24 @@ export function startDaemon(config = {}, deps = {}) {
881
1043
  // warning and silently fall back to direct spawn rather than failing every
882
1044
  // create_task with ENOENT.
883
1045
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
1046
+ // RFC 0035: a guest daemon runs on someone else's machine as a different
1047
+ // account. It keeps the ordinary daemon code path but drops the capabilities
1048
+ // that would mutate state the machine's OWNER depends on.
1049
+ const IS_GUEST_DAEMON =
1050
+ userConfig.conductor_guest === true ||
1051
+ fileConfig?.conductorGuest === true ||
1052
+ Boolean(normalizeOptionalString(process.env.CONDUCTOR_GUEST_SHARE_ID));
1053
+ // Only set when the share carries an explicit `workspaceRoot`, i.e. the owner
1054
+ // deliberately scoped where the guest should work. Absent that, a guest binds
1055
+ // projects wherever any other daemon could -- inventing a confinement the
1056
+ // owner never asked for would make the guest gratuitously less capable, and
1057
+ // it was never a security boundary anyway (the grantee's agent runs a shell).
1058
+ const GUEST_ROOT = normalizeOptionalString(process.env.CONDUCTOR_GUEST_ROOT);
1059
+ // A guest resolves `remote_exec` from its own config exactly like any other
1060
+ // daemon. It is not withheld: the grantee already has a shell here through
1061
+ // AI tasks and `pty_task`'s custom entrypoint, so blocking the scriptable
1062
+ // path would cost function without removing reach (RFC 0034 makes the same
1063
+ // argument). An owner who wants it off can set `remote_exec: false`.
884
1064
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
885
1065
 
886
1066
  // Get allow_cli_list from config
@@ -911,6 +1091,17 @@ export function startDaemon(config = {}, deps = {}) {
911
1091
  readFileSync: deps.readFileSync || fs.readFileSync,
912
1092
  }));
913
1093
  const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
1094
+ // Fingerprint of *this* daemon instance, persisted into daemon.pid so that a
1095
+ // later `--force` can tell "restart myself" apart from "kill whoever happens
1096
+ // to hold the lock". WORKSPACE_ROOT defaults to $HOME/ws, so unrelated
1097
+ // instances collide on the same lock file by default.
1098
+ const daemonInstanceIdentity = buildDaemonInstanceIdentity({
1099
+ conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
1100
+ configPath: effectiveConfigPath,
1101
+ workspaceRoot: WORKSPACE_ROOT,
1102
+ daemonName: AGENT_NAME,
1103
+ backendUrl: BACKEND_HTTP,
1104
+ });
914
1105
  const lockHandoffToken =
915
1106
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
916
1107
  normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
@@ -1010,6 +1201,16 @@ export function startDaemon(config = {}, deps = {}) {
1010
1201
  log("Fire tmux mode enabled: each Fire process will run in a detached tmux session");
1011
1202
  }
1012
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
+
1013
1214
  // Single-quote a value so it can be embedded inside a `bash -c '...'`
1014
1215
  // command. Embedded single-quotes are escaped via the standard `'\\''`
1015
1216
  // sequence.
@@ -1034,7 +1235,7 @@ export function startDaemon(config = {}, deps = {}) {
1034
1235
  .replace(/[^a-zA-Z0-9_-]/g, "_")
1035
1236
  .replace(/^_+|_+$/g, "")
1036
1237
  .slice(0, 32) || "task";
1037
- return `conductor-fire-${safe}-`;
1238
+ return `${FIRE_TMUX_SESSION_NAMESPACE}${safe}-`;
1038
1239
  }
1039
1240
 
1040
1241
  // Build a unique tmux session name for a Fire spawn.
@@ -1577,7 +1778,14 @@ export function startDaemon(config = {}, deps = {}) {
1577
1778
  // would be misread as "died hard" — reporting every clean finish as
1578
1779
  // KILLED. Staying silent restores the old blind spot for this task only,
1579
1780
  // which is strictly better than manufacturing a wrong cause of death.
1580
- 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) {
1581
1789
  logError(
1582
1790
  `Cannot classify the end of task ${taskId}: no log path on the active record, so no exit marker exists`,
1583
1791
  );
@@ -1590,7 +1798,7 @@ export function startDaemon(config = {}, deps = {}) {
1590
1798
  // that absence as "no exit marker" would report a green task as KILLED
1591
1799
  // with an invented "killed or OOM" cause. An empty-but-present file is
1592
1800
  // different: the fire really did die before writing anything.
1593
- if (readLogFileSize(record.logPath) === null) {
1801
+ if (!record.adoptedWithoutMetadata && readLogFileSize(record.logPath) === null) {
1594
1802
  logError(
1595
1803
  `Cannot classify the end of task ${taskId}: log file ${record.logPath} is missing, ` +
1596
1804
  `so the absence of an exit marker proves nothing`,
@@ -1598,7 +1806,9 @@ export function startDaemon(config = {}, deps = {}) {
1598
1806
  return;
1599
1807
  }
1600
1808
 
1601
- const rawTail = readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
1809
+ const rawTail = record.adoptedWithoutMetadata
1810
+ ? ""
1811
+ : readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
1602
1812
  // Search the file for the marker rather than hoping it is still inside
1603
1813
  // the tail window — see `findFireExitCode` for why a shared log makes the
1604
1814
  // tail unreliable. The tail is still what feeds the human-readable
@@ -1622,8 +1832,9 @@ export function startDaemon(config = {}, deps = {}) {
1622
1832
  // `latest_status_summary` with a word that says nothing. Naming *how* we
1623
1833
  // found out keeps the event worth its row: it tells the next reader that
1624
1834
  // fire never published its own status.
1625
- const baseSummary =
1626
- 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
1627
1838
  ? "fire tmux session disappeared without an exit marker (killed or OOM)"
1628
1839
  : exitCode === 0
1629
1840
  ? "completed; detected by the daemon after the tmux session ended (fire never reported it)"
@@ -1634,6 +1845,19 @@ export function startDaemon(config = {}, deps = {}) {
1634
1845
  // Ask the backend before overwriting anything: fire normally reports its
1635
1846
  // own terminal status and we must not downgrade a success.
1636
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
+ }
1637
1861
  if (backendStatus && TERMINAL_BACKEND_TASK_STATUSES.has(backendStatus)) {
1638
1862
  log(
1639
1863
  `Tmux session ${record.tmuxSession} for task ${taskId} ended; backend already ${backendStatus}, no report needed`,
@@ -1748,6 +1972,7 @@ export function startDaemon(config = {}, deps = {}) {
1748
1972
  record.stopForceKillTimer = null;
1749
1973
  }
1750
1974
  activeTaskProcesses.delete(taskId);
1975
+ forgetFireSessionRecord(record);
1751
1976
  // Report *after* dropping the local entry so a slow backend round
1752
1977
  // trip can't make a concurrent stop_task wait on us.
1753
1978
  await reportDeadTmuxSessionStatus(taskId, record);
@@ -1779,22 +2004,27 @@ export function startDaemon(config = {}, deps = {}) {
1779
2004
  }
1780
2005
  }
1781
2006
 
1782
- // List every tmux session name visible to the current user. Returns `[]`
1783
- // on any spawn error, non-zero exit, or timeout (e.g. tmux not available
1784
- // 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.
1785
2015
  function listAllTmuxSessions() {
1786
2016
  return new Promise((resolve) => {
1787
2017
  let settled = false;
1788
2018
  let stdout = "";
1789
2019
  let timer = null;
1790
- const settle = (lines) => {
2020
+ const settle = (lines, conclusive) => {
1791
2021
  if (settled) return;
1792
2022
  settled = true;
1793
2023
  if (timer) {
1794
2024
  clearTimeout(timer);
1795
2025
  timer = null;
1796
2026
  }
1797
- resolve(lines);
2027
+ resolve({ sessions: lines, conclusive });
1798
2028
  };
1799
2029
  try {
1800
2030
  const probe = spawnFn("tmux", ["list-sessions", "-F", "#{session_name}"], {
@@ -1812,14 +2042,16 @@ export function startDaemon(config = {}, deps = {}) {
1812
2042
  .split("\n")
1813
2043
  .map((s) => s.trim())
1814
2044
  .filter(Boolean),
2045
+ true,
1815
2046
  );
1816
2047
  } else {
1817
- // Non-zero exit usually means "no server running" or "no
1818
- // sessions" — both fine, just return empty.
1819
- 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);
1820
2051
  }
1821
2052
  });
1822
- probe.on("error", () => settle([]));
2053
+ // Could not run tmux at all — no information either way.
2054
+ probe.on("error", () => settle([], false));
1823
2055
  timer = setTimeout(() => {
1824
2056
  try {
1825
2057
  if (typeof probe.kill === "function") {
@@ -1829,13 +2061,13 @@ export function startDaemon(config = {}, deps = {}) {
1829
2061
  // ignore; we already settled
1830
2062
  }
1831
2063
  logError(`tmux list-sessions probe timed out after ${TMUX_PROBE_TIMEOUT_MS}ms`);
1832
- settle([]);
2064
+ settle([], false);
1833
2065
  }, TMUX_PROBE_TIMEOUT_MS);
1834
2066
  if (typeof timer.unref === "function") {
1835
2067
  timer.unref();
1836
2068
  }
1837
2069
  } catch {
1838
- settle([]);
2070
+ settle([], false);
1839
2071
  }
1840
2072
  });
1841
2073
  }
@@ -1850,17 +2082,288 @@ export function startDaemon(config = {}, deps = {}) {
1850
2082
  async function killTmuxSessionsForDeletedTask(taskId) {
1851
2083
  if (!FIRE_TMUX_MODE_ACTIVE || !taskId) return 0;
1852
2084
  const prefix = buildFireTmuxSessionPrefix(taskId);
1853
- const sessions = await listAllTmuxSessions();
2085
+ const { sessions } = await listAllTmuxSessions();
1854
2086
  let killed = 0;
1855
2087
  for (const name of sessions) {
1856
2088
  if (name.startsWith(prefix)) {
1857
2089
  log(`Killing orphaned tmux session ${name} for deleted task ${taskId}`);
1858
2090
  killFireTmuxSession(name);
2091
+ forgetFireSessionRecord({ tmuxSession: name });
1859
2092
  killed += 1;
1860
2093
  }
1861
2094
  }
1862
2095
  return killed;
1863
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
+ }
1864
2367
  // -------------------------------------------------------------------------
1865
2368
 
1866
2369
  function buildTaskWorktreeRoot(projectWorkspacePath, worktreeBranch) {
@@ -2453,6 +2956,79 @@ export function startDaemon(config = {}, deps = {}) {
2453
2956
  });
2454
2957
  }
2455
2958
 
2959
+ // In-flight worktree preparations keyed on the on-disk root. Group members
2960
+ // (worker + reviewers) start almost simultaneously and would otherwise all
2961
+ // pass the `.git` existence check below and race on `git worktree add -b`
2962
+ // for the same branch: one wins and the losers fail with
2963
+ // "Failed to prepare git worktree". Serializing per root also benefits the
2964
+ // pre-existing fork/branch sharing path.
2965
+ const taskWorktreePreparations = new Map();
2966
+
2967
+ // Readiness marker, written by the owner only after the worktree is FULLY
2968
+ // prepared. `.git` alone is not a readiness signal: it appears the moment
2969
+ // `git worktree add` returns, while submodules and symlinks are still
2970
+ // pending. An owner that dies in between (e.g. submodule sync hits its
2971
+ // timeout) would otherwise leave `.git` behind and let reuse-only members
2972
+ // walk into a half-built directory.
2973
+ //
2974
+ // It lives NEXT TO the worktree, not inside it: an untracked file inside
2975
+ // would make `git status --porcelain` report the worktree as dirty and block
2976
+ // non-forced cleanup.
2977
+ function taskWorktreeReadyMarkerPath(worktreeRoot) {
2978
+ return `${worktreeRoot}.ready`;
2979
+ }
2980
+
2981
+ function removeTaskWorktreeReadyMarker(worktreeRoot) {
2982
+ const markerPath = taskWorktreeReadyMarkerPath(worktreeRoot);
2983
+ if (!existsSyncFn(markerPath)) {
2984
+ return;
2985
+ }
2986
+ try {
2987
+ unlinkSyncFn(markerPath);
2988
+ } catch (error) {
2989
+ if (error?.code !== "ENOENT") {
2990
+ logError(
2991
+ `[worktree] failed to clear ready marker for ${worktreeRoot}: ${error?.message || error}`,
2992
+ );
2993
+ }
2994
+ }
2995
+ }
2996
+
2997
+ // Both must hold. Requiring `.git` too means a marker left behind by a
2998
+ // worktree that was removed outside our cleanup path cannot fake readiness.
2999
+ function isTaskWorktreeReady(worktreeRoot, gitMarkerPath) {
3000
+ return (
3001
+ existsSyncFn(gitMarkerPath) &&
3002
+ existsSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot))
3003
+ );
3004
+ }
3005
+
3006
+ async function waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath }) {
3007
+ const deadline = Date.now() + TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS;
3008
+ for (;;) {
3009
+ // When the owner is preparing in this same daemon process we await the
3010
+ // whole preparation (worktree add + submodules + symlinks). Polling the
3011
+ // marker additionally covers an owner that already finished, or one
3012
+ // running in a different process.
3013
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
3014
+ if (inflight) {
3015
+ await inflight;
3016
+ }
3017
+ if (isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
3018
+ return;
3019
+ }
3020
+ if (Date.now() >= deadline) {
3021
+ throw new Error(
3022
+ `Timed out waiting for shared git worktree ${worktreeRoot} for ${taskId}. ` +
3023
+ `Its owner task may have failed midway through preparation.`,
3024
+ );
3025
+ }
3026
+ await new Promise((resolve) =>
3027
+ setTimeout(resolve, TASK_WORKTREE_REUSE_POLL_INTERVAL_MS),
3028
+ );
3029
+ }
3030
+ }
3031
+
2456
3032
  async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
2457
3033
  const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
2458
3034
  if (!worktreeConfig) {
@@ -2465,7 +3041,50 @@ export function startDaemon(config = {}, deps = {}) {
2465
3041
  );
2466
3042
  const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
2467
3043
  const gitMarkerPath = path.join(worktreeRoot, ".git");
3044
+
3045
+ if (worktreeConfig.worktreeReuseOnly) {
3046
+ await waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath });
3047
+ mkdirSyncFn(finalCwd, { recursive: true });
3048
+ return finalCwd;
3049
+ }
3050
+
3051
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
3052
+ if (inflight) {
3053
+ await inflight;
3054
+ }
3055
+ // Prepare when we are the first owner here, or when the preparation we
3056
+ // waited on did not leave a fully-prepared worktree (then we take over).
3057
+ if (!inflight || !isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
3058
+ const preparation = prepareTaskWorktree({
3059
+ taskId,
3060
+ worktreeConfig,
3061
+ worktreeRoot,
3062
+ gitMarkerPath,
3063
+ finalCwd,
3064
+ });
3065
+ // Waiters must never inherit our rejection; they re-check `.git` instead.
3066
+ taskWorktreePreparations.set(worktreeRoot, preparation.catch(() => {}));
3067
+ try {
3068
+ await preparation;
3069
+ } finally {
3070
+ taskWorktreePreparations.delete(worktreeRoot);
3071
+ }
3072
+ }
3073
+ return finalCwd;
3074
+ }
3075
+
3076
+ async function prepareTaskWorktree({
3077
+ taskId,
3078
+ worktreeConfig,
3079
+ worktreeRoot,
3080
+ gitMarkerPath,
3081
+ finalCwd,
3082
+ }) {
2468
3083
  if (!existsSyncFn(gitMarkerPath)) {
3084
+ // A worktree removed outside our cleanup path can leave a stale marker
3085
+ // behind. Clear it before recreating, so a reuse-only member cannot read
3086
+ // "ready" while `git worktree add` is still running.
3087
+ removeTaskWorktreeReadyMarker(worktreeRoot);
2469
3088
  const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
2470
3089
  if (syncBranch) {
2471
3090
  try {
@@ -2563,7 +3182,8 @@ export function startDaemon(config = {}, deps = {}) {
2563
3182
  projectWorkspacePath: worktreeConfig.projectWorkspacePath,
2564
3183
  finalCwd,
2565
3184
  });
2566
- return finalCwd;
3185
+ // Publish readiness last: every step above has to have succeeded.
3186
+ writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
2567
3187
  }
2568
3188
 
2569
3189
  const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
@@ -2596,6 +3216,17 @@ export function startDaemon(config = {}, deps = {}) {
2596
3216
  process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
2597
3217
  120_000,
2598
3218
  );
3219
+ // A reuse-only member waits for the owner to create the shared worktree.
3220
+ // The bound has to cover a cold clone's submodule sync, hence the generous
3221
+ // default; it only ever elapses when the owner never starts or hard-fails.
3222
+ const TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS = parsePositiveInt(
3223
+ process.env.CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS,
3224
+ 180_000,
3225
+ );
3226
+ const TASK_WORKTREE_REUSE_POLL_INTERVAL_MS = parsePositiveInt(
3227
+ process.env.CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS,
3228
+ 250,
3229
+ );
2599
3230
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
2600
3231
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
2601
3232
  1000,
@@ -2637,36 +3268,7 @@ export function startDaemon(config = {}, deps = {}) {
2637
3268
  DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
2638
3269
  );
2639
3270
 
2640
- const readLockState = () => {
2641
- const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
2642
- if (!raw) {
2643
- return null;
2644
- }
2645
-
2646
- const pid = Number.parseInt(raw, 10);
2647
- if (!Number.isNaN(pid) && pid > 0) {
2648
- return {
2649
- pid,
2650
- handoffFromPid: null,
2651
- handoffToken: null,
2652
- handoffExpiresAt: null,
2653
- };
2654
- }
2655
-
2656
- try {
2657
- const parsed = JSON.parse(raw);
2658
- const parsedPid = normalizePositiveInt(parsed?.pid, null);
2659
- const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
2660
- return {
2661
- pid: parsedPid ?? parsedHandoffFromPid,
2662
- handoffFromPid: parsedHandoffFromPid,
2663
- handoffToken: normalizeOptionalString(parsed?.handoff_token),
2664
- handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
2665
- };
2666
- } catch {
2667
- return null;
2668
- }
2669
- };
3271
+ const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
2670
3272
 
2671
3273
  const hasMatchingLockHandoff = (lockState) => {
2672
3274
  if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
@@ -2705,7 +3307,7 @@ export function startDaemon(config = {}, deps = {}) {
2705
3307
  return exitAndReturn(1);
2706
3308
  }
2707
3309
 
2708
- const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
3310
+ const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
2709
3311
  try {
2710
3312
  if (skipPidLockCheck) {
2711
3313
  log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
@@ -2722,6 +3324,21 @@ export function startDaemon(config = {}, deps = {}) {
2722
3324
  const alive = isProcessAlive(pid);
2723
3325
  if (alive) {
2724
3326
  if (config.FORCE) {
3327
+ const forceRefusal = describeForceRestartRefusal({
3328
+ lockState,
3329
+ identity: daemonInstanceIdentity,
3330
+ lockFile: LOCK_FILE,
3331
+ env: process.env,
3332
+ });
3333
+ if (forceRefusal) {
3334
+ logError(forceRefusal);
3335
+ return exitAndReturn(1);
3336
+ }
3337
+ if (compareDaemonLockIdentity(lockState, daemonInstanceIdentity) === "unknown") {
3338
+ log(
3339
+ `${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR} is set: stopping PID ${pid} even though the lock file carries no instance identity`,
3340
+ );
3341
+ }
2725
3342
  log(`Force enabled: stopping existing daemon PID ${pid}`);
2726
3343
  let alreadyExited = false;
2727
3344
  try {
@@ -2763,7 +3380,9 @@ export function startDaemon(config = {}, deps = {}) {
2763
3380
  unlinkSyncFn(LOCK_FILE);
2764
3381
  }
2765
3382
  } else {
2766
- logError(`Daemon already running with PID ${pid}`);
3383
+ logError(
3384
+ `Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
3385
+ );
2767
3386
  return exitAndReturn(1);
2768
3387
  }
2769
3388
  } else {
@@ -2784,7 +3403,10 @@ export function startDaemon(config = {}, deps = {}) {
2784
3403
  unlinkSyncFn(LOCK_FILE);
2785
3404
  }
2786
3405
  }
2787
- writeFileSyncFn(LOCK_FILE, process.pid.toString());
3406
+ writeFileSyncFn(
3407
+ LOCK_FILE,
3408
+ serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
3409
+ );
2788
3410
  }
2789
3411
  } catch (err) {
2790
3412
  logError("Failed to acquire lock:", err);
@@ -2811,13 +3433,15 @@ export function startDaemon(config = {}, deps = {}) {
2811
3433
  if (skipPidLockCheck) {
2812
3434
  return;
2813
3435
  }
3436
+ // Keeps the instance identity attached while the handoff window is open so
3437
+ // an unrelated instance racing a `--force` during a restart still sees who
3438
+ // owns the lock.
2814
3439
  writeFileSyncFn(
2815
3440
  LOCK_FILE,
2816
- JSON.stringify({
3441
+ serializeDaemonLock({
2817
3442
  pid: handoffFromPid,
2818
- handoff_from_pid: handoffFromPid,
2819
- handoff_token: handoffToken,
2820
- handoff_expires_at: handoffExpiresAt,
3443
+ identity: daemonInstanceIdentity,
3444
+ handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
2821
3445
  }),
2822
3446
  );
2823
3447
  };
@@ -2895,6 +3519,18 @@ export function startDaemon(config = {}, deps = {}) {
2895
3519
  removeProcessListener("unhandledRejection", onUnhandledRejection);
2896
3520
  };
2897
3521
 
3522
+ // RFC 0035: never leave guest daemons orphaned. They are children of this
3523
+ // process, so without this they would survive the host daemon and keep
3524
+ // serving a share the owner believes is stopped.
3525
+ const stopGuestsOnExit = () => {
3526
+ try {
3527
+ stopAllGuestDaemons("host daemon exiting");
3528
+ } catch {
3529
+ // best effort during shutdown
3530
+ }
3531
+ };
3532
+ process.on("exit", stopGuestsOnExit);
3533
+
2898
3534
  process.on("exit", cleanupLock);
2899
3535
  process.on("SIGINT", onSigInt);
2900
3536
  process.on("SIGTERM", onSigTerm);
@@ -3022,11 +3658,13 @@ export function startDaemon(config = {}, deps = {}) {
3022
3658
  };
3023
3659
  const advertisedCapabilities = [
3024
3660
  "project_path_validation",
3661
+ "project_path_create",
3025
3662
  "project_agents_registry",
3026
3663
  "restart_daemon",
3027
3664
  "refresh_session_inplace",
3028
3665
  "task_attachments_v1",
3029
3666
  CUSTOM_COMMANDS_CAPABILITY,
3667
+ UPDATE_DAEMON_CAPABILITY,
3030
3668
  ];
3031
3669
  if (remoteExecEnabled) {
3032
3670
  advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
@@ -3036,11 +3674,67 @@ export function startDaemon(config = {}, deps = {}) {
3036
3674
  if (ptyTaskCapabilityEnabled) {
3037
3675
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
3038
3676
  }
3039
- if (advertisedCapabilities.length > 0) {
3040
- extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
3677
+ // RFC 0035: strip capabilities a guest must never offer. `ai_manager` stays
3678
+ // advertised on purpose -- the grantee needs to see how much AI quota is left
3679
+ // on the borrowed machine -- but its `switch_account` action is refused at
3680
+ // dispatch, because that one rewrites `~/.codex/auth.json` and would change
3681
+ // the machine owner's own active account.
3682
+ const effectiveCapabilities = IS_GUEST_DAEMON
3683
+ ? filterGuestCapabilities(advertisedCapabilities)
3684
+ : advertisedCapabilities;
3685
+ if (IS_GUEST_DAEMON) {
3686
+ log(
3687
+ `[guest] Running as a shared guest daemon; capabilities=${effectiveCapabilities.join(",")}` +
3688
+ (GUEST_ROOT ? `, root=${GUEST_ROOT}` : "")
3689
+ );
3690
+ // If the host daemon is SIGKILLed, nothing else would stop this process.
3691
+ startOrphanWatchdog({
3692
+ onOrphaned: () => {
3693
+ logError("[guest] Host daemon is gone; shutting down to avoid an orphaned share");
3694
+ exitAndReturn(0);
3695
+ },
3696
+ });
3697
+ }
3698
+ if (effectiveCapabilities.length > 0) {
3699
+ extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
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;
3041
3715
  }
3042
- const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
3716
+
3717
+ const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
3043
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
+ });
3044
3738
  const remoteExecHandlers = remoteExecEnabled
3045
3739
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3046
3740
  : null;
@@ -3169,10 +3863,26 @@ export function startDaemon(config = {}, deps = {}) {
3169
3863
  log(`Daemon Name: ${AGENT_NAME}`);
3170
3864
  log(`Supported Backends: ${SUPPORTED_BACKENDS.join(", ")}`);
3171
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
+
3172
3876
  client.connect().catch((err) => {
3173
3877
  logError(`Failed to connect: ${err}`);
3174
3878
  });
3175
3879
 
3880
+ // RFC 0035: keep one child daemon per accepted share. A guest never
3881
+ // supervises anything itself, or a share chain could nest.
3882
+ if (!IS_GUEST_DAEMON) {
3883
+ void reconcileGuestDaemons();
3884
+ }
3885
+
3176
3886
  if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
3177
3887
  if (installMethod === "homebrew") {
3178
3888
  log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
@@ -3183,6 +3893,9 @@ export function startDaemon(config = {}, deps = {}) {
3183
3893
 
3184
3894
  const runMaintenanceTick = async () => {
3185
3895
  void runDaemonWatchdog();
3896
+ if (!IS_GUEST_DAEMON) {
3897
+ void reconcileGuestDaemons();
3898
+ }
3186
3899
  try {
3187
3900
  await checkForUpdate();
3188
3901
  } catch {
@@ -3683,6 +4396,165 @@ export function startDaemon(config = {}, deps = {}) {
3683
4396
  await restartDaemonProcess("auto-update");
3684
4397
  }
3685
4398
 
4399
+ // ---------------------------------------------------------------------
4400
+ // RFC 0035: guest daemon supervision.
4401
+ //
4402
+ // For each share the backend reports as active, run one child
4403
+ // `conductor daemon` that authenticates as the grantee. The child is an
4404
+ // ordinary daemon in every respect; what makes it a guest is its isolated
4405
+ // CONDUCTOR_HOME/CONDUCTOR_WS and the grantee's scoped token.
4406
+ // ---------------------------------------------------------------------
4407
+ /** shareId -> { child, guestHost, restartTimer, stopping } */
4408
+ const guestDaemons = new Map();
4409
+ // Failure counts must outlive the entry they describe. The restart path
4410
+ // deletes the entry and re-asks the backend (so a share revoked during
4411
+ // backoff stays dead), which means a per-entry counter resets to 0 on every
4412
+ // respawn and the exponential backoff degrades into a permanent 10s loop.
4413
+ const guestFailureCounts = new Map();
4414
+ let guestReconcileInFlight = false;
4415
+
4416
+ function stopGuestDaemon(shareId, reason) {
4417
+ const entry = guestDaemons.get(shareId);
4418
+ if (!entry) return;
4419
+ entry.stopping = true;
4420
+ if (entry.restartTimer) clearTimeout(entry.restartTimer);
4421
+ guestDaemons.delete(shareId);
4422
+ guestFailureCounts.delete(shareId);
4423
+ if (entry.child && entry.child.exitCode === null && !entry.child.killed) {
4424
+ log(`[guest] Stopping guest ${entry.guestHost} (${reason})`);
4425
+ try {
4426
+ entry.child.kill("SIGTERM");
4427
+ } catch {
4428
+ // already gone
4429
+ }
4430
+ }
4431
+ }
4432
+
4433
+ function stopAllGuestDaemons(reason) {
4434
+ for (const shareId of [...guestDaemons.keys()]) {
4435
+ stopGuestDaemon(shareId, reason);
4436
+ }
4437
+ }
4438
+
4439
+ function spawnGuestDaemon(share) {
4440
+ const paths = resolveGuestPaths(share.id, share.workspaceRoot, homeDir);
4441
+ try {
4442
+ writeGuestConfig(
4443
+ paths,
4444
+ buildGuestConfigYaml({
4445
+ agentToken: share.agentToken,
4446
+ backendUrl: BACKEND_HTTP,
4447
+ guestHost: share.guestHost,
4448
+ workspace: paths.workspace,
4449
+ allowCliList: RAW_ALLOW_CLI_LIST,
4450
+ })
4451
+ );
4452
+ } catch (error) {
4453
+ logError(`[guest] Failed to materialize config for ${share.guestHost}: ${error?.message || error}`);
4454
+ return;
4455
+ }
4456
+
4457
+ const env = buildGuestEnv(process.env, paths, {
4458
+ shareId: share.id,
4459
+ explicitRoot: Boolean(share.workspaceRoot),
4460
+ });
4461
+ const child = spawn(
4462
+ process.execPath,
4463
+ [DAEMON_LAUNCHER_PATH, "--config-file", paths.configPath],
4464
+ { env, stdio: "ignore", detached: false }
4465
+ );
4466
+
4467
+ const entry = {
4468
+ child,
4469
+ guestHost: share.guestHost,
4470
+ restartTimer: null,
4471
+ stopping: false,
4472
+ };
4473
+ guestDaemons.set(share.id, entry);
4474
+ log(`[guest] Started guest ${share.guestHost} for ${share.granteeLabel || "grantee"} (pid=${child.pid})`);
4475
+
4476
+ child.on("exit", (code, signal) => {
4477
+ const current = guestDaemons.get(share.id);
4478
+ // Either we asked it to stop, or a newer entry already replaced it.
4479
+ if (!current || current.child !== child || current.stopping) return;
4480
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4481
+ guestFailureCounts.set(share.id, failures);
4482
+ const delay = nextRestartDelayMs(failures);
4483
+ log(
4484
+ `[guest] Guest ${share.guestHost} exited (code=${code}, signal=${signal}); ` +
4485
+ `restarting in ${Math.round(delay / 1000)}s (failure ${failures})`
4486
+ );
4487
+ current.restartTimer = setTimeout(() => {
4488
+ // Don't resurrect from a stale timer: re-check with the backend
4489
+ // instead, so a share revoked while we were backing off stays dead.
4490
+ guestDaemons.delete(share.id);
4491
+ void reconcileGuestDaemons();
4492
+ }, delay);
4493
+ if (typeof current.restartTimer.unref === "function") current.restartTimer.unref();
4494
+ });
4495
+
4496
+ child.on("error", (error) => {
4497
+ // Node emits `error` without `exit` for e.g. ENOENT. Without this the
4498
+ // entry would sit in the map forever: reconcile counts it as running, so
4499
+ // it is never retried and never started.
4500
+ logError(`[guest] Guest ${share.guestHost} failed to spawn: ${error?.message || error}`);
4501
+ const current = guestDaemons.get(share.id);
4502
+ if (!current || current.child !== child || current.stopping) return;
4503
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4504
+ guestFailureCounts.set(share.id, failures);
4505
+ guestDaemons.delete(share.id);
4506
+ const timer = setTimeout(() => void reconcileGuestDaemons(), nextRestartDelayMs(failures));
4507
+ if (typeof timer.unref === "function") timer.unref();
4508
+ });
4509
+ }
4510
+
4511
+ async function fetchActiveShares() {
4512
+ const url = `${BACKEND_HTTP}/api/daemon-shares/mine?daemonHost=${encodeURIComponent(AGENT_NAME)}`;
4513
+ // Without a deadline a hung backend pins `guestReconcileInFlight` for
4514
+ // undici's 300s default, and nothing else clears it -- the supervisor goes
4515
+ // silently deaf for five minutes.
4516
+ const response = await fetch(url, {
4517
+ headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
4518
+ signal: AbortSignal.timeout(15_000),
4519
+ });
4520
+ if (!response.ok) {
4521
+ // A backend that predates this feature returns 404; that is not an error
4522
+ // worth logging on every maintenance tick.
4523
+ if (response.status === 404) return null;
4524
+ throw new Error(`HTTP ${response.status}`);
4525
+ }
4526
+ const body = await response.json();
4527
+ return Array.isArray(body?.shares) ? body.shares : [];
4528
+ }
4529
+
4530
+ async function reconcileGuestDaemons() {
4531
+ if (guestReconcileInFlight || daemonShuttingDown) return;
4532
+ guestReconcileInFlight = true;
4533
+ try {
4534
+ const shares = await fetchActiveShares();
4535
+ if (shares === null) return;
4536
+ const running = new Set(guestDaemons.keys());
4537
+ const { start, stop, skipped } = reconcileGuests(shares, running, MAX_GUEST_DAEMONS);
4538
+ for (const shareId of stop) {
4539
+ stopGuestDaemon(shareId, "share no longer active");
4540
+ }
4541
+ for (const share of start) {
4542
+ spawnGuestDaemon(share);
4543
+ }
4544
+ if (skipped > 0) {
4545
+ // Never drop guests silently -- a share that looks accepted in the UI
4546
+ // but never runs is the hardest kind of bug to report.
4547
+ log(
4548
+ `[guest] ${skipped} share(s) not started: at the ${MAX_GUEST_DAEMONS}-guest limit for this daemon`
4549
+ );
4550
+ }
4551
+ } catch (error) {
4552
+ logError(`[guest] Failed to reconcile guest daemons: ${error?.message || error}`);
4553
+ } finally {
4554
+ guestReconcileInFlight = false;
4555
+ }
4556
+ }
4557
+
3686
4558
  async function handleRestartDaemon(payload) {
3687
4559
  const requestId = payload?.request_id ? String(payload.request_id) : "";
3688
4560
  const targetVersionRaw = payload?.target_version
@@ -3708,6 +4580,22 @@ export function startDaemon(config = {}, deps = {}) {
3708
4580
  return;
3709
4581
  }
3710
4582
 
4583
+ // RFC 0035: a versioned restart runs a *global* `npm install -g`, which
4584
+ // would swap the CLI binary out from under the machine owner's own daemon
4585
+ // and every one of their fire processes. A guest may restart itself, but
4586
+ // it may not change the machine's installed version.
4587
+ if (IS_GUEST_DAEMON && !isGuestRestartAllowed(payload)) {
4588
+ log(
4589
+ `[restart_daemon] Refused (${requestId}): guest daemons cannot install version ${targetVersionRaw}`
4590
+ );
4591
+ sendAgentCommandAck({
4592
+ requestId,
4593
+ eventType: "restart_daemon",
4594
+ accepted: false,
4595
+ }).catch(() => {});
4596
+ return;
4597
+ }
4598
+
3711
4599
  autoUpdateInProgress = true;
3712
4600
  try {
3713
4601
  log(
@@ -3768,6 +4656,8 @@ export function startDaemon(config = {}, deps = {}) {
3768
4656
 
3769
4657
  async function recoverStaleTasks() {
3770
4658
  try {
4659
+ // Never judge a task before startup adoption has had its say.
4660
+ await adoptOrphanedTmuxFiresPromise;
3771
4661
  const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
3772
4662
  method: "GET",
3773
4663
  headers: {
@@ -3800,8 +4690,48 @@ export function startDaemon(config = {}, deps = {}) {
3800
4690
  return;
3801
4691
  }
3802
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;
3803
4733
  await Promise.all(
3804
- staleTasks.map(async (task) => {
4734
+ deadTasks.map(async (task) => {
3805
4735
  const taskId = task?.id;
3806
4736
  if (!taskId) return;
3807
4737
  const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
@@ -3816,12 +4746,13 @@ export function startDaemon(config = {}, deps = {}) {
3816
4746
  if (!patchResp.ok) {
3817
4747
  logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
3818
4748
  } else {
4749
+ killedCount += 1;
3819
4750
  markBackendHttpSuccess();
3820
4751
  }
3821
4752
  }),
3822
4753
  );
3823
4754
 
3824
- log(`Recovered ${staleTasks.length} stale task(s) to killed`);
4755
+ log(`Recovered ${killedCount}/${deadTasks.length} stale task(s) to killed`);
3825
4756
  } catch (error) {
3826
4757
  logError(`recoverStaleTasks error: ${error?.message || error}`);
3827
4758
  }
@@ -3833,6 +4764,7 @@ export function startDaemon(config = {}, deps = {}) {
3833
4764
 
3834
4765
  async function reconcileAssignedTasks() {
3835
4766
  try {
4767
+ await adoptOrphanedTmuxFiresPromise;
3836
4768
  const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
3837
4769
  method: "GET",
3838
4770
  headers: {
@@ -3867,10 +4799,36 @@ export function startDaemon(config = {}, deps = {}) {
3867
4799
  });
3868
4800
 
3869
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
+ }
3870
4822
  for (const task of assigned) {
3871
4823
  const taskId = String(task?.id || "");
3872
4824
  if (!taskId) continue;
3873
- 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 || [])) {
3874
4832
  continue;
3875
4833
  }
3876
4834
  const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
@@ -3906,7 +4864,12 @@ export function startDaemon(config = {}, deps = {}) {
3906
4864
  // half-close). Fires raise their own ws to backend independently — this is
3907
4865
  // the daemon's *own* belief about which tasks it is hosting. The backend
3908
4866
  // arbitrates between this push and the persisted task state.
3909
- 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;
3910
4873
  const aliveTaskIds = getActiveTaskIds();
3911
4874
  if (aliveTaskIds.length === 0) {
3912
4875
  return Promise.resolve();
@@ -4315,89 +5278,12 @@ export function startDaemon(config = {}, deps = {}) {
4315
5278
  }
4316
5279
 
4317
5280
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
4318
- const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
4319
- const entrypointType =
4320
- normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
4321
- normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
4322
- (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
4323
- normalizeOptionalString(normalizedLaunchConfig.toolPreset)
4324
- ? "tool_preset"
4325
- : "shell");
4326
- const preferredShell = resolveDefaultPtyShell({
4327
- explicitShell: normalizedLaunchConfig.shell,
4328
- envShell: process.env.SHELL,
4329
- comspec: process.env.COMSPEC,
4330
- platform: process.platform,
5281
+ return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
5282
+ allowCliList: ALLOW_CLI_LIST,
5283
+ supportedBackends: SUPPORTED_BACKENDS,
4331
5284
  existsSync: existsSyncFn,
5285
+ log,
4332
5286
  });
4333
- const cwd =
4334
- normalizeOptionalString(normalizedLaunchConfig.cwd) ||
4335
- fallbackCwd;
4336
- const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
4337
- const cols = normalizePositiveInt(
4338
- normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
4339
- DEFAULT_TERMINAL_COLS,
4340
- );
4341
- const rows = normalizePositiveInt(
4342
- normalizedLaunchConfig.rows,
4343
- DEFAULT_TERMINAL_ROWS,
4344
- );
4345
-
4346
- if (entrypointType === "tool_preset") {
4347
- const toolPreset =
4348
- normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
4349
- normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
4350
- SUPPORTED_BACKENDS[0] ||
4351
- "codex";
4352
- const cliCommand = ALLOW_CLI_LIST[toolPreset];
4353
- if (!cliCommand) {
4354
- throw new Error(`Unsupported tool preset: ${toolPreset}`);
4355
- }
4356
- return {
4357
- entrypointType,
4358
- toolPreset,
4359
- command: preferredShell,
4360
- args: ["-lc", cliCommand],
4361
- shell: preferredShell,
4362
- cwd,
4363
- env,
4364
- cols,
4365
- rows,
4366
- };
4367
- }
4368
-
4369
- if (entrypointType === "custom") {
4370
- const command = normalizeOptionalString(normalizedLaunchConfig.command);
4371
- if (!command) {
4372
- throw new Error("launch_config.command is required for custom entrypoint");
4373
- }
4374
- const args = Array.isArray(normalizedLaunchConfig.args)
4375
- ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
4376
- : [];
4377
- return {
4378
- entrypointType,
4379
- toolPreset: null,
4380
- command,
4381
- args,
4382
- shell: preferredShell,
4383
- cwd,
4384
- env,
4385
- cols,
4386
- rows,
4387
- };
4388
- }
4389
-
4390
- return {
4391
- entrypointType: "shell",
4392
- toolPreset: null,
4393
- command: preferredShell,
4394
- args: ["-l"],
4395
- shell: preferredShell,
4396
- cwd,
4397
- env,
4398
- cols,
4399
- rows,
4400
- };
4401
5287
  }
4402
5288
 
4403
5289
  function getTerminalChunkByteLength(data) {
@@ -4913,6 +5799,10 @@ export function startDaemon(config = {}, deps = {}) {
4913
5799
  { cwd: worktreeConfig.projectRepoRoot },
4914
5800
  );
4915
5801
  }
5802
+ // The marker lives outside the worktree, so `git worktree remove` does
5803
+ // not take it with the directory. Leaving it behind would let a worktree
5804
+ // later recreated on this path look ready before it is prepared.
5805
+ removeTaskWorktreeReadyMarker(worktreeRoot);
4916
5806
 
4917
5807
  await reportTaskWorktreeCleanupResult({
4918
5808
  requestId,
@@ -5284,6 +6174,26 @@ export function startDaemon(config = {}, deps = {}) {
5284
6174
  return;
5285
6175
  }
5286
6176
  if (event.type === "ai_manager_request") {
6177
+ // RFC 0035: reads are fine and genuinely useful to a guest (it needs to
6178
+ // know how much quota the borrowed machine has left). `switch_account` is
6179
+ // not: `AiManager` resolves the codex auth path from `homedir()`, so a
6180
+ // guest switching accounts renames over `~/.codex/auth.json` and changes
6181
+ // the machine owner's own daemon, running fires, and interactive `codex`.
6182
+ const aiAction = event.payload?.action;
6183
+ if (IS_GUEST_DAEMON && !isGuestAiManagerActionAllowed(aiAction)) {
6184
+ log(`[guest] Refused ai_manager action '${aiAction}' on a shared daemon`);
6185
+ client
6186
+ .send({
6187
+ type: "ai_manager_response",
6188
+ payload: {
6189
+ request_id: event.payload?.request_id,
6190
+ error: "Account switching is disabled on a shared daemon",
6191
+ error_code: "guest_forbidden",
6192
+ },
6193
+ })
6194
+ .catch(() => {});
6195
+ return;
6196
+ }
5287
6197
  handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
5288
6198
  logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
5289
6199
  });
@@ -5332,6 +6242,14 @@ export function startDaemon(config = {}, deps = {}) {
5332
6242
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
5333
6243
  });
5334
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
+ }
5335
6253
  }
5336
6254
 
5337
6255
  function markWatchdogHealthy(signal, at = Date.now()) {
@@ -5406,6 +6324,7 @@ export function startDaemon(config = {}, deps = {}) {
5406
6324
  async function handleValidateProjectPath(payload) {
5407
6325
  const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
5408
6326
  const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
6327
+ const createIfMissing = payload?.create_if_missing === true;
5409
6328
  const validatedAt = new Date().toISOString();
5410
6329
 
5411
6330
  if (!requestId || !rawWorkspacePath) {
@@ -5413,6 +6332,27 @@ export function startDaemon(config = {}, deps = {}) {
5413
6332
  return;
5414
6333
  }
5415
6334
 
6335
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
6336
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
6337
+ // shell and can reach anything the OS user can. It matters because neither
6338
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
6339
+ // absolute path it is handed.
6340
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
6341
+ await client.sendJson({
6342
+ type: "project_path_validated",
6343
+ payload: {
6344
+ request_id: requestId,
6345
+ daemon_host: AGENT_NAME,
6346
+ workspace_path: rawWorkspacePath,
6347
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
6348
+ error_code: "outside_guest_root",
6349
+ validated_at: new Date().toISOString(),
6350
+ },
6351
+ });
6352
+ return;
6353
+ }
6354
+
6355
+
5416
6356
  let result = {
5417
6357
  workspacePath: null,
5418
6358
  repoRoot: null,
@@ -5429,7 +6369,22 @@ export function startDaemon(config = {}, deps = {}) {
5429
6369
 
5430
6370
  try {
5431
6371
  const resolvedPath = path.resolve(rawWorkspacePath);
5432
- if (!existsSyncFn(resolvedPath)) {
6372
+ let createError = null;
6373
+ if (createIfMissing && !existsSyncFn(resolvedPath)) {
6374
+ try {
6375
+ mkdirSyncFn(resolvedPath, { recursive: true });
6376
+ log(`Created workspace path on request: ${resolvedPath}`);
6377
+ } catch (error) {
6378
+ createError = error;
6379
+ }
6380
+ }
6381
+ if (createError) {
6382
+ result = {
6383
+ ...result,
6384
+ error: `Failed to create workspace path on daemon ${AGENT_NAME}: ${createError?.message || createError}`,
6385
+ errorCode: "workspace_create_failed",
6386
+ };
6387
+ } else if (!existsSyncFn(resolvedPath)) {
5433
6388
  result = {
5434
6389
  ...result,
5435
6390
  error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
@@ -5522,6 +6477,27 @@ export function startDaemon(config = {}, deps = {}) {
5522
6477
  return;
5523
6478
  }
5524
6479
 
6480
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
6481
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
6482
+ // shell and can reach anything the OS user can. It matters because neither
6483
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
6484
+ // absolute path it is handed.
6485
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
6486
+ await client.sendJson({
6487
+ type: "project_agents_resolved",
6488
+ payload: {
6489
+ request_id: requestId,
6490
+ daemon_host: AGENT_NAME,
6491
+ workspace_path: rawWorkspacePath,
6492
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
6493
+ error_code: "outside_guest_root",
6494
+ resolved_at: new Date().toISOString(),
6495
+ },
6496
+ });
6497
+ return;
6498
+ }
6499
+
6500
+
5525
6501
  let result = {
5526
6502
  workspacePath: null,
5527
6503
  agents: [],
@@ -5641,6 +6617,7 @@ export function startDaemon(config = {}, deps = {}) {
5641
6617
  }
5642
6618
 
5643
6619
  activeTaskProcesses.delete(taskId);
6620
+ forgetFireSessionRecord(processRecord);
5644
6621
  return true;
5645
6622
  }
5646
6623
 
@@ -5715,6 +6692,15 @@ export function startDaemon(config = {}, deps = {}) {
5715
6692
  return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
5716
6693
  }
5717
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
+
5718
6704
  // RFC 0029: confirm that a task the backend believes is dead is actually
5719
6705
  // still hosting a live fire on this daemon. The check is intentionally
5720
6706
  // conservative:
@@ -5813,6 +6799,7 @@ export function startDaemon(config = {}, deps = {}) {
5813
6799
  entry.stopForceKillTimer = null;
5814
6800
  }
5815
6801
  activeTaskProcesses.delete(taskId);
6802
+ forgetFireSessionRecord(entry);
5816
6803
  }
5817
6804
  entry = undefined;
5818
6805
  }
@@ -5870,7 +6857,12 @@ export function startDaemon(config = {}, deps = {}) {
5870
6857
 
5871
6858
  const processRecord = activeTaskProcesses.get(taskId);
5872
6859
  const ptyRecord = activePtySessions.get(taskId);
5873
- 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) {
5874
6866
  log(`Stop requested for task ${taskId}, but no active process found`);
5875
6867
  sendStopAck(false);
5876
6868
  // "Nothing to stop" IS the terminal answer, and the server cannot infer
@@ -6248,9 +7240,12 @@ export function startDaemon(config = {}, deps = {}) {
6248
7240
  }
6249
7241
 
6250
7242
  const existingTaskRecord = activeTaskProcesses.get(taskId);
6251
- if (existingTaskRecord?.child || pendingTaskStarts.has(taskId)) {
7243
+ if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
6252
7244
  log(
6253
- `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"}`})`,
6254
7249
  );
6255
7250
  sendAgentCommandAck({
6256
7251
  requestId,
@@ -6454,7 +7449,7 @@ export function startDaemon(config = {}, deps = {}) {
6454
7449
  const outputCapture = createChildOutputCapture({ logPath, logStartOffset });
6455
7450
  const spawnedAtMs = Date.now();
6456
7451
 
6457
- activeTaskProcesses.set(taskId, {
7452
+ const activeProcessRecord = {
6458
7453
  child,
6459
7454
  projectId,
6460
7455
  logPath,
@@ -6472,7 +7467,12 @@ export function startDaemon(config = {}, deps = {}) {
6472
7467
  managedByFireBridge: true,
6473
7468
  tmuxSession: tmuxSession || null,
6474
7469
  tmuxMode: Boolean(tmuxSession),
6475
- });
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);
6476
7476
 
6477
7477
  client
6478
7478
  .sendJson({
@@ -6578,6 +7578,7 @@ export function startDaemon(config = {}, deps = {}) {
6578
7578
  clearTimeout(active.stopForceKillTimer);
6579
7579
  }
6580
7580
  activeTaskProcesses.delete(taskId);
7581
+ forgetFireSessionRecord(active);
6581
7582
  const suppressExitStatusReport = suppressedExitStatusReports.has(taskId);
6582
7583
  suppressedExitStatusReports.delete(taskId);
6583
7584
  if (logStream) {
@@ -6768,12 +7769,17 @@ export function startDaemon(config = {}, deps = {}) {
6768
7769
  activeTarget.stopForceKillTimer = null;
6769
7770
  }
6770
7771
  activeTaskProcesses.delete(normalizedTargetTaskId);
7772
+ forgetFireSessionRecord(activeTarget);
6771
7773
  activeTarget = undefined;
6772
7774
  }
6773
7775
  }
6774
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
+
6775
7781
  if (isRefreshSessionInplace) {
6776
- if (!activeTarget?.child) {
7782
+ if (!targetHasLiveFire) {
6777
7783
  reportRestartFailure({
6778
7784
  taskId: normalizedTargetTaskId,
6779
7785
  projectId: normalizedProjectId,
@@ -6784,7 +7790,7 @@ export function startDaemon(config = {}, deps = {}) {
6784
7790
  });
6785
7791
  return;
6786
7792
  }
6787
- } else if (activeTarget?.child) {
7793
+ } else if (targetHasLiveFire) {
6788
7794
  // tmux-mode failures point at the tmux session, not a meaningless pid.
6789
7795
  const description = activeTarget.tmuxMode
6790
7796
  ? `task already active in tmux session ${activeTarget.tmuxSession || "(unknown)"} — stop the task before restarting`
@@ -7169,6 +8175,9 @@ export function startDaemon(config = {}, deps = {}) {
7169
8175
  tmuxMode: Boolean(tmuxSession),
7170
8176
  };
7171
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);
7172
8181
 
7173
8182
  // In tmux mode the Fire process writes directly to logPath via shell
7174
8183
  // redirection inside the tmux session, so the daemon does not pipe
@@ -7259,6 +8268,7 @@ export function startDaemon(config = {}, deps = {}) {
7259
8268
  clearTimeout(active.stopForceKillTimer);
7260
8269
  }
7261
8270
  activeTaskProcesses.delete(normalizedTargetTaskId);
8271
+ forgetFireSessionRecord(active);
7262
8272
  const suppressExitStatusReport = suppressedExitStatusReports.has(normalizedTargetTaskId);
7263
8273
  suppressedExitStatusReports.delete(normalizedTargetTaskId);
7264
8274
  if (logStream) {