@love-moon/conductor-cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  import { resolveResumeContext } from "./fire/resume.js";
39
39
  import {
40
40
  filterRuntimeSupportedAllowCliList,
41
+ inferBuiltInRuntimeBackendFromCommand,
41
42
  listAdvertisedBackends,
42
43
  resolveConfiguredRuntimeBackend,
43
44
  isBuiltInRuntimeBackend,
@@ -46,6 +47,7 @@ import {
46
47
  normalizeRuntimeBackendAlias,
47
48
  normalizeRuntimeBackendName,
48
49
  } from "./runtime-backends.js";
50
+ import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
49
51
  import {
50
52
  PACKAGE_NAME,
51
53
  buildUpgradeCommand,
@@ -67,7 +69,31 @@ import {
67
69
  maskErrorForLogs,
68
70
  redactSecretsForLogs,
69
71
  } from "./handoff-log-mask.js";
72
+ import {
73
+ DAEMON_LOCK_FILE_NAME,
74
+ FORCE_KILL_UNKNOWN_OWNER_ENV_VAR,
75
+ buildDaemonInstanceIdentity,
76
+ compareDaemonLockIdentity,
77
+ describeDaemonLockOwner,
78
+ describeForceRestartRefusal,
79
+ parseDaemonLockState,
80
+ serializeDaemonLock,
81
+ } from "./daemon-lock.js";
70
82
  import { StringDecoder } from "node:string_decoder";
83
+ import {
84
+ MAX_GUEST_DAEMONS,
85
+ buildGuestConfigYaml,
86
+ buildGuestEnv,
87
+ filterGuestCapabilities,
88
+ isGuestAiManagerActionAllowed,
89
+ isGuestRestartAllowed,
90
+ isPathInsideGuestRoot,
91
+ nextRestartDelayMs,
92
+ reconcileGuests,
93
+ startOrphanWatchdog,
94
+ resolveGuestPaths,
95
+ writeGuestConfig,
96
+ } from "./guest-daemon.js";
71
97
 
72
98
  dotenv.config();
73
99
 
@@ -76,6 +102,10 @@ const __dirname = path.dirname(__filename);
76
102
  const PACKAGE_ROOT = path.join(__dirname, "..");
77
103
  const moduleRequire = createRequire(import.meta.url);
78
104
  const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
105
+ // RFC 0035: the launcher a guest daemon child is spawned with. Same binary as
106
+ // the host daemon, so guests always match the installed version and there is
107
+ // no second update path to keep in sync.
108
+ const DAEMON_LAUNCHER_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon.js");
79
109
  const CLI_VERSION = (() => {
80
110
  try {
81
111
  return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
@@ -493,6 +523,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
493
523
  return { helperPath, updated: true };
494
524
  }
495
525
 
526
+ // A tool-preset PTY task runs the configured allow_cli_list command through a
527
+ // login shell, bypassing ai-sdk entirely. claude refuses to start as root with
528
+ // `--dangerously-skip-permissions`, so rewrite the command the same way the
529
+ // ai-sdk session rewrites its permission mode — one shared root check, so the
530
+ // two paths cannot drift.
531
+ export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
532
+ if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
533
+ return cliCommand;
534
+ }
535
+ return resolveClaudeCommandForRoot(cliCommand, env);
536
+ }
537
+
496
538
  export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
497
539
  const normalizedWorkspacePath =
498
540
  typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
@@ -675,6 +717,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
675
717
  projectRepoRoot,
676
718
  projectWorkspacePath,
677
719
  projectRelativePath,
720
+ // Reuse-only members (reviewers in a multi-agent group) share the owner's
721
+ // worktree but must never run `git worktree add` themselves.
722
+ worktreeReuseOnly: normalizeBooleanFlag(
723
+ normalizedLaunchConfig.worktreeReuseOnly ??
724
+ normalizedLaunchConfig.worktree_reuse_only,
725
+ ),
678
726
  };
679
727
  }
680
728
 
@@ -772,6 +820,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
772
820
  };
773
821
  }
774
822
 
823
+ // Module-level so the PTY launch wiring is directly testable: the tool-preset
824
+ // branch has to hand the *child's* env to the root check, and that seam is
825
+ // exactly where a bug hid before (the check read the daemon's own process.env,
826
+ // silently ignoring a per-task IS_SANDBOX=1 opt-out).
827
+ export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
828
+ const allowCliList = deps.allowCliList || {};
829
+ const supportedBackends = Array.isArray(deps.supportedBackends) ? deps.supportedBackends : [];
830
+ const existsSyncFn = deps.existsSync || fs.existsSync;
831
+ const baseEnv = deps.baseEnv || process.env;
832
+ const log = typeof deps.log === "function" ? deps.log : () => {};
833
+ const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
834
+ const entrypointType =
835
+ normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
836
+ normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
837
+ (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
838
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset)
839
+ ? "tool_preset"
840
+ : "shell");
841
+ const preferredShell = resolveDefaultPtyShell({
842
+ explicitShell: normalizedLaunchConfig.shell,
843
+ envShell: process.env.SHELL,
844
+ comspec: process.env.COMSPEC,
845
+ platform: process.platform,
846
+ existsSync: existsSyncFn,
847
+ });
848
+ const cwd =
849
+ normalizeOptionalString(normalizedLaunchConfig.cwd) ||
850
+ fallbackCwd;
851
+ const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
852
+ const cols = normalizePositiveInt(
853
+ normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
854
+ DEFAULT_TERMINAL_COLS,
855
+ );
856
+ const rows = normalizePositiveInt(
857
+ normalizedLaunchConfig.rows,
858
+ DEFAULT_TERMINAL_ROWS,
859
+ );
860
+
861
+ if (entrypointType === "tool_preset") {
862
+ const toolPreset =
863
+ normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
864
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
865
+ supportedBackends[0] ||
866
+ "codex";
867
+ const cliCommand = allowCliList[toolPreset];
868
+ if (!cliCommand) {
869
+ throw new Error(`Unsupported tool preset: ${toolPreset}`);
870
+ }
871
+ const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
872
+ if (launchCommand !== cliCommand) {
873
+ log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
874
+ }
875
+ return {
876
+ entrypointType,
877
+ toolPreset,
878
+ command: preferredShell,
879
+ args: ["-lc", launchCommand],
880
+ shell: preferredShell,
881
+ cwd,
882
+ env,
883
+ cols,
884
+ rows,
885
+ };
886
+ }
887
+
888
+ if (entrypointType === "custom") {
889
+ const command = normalizeOptionalString(normalizedLaunchConfig.command);
890
+ if (!command) {
891
+ throw new Error("launch_config.command is required for custom entrypoint");
892
+ }
893
+ const args = Array.isArray(normalizedLaunchConfig.args)
894
+ ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
895
+ : [];
896
+ return {
897
+ entrypointType,
898
+ toolPreset: null,
899
+ command,
900
+ args,
901
+ shell: preferredShell,
902
+ cwd,
903
+ env,
904
+ cols,
905
+ rows,
906
+ };
907
+ }
908
+
909
+ return {
910
+ entrypointType: "shell",
911
+ toolPreset: null,
912
+ command: preferredShell,
913
+ args: ["-l"],
914
+ shell: preferredShell,
915
+ cwd,
916
+ env,
917
+ cols,
918
+ rows,
919
+ };
920
+ }
921
+
922
+
775
923
  export function startDaemon(config = {}, deps = {}) {
776
924
  const exitFn = deps.exit || process.exit;
777
925
  const killFn = deps.kill || process.kill;
@@ -881,6 +1029,24 @@ export function startDaemon(config = {}, deps = {}) {
881
1029
  // warning and silently fall back to direct spawn rather than failing every
882
1030
  // create_task with ENOENT.
883
1031
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
1032
+ // RFC 0035: a guest daemon runs on someone else's machine as a different
1033
+ // account. It keeps the ordinary daemon code path but drops the capabilities
1034
+ // that would mutate state the machine's OWNER depends on.
1035
+ const IS_GUEST_DAEMON =
1036
+ userConfig.conductor_guest === true ||
1037
+ fileConfig?.conductorGuest === true ||
1038
+ Boolean(normalizeOptionalString(process.env.CONDUCTOR_GUEST_SHARE_ID));
1039
+ // Only set when the share carries an explicit `workspaceRoot`, i.e. the owner
1040
+ // deliberately scoped where the guest should work. Absent that, a guest binds
1041
+ // projects wherever any other daemon could -- inventing a confinement the
1042
+ // owner never asked for would make the guest gratuitously less capable, and
1043
+ // it was never a security boundary anyway (the grantee's agent runs a shell).
1044
+ const GUEST_ROOT = normalizeOptionalString(process.env.CONDUCTOR_GUEST_ROOT);
1045
+ // A guest resolves `remote_exec` from its own config exactly like any other
1046
+ // daemon. It is not withheld: the grantee already has a shell here through
1047
+ // AI tasks and `pty_task`'s custom entrypoint, so blocking the scriptable
1048
+ // path would cost function without removing reach (RFC 0034 makes the same
1049
+ // argument). An owner who wants it off can set `remote_exec: false`.
884
1050
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
885
1051
 
886
1052
  // Get allow_cli_list from config
@@ -911,6 +1077,17 @@ export function startDaemon(config = {}, deps = {}) {
911
1077
  readFileSync: deps.readFileSync || fs.readFileSync,
912
1078
  }));
913
1079
  const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
1080
+ // Fingerprint of *this* daemon instance, persisted into daemon.pid so that a
1081
+ // later `--force` can tell "restart myself" apart from "kill whoever happens
1082
+ // to hold the lock". WORKSPACE_ROOT defaults to $HOME/ws, so unrelated
1083
+ // instances collide on the same lock file by default.
1084
+ const daemonInstanceIdentity = buildDaemonInstanceIdentity({
1085
+ conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
1086
+ configPath: effectiveConfigPath,
1087
+ workspaceRoot: WORKSPACE_ROOT,
1088
+ daemonName: AGENT_NAME,
1089
+ backendUrl: BACKEND_HTTP,
1090
+ });
914
1091
  const lockHandoffToken =
915
1092
  normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
916
1093
  normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
@@ -2453,6 +2630,79 @@ export function startDaemon(config = {}, deps = {}) {
2453
2630
  });
2454
2631
  }
2455
2632
 
2633
+ // In-flight worktree preparations keyed on the on-disk root. Group members
2634
+ // (worker + reviewers) start almost simultaneously and would otherwise all
2635
+ // pass the `.git` existence check below and race on `git worktree add -b`
2636
+ // for the same branch: one wins and the losers fail with
2637
+ // "Failed to prepare git worktree". Serializing per root also benefits the
2638
+ // pre-existing fork/branch sharing path.
2639
+ const taskWorktreePreparations = new Map();
2640
+
2641
+ // Readiness marker, written by the owner only after the worktree is FULLY
2642
+ // prepared. `.git` alone is not a readiness signal: it appears the moment
2643
+ // `git worktree add` returns, while submodules and symlinks are still
2644
+ // pending. An owner that dies in between (e.g. submodule sync hits its
2645
+ // timeout) would otherwise leave `.git` behind and let reuse-only members
2646
+ // walk into a half-built directory.
2647
+ //
2648
+ // It lives NEXT TO the worktree, not inside it: an untracked file inside
2649
+ // would make `git status --porcelain` report the worktree as dirty and block
2650
+ // non-forced cleanup.
2651
+ function taskWorktreeReadyMarkerPath(worktreeRoot) {
2652
+ return `${worktreeRoot}.ready`;
2653
+ }
2654
+
2655
+ function removeTaskWorktreeReadyMarker(worktreeRoot) {
2656
+ const markerPath = taskWorktreeReadyMarkerPath(worktreeRoot);
2657
+ if (!existsSyncFn(markerPath)) {
2658
+ return;
2659
+ }
2660
+ try {
2661
+ unlinkSyncFn(markerPath);
2662
+ } catch (error) {
2663
+ if (error?.code !== "ENOENT") {
2664
+ logError(
2665
+ `[worktree] failed to clear ready marker for ${worktreeRoot}: ${error?.message || error}`,
2666
+ );
2667
+ }
2668
+ }
2669
+ }
2670
+
2671
+ // Both must hold. Requiring `.git` too means a marker left behind by a
2672
+ // worktree that was removed outside our cleanup path cannot fake readiness.
2673
+ function isTaskWorktreeReady(worktreeRoot, gitMarkerPath) {
2674
+ return (
2675
+ existsSyncFn(gitMarkerPath) &&
2676
+ existsSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot))
2677
+ );
2678
+ }
2679
+
2680
+ async function waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath }) {
2681
+ const deadline = Date.now() + TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS;
2682
+ for (;;) {
2683
+ // When the owner is preparing in this same daemon process we await the
2684
+ // whole preparation (worktree add + submodules + symlinks). Polling the
2685
+ // marker additionally covers an owner that already finished, or one
2686
+ // running in a different process.
2687
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
2688
+ if (inflight) {
2689
+ await inflight;
2690
+ }
2691
+ if (isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
2692
+ return;
2693
+ }
2694
+ if (Date.now() >= deadline) {
2695
+ throw new Error(
2696
+ `Timed out waiting for shared git worktree ${worktreeRoot} for ${taskId}. ` +
2697
+ `Its owner task may have failed midway through preparation.`,
2698
+ );
2699
+ }
2700
+ await new Promise((resolve) =>
2701
+ setTimeout(resolve, TASK_WORKTREE_REUSE_POLL_INTERVAL_MS),
2702
+ );
2703
+ }
2704
+ }
2705
+
2456
2706
  async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
2457
2707
  const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
2458
2708
  if (!worktreeConfig) {
@@ -2465,7 +2715,50 @@ export function startDaemon(config = {}, deps = {}) {
2465
2715
  );
2466
2716
  const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
2467
2717
  const gitMarkerPath = path.join(worktreeRoot, ".git");
2718
+
2719
+ if (worktreeConfig.worktreeReuseOnly) {
2720
+ await waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath });
2721
+ mkdirSyncFn(finalCwd, { recursive: true });
2722
+ return finalCwd;
2723
+ }
2724
+
2725
+ const inflight = taskWorktreePreparations.get(worktreeRoot);
2726
+ if (inflight) {
2727
+ await inflight;
2728
+ }
2729
+ // Prepare when we are the first owner here, or when the preparation we
2730
+ // waited on did not leave a fully-prepared worktree (then we take over).
2731
+ if (!inflight || !isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
2732
+ const preparation = prepareTaskWorktree({
2733
+ taskId,
2734
+ worktreeConfig,
2735
+ worktreeRoot,
2736
+ gitMarkerPath,
2737
+ finalCwd,
2738
+ });
2739
+ // Waiters must never inherit our rejection; they re-check `.git` instead.
2740
+ taskWorktreePreparations.set(worktreeRoot, preparation.catch(() => {}));
2741
+ try {
2742
+ await preparation;
2743
+ } finally {
2744
+ taskWorktreePreparations.delete(worktreeRoot);
2745
+ }
2746
+ }
2747
+ return finalCwd;
2748
+ }
2749
+
2750
+ async function prepareTaskWorktree({
2751
+ taskId,
2752
+ worktreeConfig,
2753
+ worktreeRoot,
2754
+ gitMarkerPath,
2755
+ finalCwd,
2756
+ }) {
2468
2757
  if (!existsSyncFn(gitMarkerPath)) {
2758
+ // A worktree removed outside our cleanup path can leave a stale marker
2759
+ // behind. Clear it before recreating, so a reuse-only member cannot read
2760
+ // "ready" while `git worktree add` is still running.
2761
+ removeTaskWorktreeReadyMarker(worktreeRoot);
2469
2762
  const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
2470
2763
  if (syncBranch) {
2471
2764
  try {
@@ -2563,7 +2856,8 @@ export function startDaemon(config = {}, deps = {}) {
2563
2856
  projectWorkspacePath: worktreeConfig.projectWorkspacePath,
2564
2857
  finalCwd,
2565
2858
  });
2566
- return finalCwd;
2859
+ // Publish readiness last: every step above has to have succeeded.
2860
+ writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
2567
2861
  }
2568
2862
 
2569
2863
  const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
@@ -2596,6 +2890,17 @@ export function startDaemon(config = {}, deps = {}) {
2596
2890
  process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
2597
2891
  120_000,
2598
2892
  );
2893
+ // A reuse-only member waits for the owner to create the shared worktree.
2894
+ // The bound has to cover a cold clone's submodule sync, hence the generous
2895
+ // default; it only ever elapses when the owner never starts or hard-fails.
2896
+ const TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS = parsePositiveInt(
2897
+ process.env.CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS,
2898
+ 180_000,
2899
+ );
2900
+ const TASK_WORKTREE_REUSE_POLL_INTERVAL_MS = parsePositiveInt(
2901
+ process.env.CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS,
2902
+ 250,
2903
+ );
2599
2904
  const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
2600
2905
  process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
2601
2906
  1000,
@@ -2637,36 +2942,7 @@ export function startDaemon(config = {}, deps = {}) {
2637
2942
  DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
2638
2943
  );
2639
2944
 
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
- };
2945
+ const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
2670
2946
 
2671
2947
  const hasMatchingLockHandoff = (lockState) => {
2672
2948
  if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
@@ -2705,7 +2981,7 @@ export function startDaemon(config = {}, deps = {}) {
2705
2981
  return exitAndReturn(1);
2706
2982
  }
2707
2983
 
2708
- const LOCK_FILE = path.join(WORKSPACE_ROOT, "daemon.pid");
2984
+ const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
2709
2985
  try {
2710
2986
  if (skipPidLockCheck) {
2711
2987
  log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
@@ -2722,6 +2998,21 @@ export function startDaemon(config = {}, deps = {}) {
2722
2998
  const alive = isProcessAlive(pid);
2723
2999
  if (alive) {
2724
3000
  if (config.FORCE) {
3001
+ const forceRefusal = describeForceRestartRefusal({
3002
+ lockState,
3003
+ identity: daemonInstanceIdentity,
3004
+ lockFile: LOCK_FILE,
3005
+ env: process.env,
3006
+ });
3007
+ if (forceRefusal) {
3008
+ logError(forceRefusal);
3009
+ return exitAndReturn(1);
3010
+ }
3011
+ if (compareDaemonLockIdentity(lockState, daemonInstanceIdentity) === "unknown") {
3012
+ log(
3013
+ `${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR} is set: stopping PID ${pid} even though the lock file carries no instance identity`,
3014
+ );
3015
+ }
2725
3016
  log(`Force enabled: stopping existing daemon PID ${pid}`);
2726
3017
  let alreadyExited = false;
2727
3018
  try {
@@ -2763,7 +3054,9 @@ export function startDaemon(config = {}, deps = {}) {
2763
3054
  unlinkSyncFn(LOCK_FILE);
2764
3055
  }
2765
3056
  } else {
2766
- logError(`Daemon already running with PID ${pid}`);
3057
+ logError(
3058
+ `Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
3059
+ );
2767
3060
  return exitAndReturn(1);
2768
3061
  }
2769
3062
  } else {
@@ -2784,7 +3077,10 @@ export function startDaemon(config = {}, deps = {}) {
2784
3077
  unlinkSyncFn(LOCK_FILE);
2785
3078
  }
2786
3079
  }
2787
- writeFileSyncFn(LOCK_FILE, process.pid.toString());
3080
+ writeFileSyncFn(
3081
+ LOCK_FILE,
3082
+ serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
3083
+ );
2788
3084
  }
2789
3085
  } catch (err) {
2790
3086
  logError("Failed to acquire lock:", err);
@@ -2811,13 +3107,15 @@ export function startDaemon(config = {}, deps = {}) {
2811
3107
  if (skipPidLockCheck) {
2812
3108
  return;
2813
3109
  }
3110
+ // Keeps the instance identity attached while the handoff window is open so
3111
+ // an unrelated instance racing a `--force` during a restart still sees who
3112
+ // owns the lock.
2814
3113
  writeFileSyncFn(
2815
3114
  LOCK_FILE,
2816
- JSON.stringify({
3115
+ serializeDaemonLock({
2817
3116
  pid: handoffFromPid,
2818
- handoff_from_pid: handoffFromPid,
2819
- handoff_token: handoffToken,
2820
- handoff_expires_at: handoffExpiresAt,
3117
+ identity: daemonInstanceIdentity,
3118
+ handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
2821
3119
  }),
2822
3120
  );
2823
3121
  };
@@ -2895,6 +3193,18 @@ export function startDaemon(config = {}, deps = {}) {
2895
3193
  removeProcessListener("unhandledRejection", onUnhandledRejection);
2896
3194
  };
2897
3195
 
3196
+ // RFC 0035: never leave guest daemons orphaned. They are children of this
3197
+ // process, so without this they would survive the host daemon and keep
3198
+ // serving a share the owner believes is stopped.
3199
+ const stopGuestsOnExit = () => {
3200
+ try {
3201
+ stopAllGuestDaemons("host daemon exiting");
3202
+ } catch {
3203
+ // best effort during shutdown
3204
+ }
3205
+ };
3206
+ process.on("exit", stopGuestsOnExit);
3207
+
2898
3208
  process.on("exit", cleanupLock);
2899
3209
  process.on("SIGINT", onSigInt);
2900
3210
  process.on("SIGTERM", onSigTerm);
@@ -3022,6 +3332,7 @@ export function startDaemon(config = {}, deps = {}) {
3022
3332
  };
3023
3333
  const advertisedCapabilities = [
3024
3334
  "project_path_validation",
3335
+ "project_path_create",
3025
3336
  "project_agents_registry",
3026
3337
  "restart_daemon",
3027
3338
  "refresh_session_inplace",
@@ -3036,10 +3347,31 @@ export function startDaemon(config = {}, deps = {}) {
3036
3347
  if (ptyTaskCapabilityEnabled) {
3037
3348
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
3038
3349
  }
3039
- if (advertisedCapabilities.length > 0) {
3040
- extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
3350
+ // RFC 0035: strip capabilities a guest must never offer. `ai_manager` stays
3351
+ // advertised on purpose -- the grantee needs to see how much AI quota is left
3352
+ // on the borrowed machine -- but its `switch_account` action is refused at
3353
+ // dispatch, because that one rewrites `~/.codex/auth.json` and would change
3354
+ // the machine owner's own active account.
3355
+ const effectiveCapabilities = IS_GUEST_DAEMON
3356
+ ? filterGuestCapabilities(advertisedCapabilities)
3357
+ : advertisedCapabilities;
3358
+ if (IS_GUEST_DAEMON) {
3359
+ log(
3360
+ `[guest] Running as a shared guest daemon; capabilities=${effectiveCapabilities.join(",")}` +
3361
+ (GUEST_ROOT ? `, root=${GUEST_ROOT}` : "")
3362
+ );
3363
+ // If the host daemon is SIGKILLed, nothing else would stop this process.
3364
+ startOrphanWatchdog({
3365
+ onOrphaned: () => {
3366
+ logError("[guest] Host daemon is gone; shutting down to avoid an orphaned share");
3367
+ exitAndReturn(0);
3368
+ },
3369
+ });
3370
+ }
3371
+ if (effectiveCapabilities.length > 0) {
3372
+ extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
3041
3373
  }
3042
- const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
3374
+ const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
3043
3375
  const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
3044
3376
  const remoteExecHandlers = remoteExecEnabled
3045
3377
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
@@ -3173,6 +3505,12 @@ export function startDaemon(config = {}, deps = {}) {
3173
3505
  logError(`Failed to connect: ${err}`);
3174
3506
  });
3175
3507
 
3508
+ // RFC 0035: keep one child daemon per accepted share. A guest never
3509
+ // supervises anything itself, or a share chain could nest.
3510
+ if (!IS_GUEST_DAEMON) {
3511
+ void reconcileGuestDaemons();
3512
+ }
3513
+
3176
3514
  if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
3177
3515
  if (installMethod === "homebrew") {
3178
3516
  log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
@@ -3183,6 +3521,9 @@ export function startDaemon(config = {}, deps = {}) {
3183
3521
 
3184
3522
  const runMaintenanceTick = async () => {
3185
3523
  void runDaemonWatchdog();
3524
+ if (!IS_GUEST_DAEMON) {
3525
+ void reconcileGuestDaemons();
3526
+ }
3186
3527
  try {
3187
3528
  await checkForUpdate();
3188
3529
  } catch {
@@ -3683,6 +4024,165 @@ export function startDaemon(config = {}, deps = {}) {
3683
4024
  await restartDaemonProcess("auto-update");
3684
4025
  }
3685
4026
 
4027
+ // ---------------------------------------------------------------------
4028
+ // RFC 0035: guest daemon supervision.
4029
+ //
4030
+ // For each share the backend reports as active, run one child
4031
+ // `conductor daemon` that authenticates as the grantee. The child is an
4032
+ // ordinary daemon in every respect; what makes it a guest is its isolated
4033
+ // CONDUCTOR_HOME/CONDUCTOR_WS and the grantee's scoped token.
4034
+ // ---------------------------------------------------------------------
4035
+ /** shareId -> { child, guestHost, restartTimer, stopping } */
4036
+ const guestDaemons = new Map();
4037
+ // Failure counts must outlive the entry they describe. The restart path
4038
+ // deletes the entry and re-asks the backend (so a share revoked during
4039
+ // backoff stays dead), which means a per-entry counter resets to 0 on every
4040
+ // respawn and the exponential backoff degrades into a permanent 10s loop.
4041
+ const guestFailureCounts = new Map();
4042
+ let guestReconcileInFlight = false;
4043
+
4044
+ function stopGuestDaemon(shareId, reason) {
4045
+ const entry = guestDaemons.get(shareId);
4046
+ if (!entry) return;
4047
+ entry.stopping = true;
4048
+ if (entry.restartTimer) clearTimeout(entry.restartTimer);
4049
+ guestDaemons.delete(shareId);
4050
+ guestFailureCounts.delete(shareId);
4051
+ if (entry.child && entry.child.exitCode === null && !entry.child.killed) {
4052
+ log(`[guest] Stopping guest ${entry.guestHost} (${reason})`);
4053
+ try {
4054
+ entry.child.kill("SIGTERM");
4055
+ } catch {
4056
+ // already gone
4057
+ }
4058
+ }
4059
+ }
4060
+
4061
+ function stopAllGuestDaemons(reason) {
4062
+ for (const shareId of [...guestDaemons.keys()]) {
4063
+ stopGuestDaemon(shareId, reason);
4064
+ }
4065
+ }
4066
+
4067
+ function spawnGuestDaemon(share) {
4068
+ const paths = resolveGuestPaths(share.id, share.workspaceRoot, homeDir);
4069
+ try {
4070
+ writeGuestConfig(
4071
+ paths,
4072
+ buildGuestConfigYaml({
4073
+ agentToken: share.agentToken,
4074
+ backendUrl: BACKEND_HTTP,
4075
+ guestHost: share.guestHost,
4076
+ workspace: paths.workspace,
4077
+ allowCliList: RAW_ALLOW_CLI_LIST,
4078
+ })
4079
+ );
4080
+ } catch (error) {
4081
+ logError(`[guest] Failed to materialize config for ${share.guestHost}: ${error?.message || error}`);
4082
+ return;
4083
+ }
4084
+
4085
+ const env = buildGuestEnv(process.env, paths, {
4086
+ shareId: share.id,
4087
+ explicitRoot: Boolean(share.workspaceRoot),
4088
+ });
4089
+ const child = spawn(
4090
+ process.execPath,
4091
+ [DAEMON_LAUNCHER_PATH, "--config-file", paths.configPath],
4092
+ { env, stdio: "ignore", detached: false }
4093
+ );
4094
+
4095
+ const entry = {
4096
+ child,
4097
+ guestHost: share.guestHost,
4098
+ restartTimer: null,
4099
+ stopping: false,
4100
+ };
4101
+ guestDaemons.set(share.id, entry);
4102
+ log(`[guest] Started guest ${share.guestHost} for ${share.granteeLabel || "grantee"} (pid=${child.pid})`);
4103
+
4104
+ child.on("exit", (code, signal) => {
4105
+ const current = guestDaemons.get(share.id);
4106
+ // Either we asked it to stop, or a newer entry already replaced it.
4107
+ if (!current || current.child !== child || current.stopping) return;
4108
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4109
+ guestFailureCounts.set(share.id, failures);
4110
+ const delay = nextRestartDelayMs(failures);
4111
+ log(
4112
+ `[guest] Guest ${share.guestHost} exited (code=${code}, signal=${signal}); ` +
4113
+ `restarting in ${Math.round(delay / 1000)}s (failure ${failures})`
4114
+ );
4115
+ current.restartTimer = setTimeout(() => {
4116
+ // Don't resurrect from a stale timer: re-check with the backend
4117
+ // instead, so a share revoked while we were backing off stays dead.
4118
+ guestDaemons.delete(share.id);
4119
+ void reconcileGuestDaemons();
4120
+ }, delay);
4121
+ if (typeof current.restartTimer.unref === "function") current.restartTimer.unref();
4122
+ });
4123
+
4124
+ child.on("error", (error) => {
4125
+ // Node emits `error` without `exit` for e.g. ENOENT. Without this the
4126
+ // entry would sit in the map forever: reconcile counts it as running, so
4127
+ // it is never retried and never started.
4128
+ logError(`[guest] Guest ${share.guestHost} failed to spawn: ${error?.message || error}`);
4129
+ const current = guestDaemons.get(share.id);
4130
+ if (!current || current.child !== child || current.stopping) return;
4131
+ const failures = (guestFailureCounts.get(share.id) || 0) + 1;
4132
+ guestFailureCounts.set(share.id, failures);
4133
+ guestDaemons.delete(share.id);
4134
+ const timer = setTimeout(() => void reconcileGuestDaemons(), nextRestartDelayMs(failures));
4135
+ if (typeof timer.unref === "function") timer.unref();
4136
+ });
4137
+ }
4138
+
4139
+ async function fetchActiveShares() {
4140
+ const url = `${BACKEND_HTTP}/api/daemon-shares/mine?daemonHost=${encodeURIComponent(AGENT_NAME)}`;
4141
+ // Without a deadline a hung backend pins `guestReconcileInFlight` for
4142
+ // undici's 300s default, and nothing else clears it -- the supervisor goes
4143
+ // silently deaf for five minutes.
4144
+ const response = await fetch(url, {
4145
+ headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
4146
+ signal: AbortSignal.timeout(15_000),
4147
+ });
4148
+ if (!response.ok) {
4149
+ // A backend that predates this feature returns 404; that is not an error
4150
+ // worth logging on every maintenance tick.
4151
+ if (response.status === 404) return null;
4152
+ throw new Error(`HTTP ${response.status}`);
4153
+ }
4154
+ const body = await response.json();
4155
+ return Array.isArray(body?.shares) ? body.shares : [];
4156
+ }
4157
+
4158
+ async function reconcileGuestDaemons() {
4159
+ if (guestReconcileInFlight || daemonShuttingDown) return;
4160
+ guestReconcileInFlight = true;
4161
+ try {
4162
+ const shares = await fetchActiveShares();
4163
+ if (shares === null) return;
4164
+ const running = new Set(guestDaemons.keys());
4165
+ const { start, stop, skipped } = reconcileGuests(shares, running, MAX_GUEST_DAEMONS);
4166
+ for (const shareId of stop) {
4167
+ stopGuestDaemon(shareId, "share no longer active");
4168
+ }
4169
+ for (const share of start) {
4170
+ spawnGuestDaemon(share);
4171
+ }
4172
+ if (skipped > 0) {
4173
+ // Never drop guests silently -- a share that looks accepted in the UI
4174
+ // but never runs is the hardest kind of bug to report.
4175
+ log(
4176
+ `[guest] ${skipped} share(s) not started: at the ${MAX_GUEST_DAEMONS}-guest limit for this daemon`
4177
+ );
4178
+ }
4179
+ } catch (error) {
4180
+ logError(`[guest] Failed to reconcile guest daemons: ${error?.message || error}`);
4181
+ } finally {
4182
+ guestReconcileInFlight = false;
4183
+ }
4184
+ }
4185
+
3686
4186
  async function handleRestartDaemon(payload) {
3687
4187
  const requestId = payload?.request_id ? String(payload.request_id) : "";
3688
4188
  const targetVersionRaw = payload?.target_version
@@ -3708,6 +4208,22 @@ export function startDaemon(config = {}, deps = {}) {
3708
4208
  return;
3709
4209
  }
3710
4210
 
4211
+ // RFC 0035: a versioned restart runs a *global* `npm install -g`, which
4212
+ // would swap the CLI binary out from under the machine owner's own daemon
4213
+ // and every one of their fire processes. A guest may restart itself, but
4214
+ // it may not change the machine's installed version.
4215
+ if (IS_GUEST_DAEMON && !isGuestRestartAllowed(payload)) {
4216
+ log(
4217
+ `[restart_daemon] Refused (${requestId}): guest daemons cannot install version ${targetVersionRaw}`
4218
+ );
4219
+ sendAgentCommandAck({
4220
+ requestId,
4221
+ eventType: "restart_daemon",
4222
+ accepted: false,
4223
+ }).catch(() => {});
4224
+ return;
4225
+ }
4226
+
3711
4227
  autoUpdateInProgress = true;
3712
4228
  try {
3713
4229
  log(
@@ -4315,89 +4831,12 @@ export function startDaemon(config = {}, deps = {}) {
4315
4831
  }
4316
4832
 
4317
4833
  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,
4834
+ return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
4835
+ allowCliList: ALLOW_CLI_LIST,
4836
+ supportedBackends: SUPPORTED_BACKENDS,
4331
4837
  existsSync: existsSyncFn,
4838
+ log,
4332
4839
  });
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
4840
  }
4402
4841
 
4403
4842
  function getTerminalChunkByteLength(data) {
@@ -4913,6 +5352,10 @@ export function startDaemon(config = {}, deps = {}) {
4913
5352
  { cwd: worktreeConfig.projectRepoRoot },
4914
5353
  );
4915
5354
  }
5355
+ // The marker lives outside the worktree, so `git worktree remove` does
5356
+ // not take it with the directory. Leaving it behind would let a worktree
5357
+ // later recreated on this path look ready before it is prepared.
5358
+ removeTaskWorktreeReadyMarker(worktreeRoot);
4916
5359
 
4917
5360
  await reportTaskWorktreeCleanupResult({
4918
5361
  requestId,
@@ -5284,6 +5727,26 @@ export function startDaemon(config = {}, deps = {}) {
5284
5727
  return;
5285
5728
  }
5286
5729
  if (event.type === "ai_manager_request") {
5730
+ // RFC 0035: reads are fine and genuinely useful to a guest (it needs to
5731
+ // know how much quota the borrowed machine has left). `switch_account` is
5732
+ // not: `AiManager` resolves the codex auth path from `homedir()`, so a
5733
+ // guest switching accounts renames over `~/.codex/auth.json` and changes
5734
+ // the machine owner's own daemon, running fires, and interactive `codex`.
5735
+ const aiAction = event.payload?.action;
5736
+ if (IS_GUEST_DAEMON && !isGuestAiManagerActionAllowed(aiAction)) {
5737
+ log(`[guest] Refused ai_manager action '${aiAction}' on a shared daemon`);
5738
+ client
5739
+ .send({
5740
+ type: "ai_manager_response",
5741
+ payload: {
5742
+ request_id: event.payload?.request_id,
5743
+ error: "Account switching is disabled on a shared daemon",
5744
+ error_code: "guest_forbidden",
5745
+ },
5746
+ })
5747
+ .catch(() => {});
5748
+ return;
5749
+ }
5287
5750
  handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
5288
5751
  logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
5289
5752
  });
@@ -5406,6 +5869,7 @@ export function startDaemon(config = {}, deps = {}) {
5406
5869
  async function handleValidateProjectPath(payload) {
5407
5870
  const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
5408
5871
  const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
5872
+ const createIfMissing = payload?.create_if_missing === true;
5409
5873
  const validatedAt = new Date().toISOString();
5410
5874
 
5411
5875
  if (!requestId || !rawWorkspacePath) {
@@ -5413,6 +5877,27 @@ export function startDaemon(config = {}, deps = {}) {
5413
5877
  return;
5414
5878
  }
5415
5879
 
5880
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
5881
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
5882
+ // shell and can reach anything the OS user can. It matters because neither
5883
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
5884
+ // absolute path it is handed.
5885
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
5886
+ await client.sendJson({
5887
+ type: "project_path_validated",
5888
+ payload: {
5889
+ request_id: requestId,
5890
+ daemon_host: AGENT_NAME,
5891
+ workspace_path: rawWorkspacePath,
5892
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
5893
+ error_code: "outside_guest_root",
5894
+ validated_at: new Date().toISOString(),
5895
+ },
5896
+ });
5897
+ return;
5898
+ }
5899
+
5900
+
5416
5901
  let result = {
5417
5902
  workspacePath: null,
5418
5903
  repoRoot: null,
@@ -5429,7 +5914,22 @@ export function startDaemon(config = {}, deps = {}) {
5429
5914
 
5430
5915
  try {
5431
5916
  const resolvedPath = path.resolve(rawWorkspacePath);
5432
- if (!existsSyncFn(resolvedPath)) {
5917
+ let createError = null;
5918
+ if (createIfMissing && !existsSyncFn(resolvedPath)) {
5919
+ try {
5920
+ mkdirSyncFn(resolvedPath, { recursive: true });
5921
+ log(`Created workspace path on request: ${resolvedPath}`);
5922
+ } catch (error) {
5923
+ createError = error;
5924
+ }
5925
+ }
5926
+ if (createError) {
5927
+ result = {
5928
+ ...result,
5929
+ error: `Failed to create workspace path on daemon ${AGENT_NAME}: ${createError?.message || createError}`,
5930
+ errorCode: "workspace_create_failed",
5931
+ };
5932
+ } else if (!existsSyncFn(resolvedPath)) {
5433
5933
  result = {
5434
5934
  ...result,
5435
5935
  error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
@@ -5522,6 +6022,27 @@ export function startDaemon(config = {}, deps = {}) {
5522
6022
  return;
5523
6023
  }
5524
6024
 
6025
+ // RFC 0035: keep a guest inside the directory its owner set aside. This is
6026
+ // misuse prevention, not a security boundary -- the guest's AI agent runs a
6027
+ // shell and can reach anything the OS user can. It matters because neither
6028
+ // handler is otherwise bounded: this one will `mkdirSync(recursive)` any
6029
+ // absolute path it is handed.
6030
+ if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
6031
+ await client.sendJson({
6032
+ type: "project_agents_resolved",
6033
+ payload: {
6034
+ request_id: requestId,
6035
+ daemon_host: AGENT_NAME,
6036
+ workspace_path: rawWorkspacePath,
6037
+ error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
6038
+ error_code: "outside_guest_root",
6039
+ resolved_at: new Date().toISOString(),
6040
+ },
6041
+ });
6042
+ return;
6043
+ }
6044
+
6045
+
5525
6046
  let result = {
5526
6047
  workspacePath: null,
5527
6048
  agents: [],