u-foo 3.0.21 → 3.0.23

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.
Files changed (34) hide show
  1. package/README.md +13 -7
  2. package/README.zh-CN.md +11 -7
  3. package/dist/tui/darwin-arm64/ufoo-tui +0 -0
  4. package/dist/tui/darwin-x64/ufoo-tui +0 -0
  5. package/dist/tui/linux-arm64/ufoo-tui +0 -0
  6. package/dist/tui/linux-x64/ufoo-tui +0 -0
  7. package/package.json +1 -1
  8. package/src/agents/launch/launcher.js +52 -29
  9. package/src/app/chat/commandExecutor.js +15 -0
  10. package/src/app/chat/daemonConnection.js +7 -2
  11. package/src/app/chat/daemonCoordinator.js +1 -0
  12. package/src/app/chat/daemonTransport.js +21 -5
  13. package/src/app/chat/projectCloseController.js +6 -1
  14. package/src/app/chat/transport.js +84 -8
  15. package/src/app/cli/run.js +44 -7
  16. package/src/config.js +48 -0
  17. package/src/runtime/contracts/eventContract.js +1 -0
  18. package/src/runtime/daemon/agentProcessManager.js +34 -14
  19. package/src/runtime/daemon/controlPlaneService.js +14 -5
  20. package/src/runtime/daemon/endpoint.js +79 -0
  21. package/src/runtime/daemon/globalDaemon.js +394 -0
  22. package/src/runtime/daemon/groupOrchestrator.js +6 -0
  23. package/src/runtime/daemon/index.js +272 -109
  24. package/src/runtime/daemon/mcpHttpServer.js +5 -0
  25. package/src/runtime/daemon/ops.js +48 -17
  26. package/src/runtime/daemon/processLifecycle.js +127 -0
  27. package/src/runtime/daemon/projectContext.js +46 -0
  28. package/src/runtime/daemon/projectRuntime.js +221 -0
  29. package/src/runtime/daemon/projectRuntimeGateway.js +72 -0
  30. package/src/runtime/daemon/projectRuntimeManager.js +268 -0
  31. package/src/runtime/daemon/run.js +66 -12
  32. package/src/runtime/projects/registry.js +26 -4
  33. package/src/runtime/projects/runtimes.js +1 -1
  34. package/src/ui/rustChatHost.js +46 -8
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+
3
+ const {
4
+ canonicalProjectRoot,
5
+ } = require("../projects");
6
+ const { createProjectContext } = require("./projectContext");
7
+ const {
8
+ createProjectRuntime,
9
+ RUNTIME_STATES,
10
+ } = require("./projectRuntime");
11
+
12
+ const DEFAULT_IDLE_GRACE_MS = 5 * 60 * 1000;
13
+ const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
14
+
15
+ class ProjectRuntimeManager {
16
+ constructor(options = {}) {
17
+ this.contextFactory = options.contextFactory || createProjectContext;
18
+ this.runtimeFactory = options.runtimeFactory || createProjectRuntime;
19
+ this.configureRuntime = typeof options.configureRuntime === "function"
20
+ ? options.configureRuntime
21
+ : null;
22
+ this.authorizeProjectRoot = typeof options.authorizeProjectRoot === "function"
23
+ ? options.authorizeProjectRoot
24
+ : (() => true);
25
+ this.idleGraceMs = Number.isFinite(options.idleGraceMs)
26
+ ? Math.max(0, Number(options.idleGraceMs))
27
+ : DEFAULT_IDLE_GRACE_MS;
28
+ this.maxActiveRuntimes = Number.isFinite(options.maxActiveRuntimes)
29
+ ? Math.max(1, Number(options.maxActiveRuntimes))
30
+ : 32;
31
+ this.maxConcurrentRequests = Number.isFinite(options.maxConcurrentRequests)
32
+ ? Math.max(1, Number(options.maxConcurrentRequests))
33
+ : 256;
34
+ this.now = typeof options.now === "function" ? options.now : Date.now;
35
+ this.runtimes = new Map();
36
+ this.activationPromises = new Map();
37
+ this.activeRequestCount = 0;
38
+ this.disposed = false;
39
+ const sweepIntervalMs = Number.isFinite(options.sweepIntervalMs)
40
+ ? Math.max(0, Number(options.sweepIntervalMs))
41
+ : DEFAULT_SWEEP_INTERVAL_MS;
42
+ this.sweepTimer = sweepIntervalMs > 0
43
+ ? setInterval(() => {
44
+ void this.sweepIdle();
45
+ }, sweepIntervalMs)
46
+ : null;
47
+ if (this.sweepTimer && typeof this.sweepTimer.unref === "function") {
48
+ this.sweepTimer.unref();
49
+ }
50
+ }
51
+
52
+ resolveProject(projectRoot, options = {}) {
53
+ const canonicalRoot = canonicalProjectRoot(projectRoot);
54
+ if (this.authorizeProjectRoot(canonicalRoot, options) !== true) {
55
+ const err = new Error(`project runtime access denied: ${canonicalRoot}`);
56
+ err.code = "PROJECT_RUNTIME_ACCESS_DENIED";
57
+ throw err;
58
+ }
59
+ return canonicalRoot;
60
+ }
61
+
62
+ createEntry(projectRoot, options = {}) {
63
+ const previousGeneration = Number(options.previousGeneration) || 0;
64
+ const context = this.contextFactory({
65
+ ...options,
66
+ projectRoot,
67
+ runtimeGeneration: previousGeneration + 1,
68
+ });
69
+ const runtime = this.runtimeFactory(context, options.runtimeOptions || {});
70
+ if (this.configureRuntime) this.configureRuntime(runtime, context);
71
+ const entry = {
72
+ context,
73
+ runtime,
74
+ lastUsedAtMs: this.now(),
75
+ createdAt: new Date(this.now()).toISOString(),
76
+ };
77
+ this.runtimes.set(context.projectId, entry);
78
+ return entry;
79
+ }
80
+
81
+ entryForRoot(projectRoot) {
82
+ const canonicalRoot = canonicalProjectRoot(projectRoot);
83
+ for (const entry of this.runtimes.values()) {
84
+ if (entry.context.projectRoot === canonicalRoot) return entry;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ activeRuntimeCount() {
90
+ let count = 0;
91
+ for (const entry of this.runtimes.values()) {
92
+ if (entry.runtime.state === RUNTIME_STATES.ACTIVE) count += 1;
93
+ }
94
+ return count;
95
+ }
96
+
97
+ async activate(projectRoot, options = {}) {
98
+ if (this.disposed) {
99
+ const err = new Error("project runtime manager is disposed");
100
+ err.code = "PROJECT_RUNTIME_MANAGER_DISPOSED";
101
+ throw err;
102
+ }
103
+ const canonicalRoot = this.resolveProject(projectRoot, options);
104
+ let entry = this.entryForRoot(canonicalRoot);
105
+ if (entry && entry.runtime.state === RUNTIME_STATES.ACTIVE) {
106
+ entry.lastUsedAtMs = this.now();
107
+ return entry.runtime;
108
+ }
109
+ const projectId = entry
110
+ ? entry.context.projectId
111
+ : this.contextFactory({
112
+ ...options,
113
+ projectRoot: canonicalRoot,
114
+ runtimeGeneration: 1,
115
+ }).projectId;
116
+ if (this.activationPromises.has(projectId)) {
117
+ return this.activationPromises.get(projectId);
118
+ }
119
+ const activation = (async () => {
120
+ if (!entry) {
121
+ entry = this.createEntry(canonicalRoot, options);
122
+ } else if (
123
+ entry.runtime.state === RUNTIME_STATES.FAILED
124
+ || entry.runtime.state === RUNTIME_STATES.DISPOSED
125
+ ) {
126
+ entry = await this.recycle(canonicalRoot, options);
127
+ }
128
+ if (
129
+ entry.runtime.state !== RUNTIME_STATES.ACTIVE
130
+ && this.activeRuntimeCount() >= this.maxActiveRuntimes
131
+ ) {
132
+ await this.sweepIdle({ forcePressure: true });
133
+ }
134
+ if (
135
+ entry.runtime.state !== RUNTIME_STATES.ACTIVE
136
+ && this.activeRuntimeCount() >= this.maxActiveRuntimes
137
+ ) {
138
+ const err = new Error(
139
+ `active project runtime limit reached (${this.maxActiveRuntimes})`
140
+ );
141
+ err.code = "PROJECT_RUNTIME_LIMIT";
142
+ throw err;
143
+ }
144
+ await entry.runtime.activate();
145
+ entry.lastUsedAtMs = this.now();
146
+ return entry.runtime;
147
+ })();
148
+ this.activationPromises.set(projectId, activation);
149
+ try {
150
+ return await activation;
151
+ } finally {
152
+ this.activationPromises.delete(projectId);
153
+ }
154
+ }
155
+
156
+ async call(projectRoot, operation, args = {}, requestContext = {}) {
157
+ if (this.activeRequestCount >= this.maxConcurrentRequests) {
158
+ const err = new Error(
159
+ `global project runtime request limit reached (${this.maxConcurrentRequests})`
160
+ );
161
+ err.code = "PROJECT_RUNTIME_REQUEST_LIMIT";
162
+ throw err;
163
+ }
164
+ const runtime = await this.activate(projectRoot, requestContext);
165
+ const entry = this.runtimes.get(runtime.context.projectId);
166
+ this.activeRequestCount += 1;
167
+ if (entry) entry.lastUsedAtMs = this.now();
168
+ try {
169
+ return await runtime.call(operation, args, requestContext);
170
+ } finally {
171
+ this.activeRequestCount -= 1;
172
+ if (entry) entry.lastUsedAtMs = this.now();
173
+ }
174
+ }
175
+
176
+ cancel(projectRoot, requestId) {
177
+ const entry = this.entryForRoot(projectRoot);
178
+ return entry ? entry.runtime.cancel(requestId) : false;
179
+ }
180
+
181
+ async suspend(projectRoot) {
182
+ const entry = this.entryForRoot(projectRoot);
183
+ if (!entry) return false;
184
+ if (entry.runtime.state === RUNTIME_STATES.DORMANT) return true;
185
+ await entry.runtime.suspend();
186
+ return true;
187
+ }
188
+
189
+ async recycle(projectRoot, options = {}) {
190
+ const canonicalRoot = this.resolveProject(projectRoot, options);
191
+ const existing = this.entryForRoot(canonicalRoot);
192
+ const generation = existing ? existing.context.runtimeGeneration : 0;
193
+ if (existing) {
194
+ existing.runtime.dispose();
195
+ this.runtimes.delete(existing.context.projectId);
196
+ }
197
+ return this.createEntry(canonicalRoot, {
198
+ ...options,
199
+ previousGeneration: generation,
200
+ });
201
+ }
202
+
203
+ remove(projectRoot) {
204
+ const entry = this.entryForRoot(projectRoot);
205
+ if (!entry) return false;
206
+ entry.runtime.dispose();
207
+ this.runtimes.delete(entry.context.projectId);
208
+ this.activationPromises.delete(entry.context.projectId);
209
+ return true;
210
+ }
211
+
212
+ async sweepIdle(options = {}) {
213
+ const nowMs = this.now();
214
+ const candidates = Array.from(this.runtimes.values())
215
+ .filter((entry) => entry.runtime.state === RUNTIME_STATES.ACTIVE)
216
+ .sort((a, b) => a.lastUsedAtMs - b.lastUsedAtMs);
217
+ let suspended = 0;
218
+ for (const entry of candidates) {
219
+ const idleForMs = nowMs - entry.lastUsedAtMs;
220
+ const underPressure = options.forcePressure === true
221
+ && this.activeRuntimeCount() >= this.maxActiveRuntimes;
222
+ if (!underPressure && idleForMs < this.idleGraceMs) continue;
223
+ if (!entry.runtime.canSuspend()) continue;
224
+ try {
225
+ await entry.runtime.suspend();
226
+ suspended += 1;
227
+ } catch {
228
+ // A busy/failing runtime is reported by its own status and skipped.
229
+ }
230
+ }
231
+ return suspended;
232
+ }
233
+
234
+ status() {
235
+ return {
236
+ disposed: this.disposed,
237
+ runtime_count: this.runtimes.size,
238
+ active_runtime_count: this.activeRuntimeCount(),
239
+ active_request_count: this.activeRequestCount,
240
+ max_active_runtimes: this.maxActiveRuntimes,
241
+ max_concurrent_requests: this.maxConcurrentRequests,
242
+ runtimes: Array.from(this.runtimes.values())
243
+ .map((entry) => ({
244
+ ...entry.runtime.status(),
245
+ manager_last_used_at: new Date(entry.lastUsedAtMs).toISOString(),
246
+ }))
247
+ .sort((a, b) => a.project_root.localeCompare(b.project_root)),
248
+ };
249
+ }
250
+
251
+ dispose() {
252
+ if (this.disposed) return;
253
+ this.disposed = true;
254
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
255
+ this.sweepTimer = null;
256
+ for (const entry of this.runtimes.values()) {
257
+ entry.runtime.dispose();
258
+ }
259
+ this.runtimes.clear();
260
+ this.activationPromises.clear();
261
+ }
262
+ }
263
+
264
+ module.exports = {
265
+ DEFAULT_IDLE_GRACE_MS,
266
+ DEFAULT_SWEEP_INTERVAL_MS,
267
+ ProjectRuntimeManager,
268
+ };
@@ -1,15 +1,31 @@
1
1
  const path = require("path");
2
+ const fs = require("fs");
2
3
  const { startDaemon, stopDaemon, isRunning } = require("./index");
4
+ const { startGlobalDaemon } = require("./globalDaemon");
3
5
  const { restartDaemonLifecycleSync } = require("./restart");
4
- const { loadConfig, defaultAgentModelForProvider } = require("../../config");
6
+ const {
7
+ loadConfig,
8
+ defaultAgentModelForProvider,
9
+ normalizeDaemonTopology,
10
+ saveGlobalDaemonConfig,
11
+ } = require("../../config");
5
12
  const { resolveNodeExecutable } = require("../process/nodeExecutable");
13
+ const {
14
+ isGlobalControllerProjectRoot,
15
+ resolveGlobalControllerProjectRoot,
16
+ } = require("../projects");
17
+ const { getUfooPaths } = require("../../coordination/state/paths");
6
18
 
7
- function spawnDaemonStart(projectRoot) {
19
+ function spawnDaemonStart(projectRoot, daemonTopology = "") {
8
20
  const { spawn } = require("child_process");
9
21
  const child = spawn(resolveNodeExecutable(), [path.join(__dirname, "..", "..", "..", "bin", "ufoo.js"), "daemon", "start"], {
10
22
  detached: true,
11
23
  stdio: "ignore",
12
- env: { ...process.env, UFOO_DAEMON_CHILD: "1" },
24
+ env: {
25
+ ...process.env,
26
+ UFOO_DAEMON_CHILD: "1",
27
+ ...(daemonTopology ? { UFOO_DAEMON_TOPOLOGY: daemonTopology } : {}),
28
+ },
13
29
  cwd: projectRoot,
14
30
  });
15
31
  child.unref();
@@ -22,8 +38,27 @@ function sleepSync(ms) {
22
38
 
23
39
  function runDaemonCli(argv) {
24
40
  const cmd = argv[1] || "start";
41
+ if (cmd === "topology" || cmd === "--topology") {
42
+ const requested = String(argv[2] || "").trim().toLowerCase();
43
+ if (!["project", "hybrid", "global"].includes(requested)) {
44
+ throw new Error("daemon topology requires project|hybrid|global");
45
+ }
46
+ const saved = saveGlobalDaemonConfig({ daemonTopology: requested });
47
+ // eslint-disable-next-line no-console
48
+ console.log(saved.daemonTopology);
49
+ return;
50
+ }
25
51
  const projectRoot = process.cwd();
26
52
  const config = loadConfig(projectRoot);
53
+ const daemonTopology = normalizeDaemonTopology(
54
+ process.env.UFOO_DAEMON_TOPOLOGY || config.daemonTopology
55
+ );
56
+ const daemonRoot = daemonTopology === "project"
57
+ ? projectRoot
58
+ : resolveGlobalControllerProjectRoot();
59
+ if (daemonTopology !== "project") {
60
+ fs.mkdirSync(getUfooPaths(daemonRoot).ufooDir, { recursive: true });
61
+ }
27
62
  const envProvider = process.env.UFOO_AGENT_PROVIDER;
28
63
  const provider = envProvider || config.agentProvider || "codex-cli";
29
64
  const model =
@@ -31,32 +66,51 @@ function runDaemonCli(argv) {
31
66
  || (envProvider && envProvider !== config.agentProvider ? "" : config.agentModel)
32
67
  || defaultAgentModelForProvider(provider);
33
68
  const resumeMode = process.env.UFOO_FORCE_RESUME === "1" ? "force" : "auto";
34
- const launchMode = config.launchMode || "terminal";
69
+ const useGlobalRuntimeHost =
70
+ isGlobalControllerProjectRoot(daemonRoot)
71
+ && daemonTopology !== "project";
72
+ const startSelectedDaemon = (selectedResumeMode) => {
73
+ if (useGlobalRuntimeHost) {
74
+ return startGlobalDaemon({
75
+ controllerRoot: daemonRoot,
76
+ provider,
77
+ model,
78
+ resumeMode: selectedResumeMode,
79
+ topology: daemonTopology,
80
+ });
81
+ }
82
+ return startDaemon({
83
+ projectRoot: daemonRoot,
84
+ provider,
85
+ model,
86
+ resumeMode: selectedResumeMode,
87
+ });
88
+ };
35
89
 
36
90
  if (cmd === "start" || cmd === "--start") {
37
- if (isRunning(projectRoot)) return;
91
+ if (isRunning(daemonRoot)) return;
38
92
  if (!process.env.UFOO_DAEMON_CHILD) {
39
- spawnDaemonStart(projectRoot);
93
+ spawnDaemonStart(daemonRoot, daemonTopology);
40
94
  return;
41
95
  }
42
- startDaemon({ projectRoot, provider, model, resumeMode });
96
+ startSelectedDaemon(resumeMode);
43
97
  return;
44
98
  }
45
99
  if (cmd === "stop" || cmd === "--stop") {
46
- if (!stopDaemon(projectRoot, { source: process.env.UFOO_DAEMON_STOP_SOURCE || `daemon-cli:${cmd} pid=${process.pid}` })) {
100
+ if (!stopDaemon(daemonRoot, { source: process.env.UFOO_DAEMON_STOP_SOURCE || `daemon-cli:${cmd} pid=${process.pid}` })) {
47
101
  process.exitCode = 1;
48
102
  }
49
103
  return;
50
104
  }
51
105
  if (cmd === "restart" || cmd === "--restart") {
52
106
  const result = restartDaemonLifecycleSync({
53
- projectRoot,
107
+ projectRoot: daemonRoot,
54
108
  isRunning,
55
109
  stopDaemon,
56
110
  startDaemon: () => {
57
- if (!process.env.UFOO_DAEMON_CHILD) return spawnDaemonStart(projectRoot);
111
+ if (!process.env.UFOO_DAEMON_CHILD) return spawnDaemonStart(daemonRoot, daemonTopology);
58
112
  // Manual restart does not auto-resume; crash-recovery is handled on next auto start with stale lock detection.
59
- return startDaemon({ projectRoot, provider, model, resumeMode: "none" });
113
+ return startSelectedDaemon("none");
60
114
  },
61
115
  stopOptions: { source: process.env.UFOO_DAEMON_STOP_SOURCE || `daemon-cli:${cmd} pid=${process.pid}` },
62
116
  sleepSync,
@@ -65,7 +119,7 @@ function runDaemonCli(argv) {
65
119
  return;
66
120
  }
67
121
  if (cmd === "status" || cmd === "--status") {
68
- const running = isRunning(projectRoot);
122
+ const running = isRunning(daemonRoot);
69
123
  // eslint-disable-next-line no-console
70
124
  console.log(running ? "running" : "stopped");
71
125
  return;
@@ -119,7 +119,13 @@ function isSocketAlive(socketPath) {
119
119
 
120
120
  function normalizeStatus(value, fallback = "running") {
121
121
  const raw = String(value || "").trim().toLowerCase();
122
- if (raw === "running" || raw === "stale" || raw === "stopped") return raw;
122
+ if (
123
+ raw === "running"
124
+ || raw === "dormant"
125
+ || raw === "failed"
126
+ || raw === "stale"
127
+ || raw === "stopped"
128
+ ) return raw;
123
129
  return fallback;
124
130
  }
125
131
 
@@ -192,6 +198,21 @@ function markProjectStopped(projectRoot, options = {}) {
192
198
  }, options);
193
199
  }
194
200
 
201
+ function markProjectDormant(projectRoot, options = {}) {
202
+ if (!projectRoot) return null;
203
+ const existing = readProjectRuntimeByRoot(projectRoot, options);
204
+ const paths = getUfooPaths(canonicalizeForRecord(projectRoot));
205
+ return upsertProjectRuntime({
206
+ projectRoot,
207
+ projectName: existing ? existing.project_name : path.basename(projectRoot),
208
+ daemonPid: existing ? existing.daemon_pid : process.pid,
209
+ socketPath: existing ? existing.socket_path : paths.ufooSock,
210
+ status: "dormant",
211
+ lastSeen: new Date().toISOString(),
212
+ lastSwitchAt: existing ? existing.last_switch_at : undefined,
213
+ }, options);
214
+ }
215
+
195
216
  function validateProjectRuntime(entry = {}, options = {}) {
196
217
  if (!entry || typeof entry !== "object") return null;
197
218
  const staleTtlMs = Number.isFinite(options.staleTtlMs) ? options.staleTtlMs : DEFAULT_STALE_TTL_MS;
@@ -207,9 +228,9 @@ function validateProjectRuntime(entry = {}, options = {}) {
207
228
  let status = normalizeStatus(entry.status, "running");
208
229
  if (running) {
209
230
  status = "running";
210
- } else if (status === "stopped") {
211
- // Respect explicit stop state even if pid/socket checks are unavailable.
212
- status = "stopped";
231
+ } else if (status === "stopped" || status === "dormant" || status === "failed") {
232
+ // Respect explicit lifecycle state even when the compatibility project
233
+ // socket is intentionally absent.
213
234
  } else if (ageMs === null || ageMs > staleTtlMs) {
214
235
  status = "stale";
215
236
  }
@@ -273,6 +294,7 @@ module.exports = {
273
294
  runtimeFilePathByProjectRoot,
274
295
  upsertProjectRuntime,
275
296
  markProjectStopped,
297
+ markProjectDormant,
276
298
  listProjectRuntimes,
277
299
  getCurrentProjectRuntime,
278
300
  validateProjectRuntime,
@@ -17,7 +17,7 @@ function filterVisibleProjectRuntimes(rows = []) {
17
17
  const sourceRows = Array.isArray(rows) ? rows : [];
18
18
  return sourceRows.filter((row) => {
19
19
  const status = String((row && row.status) || "").trim().toLowerCase();
20
- return status === "running";
20
+ return status === "running" || status === "dormant";
21
21
  });
22
22
  }
23
23
 
@@ -35,6 +35,10 @@ const {
35
35
  } = require("../runtime/contracts/uiProtocol");
36
36
  const { IPC_REQUEST_TYPES, IPC_RESPONSE_TYPES } = require("../runtime/contracts/eventContract");
37
37
  const { createDaemonMessageRouter } = require("../app/chat/daemonMessageRouter");
38
+ const {
39
+ resolveDaemonEndpoint,
40
+ routeDaemonRequest,
41
+ } = require("../runtime/daemon/endpoint");
38
42
 
39
43
  function stripTags(value) {
40
44
  return String(value || "").replace(/\{[^}]+\}/g, "");
@@ -145,19 +149,23 @@ async function runChatRust(projectRoot, options = {}) {
145
149
  });
146
150
  }
147
151
  await ensureSubscriberId(projectRoot);
148
- if (!env.isRunning(projectRoot)) {
152
+ const initialDaemonEndpoint = resolveDaemonEndpoint(projectRoot);
153
+ const initialDaemonRoot = initialDaemonEndpoint.scope === "global"
154
+ ? initialDaemonEndpoint.controllerRoot
155
+ : initialDaemonEndpoint.projectRoot;
156
+ if (!env.isRunning(initialDaemonRoot)) {
149
157
  env.startDaemon(projectRoot);
150
158
  }
151
159
 
152
- const { socketPath } = require("../runtime/daemon");
153
160
  const { connectWithRetry } = require("../app/chat/transport");
154
161
  const { createDaemonTransport } = require("../app/chat/daemonTransport");
155
162
  const { createDaemonConnection } = require("../app/chat/daemonConnection");
156
163
 
157
- const sock = socketPath(projectRoot);
164
+ const sock = initialDaemonEndpoint.socketPath;
158
165
  const daemonTransport = createDaemonTransport({
159
166
  projectRoot,
160
167
  sockPath: sock,
168
+ daemonRoot: initialDaemonRoot,
161
169
  isRunning: env.isRunning,
162
170
  startDaemon: env.startDaemon,
163
171
  connectWithRetry,
@@ -336,7 +344,16 @@ async function runChatRust(projectRoot, options = {}) {
336
344
  multiSession.stop();
337
345
  }
338
346
 
339
- if (env.globalMode && typeof env.isRunning === "function" && !env.isRunning(root)) {
347
+ const targetEndpoint = resolveDaemonEndpoint(root);
348
+ const targetDaemonRoot = targetEndpoint.scope === "global"
349
+ ? targetEndpoint.controllerRoot
350
+ : targetEndpoint.projectRoot;
351
+ if (
352
+ env.globalMode
353
+ && targetEndpoint.scope === "project"
354
+ && typeof env.isRunning === "function"
355
+ && !env.isRunning(targetDaemonRoot)
356
+ ) {
340
357
  try {
341
358
  const { markProjectStopped } = require("../runtime/projects");
342
359
  markProjectStopped(root);
@@ -365,7 +382,9 @@ async function runChatRust(projectRoot, options = {}) {
365
382
  if (daemonCoordinator && typeof daemonCoordinator.switchProject === "function") {
366
383
  const res = await daemonCoordinator.switchProject({
367
384
  projectRoot: root,
368
- sockPath: socketPath(root),
385
+ sockPath: targetEndpoint.socketPath,
386
+ daemonRoot: targetDaemonRoot,
387
+ transformRequest: (request) => routeDaemonRequest(targetEndpoint, request),
369
388
  autoStart: options.autoStart === true,
370
389
  });
371
390
  if (!res || res.ok !== true) {
@@ -399,9 +418,14 @@ async function runChatRust(projectRoot, options = {}) {
399
418
  return { ok: true, project_root: projectRoot, root: projectRoot };
400
419
  }
401
420
  if (daemonCoordinator && typeof daemonCoordinator.switchProject === "function") {
421
+ const controllerEndpoint = resolveDaemonEndpoint(projectRoot);
402
422
  const res = await daemonCoordinator.switchProject({
403
423
  projectRoot,
404
- sockPath: socketPath(projectRoot),
424
+ sockPath: controllerEndpoint.socketPath,
425
+ daemonRoot: controllerEndpoint.scope === "global"
426
+ ? controllerEndpoint.controllerRoot
427
+ : controllerEndpoint.projectRoot,
428
+ transformRequest: (request) => routeDaemonRequest(controllerEndpoint, request),
405
429
  });
406
430
  if (!res || res.ok !== true) {
407
431
  appendLocal("error", `Switch to global failed: ${(res && res.error) || "switch failed"}`);
@@ -1151,7 +1175,7 @@ async function runChatRust(projectRoot, options = {}) {
1151
1175
  if (!root) return { ok: false, error: "missing project root" };
1152
1176
  try {
1153
1177
  const { createProjectCloseController } = require("../app/chat/projectCloseController");
1154
- const { stopDaemon } = require("../app/chat/transport");
1178
+ const { requestDaemon, stopDaemon } = require("../app/chat/transport");
1155
1179
  const { isRunning } = require("../runtime/daemon");
1156
1180
  const projects = loadGlobalProjectRows(activeProjectRoot);
1157
1181
  const index = projects.findIndex((row) => String(row.root || "") === root);
@@ -1166,8 +1190,21 @@ async function runChatRust(projectRoot, options = {}) {
1166
1190
  })),
1167
1191
  getActiveProjectRoot: () => activeProjectRoot,
1168
1192
  resolveProjectRoot: (row) => String((row && (row.root || row.project_root)) || ""),
1169
- isRunning,
1193
+ isRunning: (targetRoot) => {
1194
+ const endpoint = resolveDaemonEndpoint(targetRoot);
1195
+ return endpoint.scope === "global" ? true : isRunning(targetRoot);
1196
+ },
1170
1197
  stopDaemon,
1198
+ closeProject: async (targetRoot) => {
1199
+ const endpoint = resolveDaemonEndpoint(targetRoot);
1200
+ if (endpoint.scope === "global") {
1201
+ return requestDaemon(targetRoot, {
1202
+ type: IPC_REQUEST_TYPES.CLOSE_PROJECT_RUNTIME,
1203
+ terminate_agents: true,
1204
+ });
1205
+ }
1206
+ return stopDaemon(targetRoot, { source: `project-close:${targetRoot}` });
1207
+ },
1171
1208
  switchProject: async (fallbackRoot) => hostApi.switchToProjectRoot(fallbackRoot),
1172
1209
  refreshProjects: () => publishProjects(),
1173
1210
  logMessage: (kind, text) => {
@@ -1408,6 +1445,7 @@ async function runChatRust(projectRoot, options = {}) {
1408
1445
 
1409
1446
  const daemonConnection = createDaemonConnection({
1410
1447
  connectClient: daemonTransport.connectClient.bind(daemonTransport),
1448
+ transformRequest: (request) => routeDaemonRequest(initialDaemonEndpoint, request),
1411
1449
  handleMessage: (msg) => {
1412
1450
  if (typeof routedMessageHandler === "function" && routedMessageHandler(msg)) {
1413
1451
  return;