u-foo 3.0.21 → 3.0.22

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.
@@ -3,7 +3,11 @@ const os = require("os");
3
3
  const { spawnSync } = require("child_process");
4
4
  const net = require("net");
5
5
  const fs = require("fs");
6
- const { socketPath, isRunning } = require("../../runtime/daemon");
6
+ const { isRunning } = require("../../runtime/daemon");
7
+ const {
8
+ resolveDaemonEndpoint,
9
+ routeDaemonRequest,
10
+ } = require("../../runtime/daemon/endpoint");
7
11
  const { runBusCoreCommand } = require("./busCoreCommands");
8
12
  const { runCtxCommand } = require("./ctxCoreCommands");
9
13
  const { runOnlineCommand } = require("./onlineCoreCommands");
@@ -59,10 +63,24 @@ async function connectWithRetry(sockPath, retries, delayMs) {
59
63
  }
60
64
 
61
65
  async function ensureDaemonRunning(projectRoot) {
62
- if (isRunning(projectRoot)) return;
66
+ const endpoint = resolveDaemonEndpoint(projectRoot);
67
+ const daemonRoot = endpoint.scope === "global"
68
+ ? endpoint.controllerRoot
69
+ : endpoint.projectRoot;
70
+ if (isRunning(daemonRoot)) return;
63
71
  const repoRoot = getPackageRoot();
64
- run(resolveNodeExecutable(), [path.join(repoRoot, "bin", "ufoo.js"), "daemon", "start"]);
65
- const sock = socketPath(projectRoot);
72
+ run(
73
+ resolveNodeExecutable(),
74
+ [path.join(repoRoot, "bin", "ufoo.js"), "daemon", "start"],
75
+ {
76
+ cwd: daemonRoot,
77
+ env: {
78
+ ...process.env,
79
+ UFOO_DAEMON_TOPOLOGY: endpoint.topology,
80
+ },
81
+ }
82
+ );
83
+ const sock = endpoint.socketPath;
66
84
  for (let i = 0; i < 30; i += 1) {
67
85
  if (fs.existsSync(sock)) {
68
86
  return;
@@ -74,7 +92,8 @@ async function ensureDaemonRunning(projectRoot) {
74
92
  }
75
93
 
76
94
  async function sendDaemonRequest(projectRoot, payload) {
77
- const sock = socketPath(projectRoot);
95
+ const endpoint = resolveDaemonEndpoint(projectRoot);
96
+ const sock = endpoint.socketPath;
78
97
  const client = await connectWithRetry(sock, 25, 200);
79
98
  if (!client) {
80
99
  throw new Error("Failed to connect to ufoo daemon");
@@ -125,7 +144,7 @@ async function sendDaemonRequest(projectRoot, payload) {
125
144
  cleanup();
126
145
  reject(err);
127
146
  });
128
- client.write(`${JSON.stringify(payload)}\n`);
147
+ client.write(`${JSON.stringify(routeDaemonRequest(endpoint, payload))}\n`);
129
148
  });
130
149
  }
131
150
 
@@ -186,6 +205,17 @@ function collectHostLaunchRequestContext(env = process.env) {
186
205
  return context;
187
206
  }
188
207
 
208
+ function collectTmuxLaunchRequestContext(env = process.env) {
209
+ const tmuxTarget = String(env.UFOO_TMUX_TARGET || "").trim();
210
+ const tmuxPane = String(env.UFOO_TMUX_PANE || env.TMUX_PANE || "").trim();
211
+ const tmuxSession = String(env.UFOO_TMUX_SESSION || "").trim();
212
+ const context = {};
213
+ if (tmuxTarget) context.tmux_target = tmuxTarget;
214
+ if (tmuxPane) context.tmux_pane = tmuxPane;
215
+ if (tmuxSession) context.tmux_session = tmuxSession;
216
+ return context;
217
+ }
218
+
189
219
  function collectOption(value, previous) {
190
220
  const next = Array.isArray(previous) ? previous.slice() : [];
191
221
  const parts = String(value || "")
@@ -567,10 +597,12 @@ async function runCli(argv) {
567
597
  .option("--start", "Start daemon")
568
598
  .option("--stop", "Stop daemon")
569
599
  .option("--status", "Check daemon status")
600
+ .option("--topology <mode>", "Set machine-wide daemon topology: project, hybrid, or global")
570
601
  .action((opts) => {
571
602
  const repoRoot = getPackageRoot();
572
603
  const args = ["daemon"];
573
- if (opts.start) args.push("start");
604
+ if (opts.topology) args.push("topology", opts.topology);
605
+ else if (opts.start) args.push("start");
574
606
  else if (opts.stop) args.push("stop");
575
607
  else if (opts.status) args.push("status");
576
608
  run(process.execPath, [path.join(repoRoot, "bin", "ufoo.js"), ...args]);
@@ -769,6 +801,7 @@ async function runCli(argv) {
769
801
  prompt_profile: opts.profile || "",
770
802
  count: 1,
771
803
  ...collectHostLaunchRequestContext(),
804
+ ...collectTmuxLaunchRequestContext(),
772
805
  });
773
806
  const reply = resp?.data?.reply || `Launching ${normalizedAgent} agent...`;
774
807
  console.log(reply);
@@ -832,6 +865,7 @@ async function runCli(argv) {
832
865
  count: 1,
833
866
  launch_scope: scope,
834
867
  ...collectHostLaunchRequestContext(),
868
+ ...collectTmuxLaunchRequestContext(),
835
869
  });
836
870
  console.log(resp?.data?.reply || `Launched ${agent} role ${promptProfile}`);
837
871
  } catch (err) {
@@ -1200,6 +1234,7 @@ async function runCli(argv) {
1200
1234
  instance: opts.instance || "",
1201
1235
  dry_run: opts.dryRun === true,
1202
1236
  ...collectHostLaunchRequestContext(),
1237
+ ...collectTmuxLaunchRequestContext(),
1203
1238
  });
1204
1239
  if (opts.json) {
1205
1240
  console.log(JSON.stringify(resp?.data || {}, null, 2));
@@ -2175,6 +2210,7 @@ async function runCli(argv) {
2175
2210
  instance,
2176
2211
  dry_run: dryRun,
2177
2212
  ...collectHostLaunchRequestContext(),
2213
+ ...collectTmuxLaunchRequestContext(),
2178
2214
  });
2179
2215
  if (outputJson) {
2180
2216
  console.log(JSON.stringify(resp?.data || {}, null, 2));
@@ -2311,6 +2347,7 @@ async function runCli(argv) {
2311
2347
  count: 1,
2312
2348
  launch_scope: scope,
2313
2349
  ...collectHostLaunchRequestContext(),
2350
+ ...collectTmuxLaunchRequestContext(),
2314
2351
  });
2315
2352
  console.log(resp?.data?.reply || `Launched ${agent} role ${profile}`);
2316
2353
  return;
package/src/config.js CHANGED
@@ -34,6 +34,7 @@ const SETTINGS_MODEL_DEFAULTS = Object.freeze({
34
34
 
35
35
  const DEFAULT_CONFIG = {
36
36
  launchMode: "auto",
37
+ daemonTopology: "global",
37
38
  agentProvider: "codex-cli",
38
39
  controllerMode: "main",
39
40
  mcpPort: 47631,
@@ -67,6 +68,14 @@ function normalizeLaunchMode(value) {
67
68
  return "auto";
68
69
  }
69
70
 
71
+ function normalizeDaemonTopology(value) {
72
+ const raw = String(value || "").trim().toLowerCase();
73
+ if (raw === "project") return "project";
74
+ if (raw === "hybrid") return "hybrid";
75
+ if (raw === "global") return "global";
76
+ return DEFAULT_CONFIG.daemonTopology;
77
+ }
78
+
70
79
  function normalizeAgentProvider(value) {
71
80
  if (value === "claude-cli") return "claude-cli";
72
81
  if (value === "agy-cli" || value === "agy" || value === "antigravity") return "agy-cli";
@@ -175,6 +184,7 @@ function loadJsonSafe(filePath) {
175
184
  function loadConfig(projectRoot) {
176
185
  try {
177
186
  const raw = loadJsonSafe(configPath(projectRoot));
187
+ const globalRaw = loadJsonSafe(globalConfigPath());
178
188
  const agentProvider = normalizeAgentProvider(raw.agentProvider);
179
189
  const routerProvider = normalizeModel(
180
190
  raw.routerProvider,
@@ -184,6 +194,11 @@ function loadConfig(projectRoot) {
184
194
  ...DEFAULT_CONFIG,
185
195
  ...raw,
186
196
  launchMode: normalizeLaunchMode(raw.launchMode),
197
+ daemonTopology: normalizeDaemonTopology(
198
+ Object.prototype.hasOwnProperty.call(raw, "daemonTopology")
199
+ ? raw.daemonTopology
200
+ : globalRaw.daemonTopology
201
+ ),
187
202
  agentProvider,
188
203
  agentModel: normalizeModel(raw.agentModel, defaultAgentModelForProvider(agentProvider)),
189
204
  routerProvider,
@@ -238,11 +253,22 @@ function saveConfig(projectRoot, config) {
238
253
  ...existing,
239
254
  ...projectUpdates,
240
255
  };
256
+ const hasProjectDaemonTopology =
257
+ Object.prototype.hasOwnProperty.call(existing, "daemonTopology")
258
+ || Object.prototype.hasOwnProperty.call(projectUpdates, "daemonTopology");
241
259
  // Remove any stale ucode fields from project config
242
260
  for (const f of UCODE_FIELDS) {
243
261
  delete merged[f];
244
262
  }
245
263
  merged.launchMode = normalizeLaunchMode(merged.launchMode);
264
+ if (hasProjectDaemonTopology) {
265
+ merged.daemonTopology = normalizeDaemonTopology(merged.daemonTopology);
266
+ } else {
267
+ // Topology is a machine-wide rollout setting unless a project explicitly
268
+ // opts into an override. Do not persist the default and accidentally pin
269
+ // this project to project-daemon mode forever.
270
+ delete merged.daemonTopology;
271
+ }
246
272
  merged.agentProvider = normalizeAgentProvider(merged.agentProvider);
247
273
  merged.agentModel = typeof merged.agentModel === "string" ? merged.agentModel.trim() : "";
248
274
  merged.routerProvider = typeof merged.routerProvider === "string" ? merged.routerProvider.trim() : "";
@@ -286,13 +312,35 @@ function saveGlobalUcodeConfig(updates = {}) {
286
312
  return merged;
287
313
  }
288
314
 
315
+ function loadGlobalDaemonConfig() {
316
+ const raw = loadJsonSafe(globalConfigPath());
317
+ return {
318
+ daemonTopology: normalizeDaemonTopology(raw.daemonTopology),
319
+ };
320
+ }
321
+
322
+ function saveGlobalDaemonConfig(updates = {}) {
323
+ const target = globalConfigPath();
324
+ fs.mkdirSync(path.dirname(target), { recursive: true });
325
+ const existing = loadJsonSafe(target);
326
+ const merged = { ...existing };
327
+ if (Object.prototype.hasOwnProperty.call(updates, "daemonTopology")) {
328
+ merged.daemonTopology = normalizeDaemonTopology(updates.daemonTopology);
329
+ }
330
+ fs.writeFileSync(target, JSON.stringify(merged, null, 2));
331
+ return loadGlobalDaemonConfig();
332
+ }
333
+
289
334
  module.exports = {
290
335
  SETTINGS_MODEL_DEFAULTS,
291
336
  loadConfig,
292
337
  saveConfig,
293
338
  loadGlobalUcodeConfig,
294
339
  saveGlobalUcodeConfig,
340
+ loadGlobalDaemonConfig,
341
+ saveGlobalDaemonConfig,
295
342
  normalizeLaunchMode,
343
+ normalizeDaemonTopology,
296
344
  normalizeAgentProvider,
297
345
  providerKey,
298
346
  sameModelProvider,
@@ -24,6 +24,7 @@ const IPC_REQUEST_TYPES = {
24
24
  CONTROL_PLANE_CANCEL: "control_plane_cancel",
25
25
  MCP_STATUS: "mcp_status",
26
26
  MCP_RESTART: "mcp_restart",
27
+ CLOSE_PROJECT_RUNTIME: "close_project_runtime",
27
28
  };
28
29
 
29
30
  const IPC_RESPONSE_TYPES = {
@@ -5,7 +5,7 @@ const EventBus = require("../../coordination/bus");
5
5
  class AgentProcessManager {
6
6
  constructor(projectRoot) {
7
7
  this.projectRoot = projectRoot;
8
- this.processes = new Map(); // subscriber_id -> child_process
8
+ this.processes = new Map(); // subscriber_id -> { child, onExit, onError }
9
9
  }
10
10
 
11
11
  /**
@@ -14,9 +14,7 @@ class AgentProcessManager {
14
14
  register(subscriberId, childProcess) {
15
15
  if (!subscriberId || !childProcess) return;
16
16
 
17
- this.processes.set(subscriberId, childProcess);
18
-
19
- childProcess.on("exit", (code, signal) => {
17
+ const onExit = (code, signal) => {
20
18
  this.processes.delete(subscriberId);
21
19
 
22
20
  // 自动清理 bus 状态
@@ -32,19 +30,26 @@ class AgentProcessManager {
32
30
  } catch (err) {
33
31
  console.error(`[daemon] Failed to cleanup ${subscriberId}:`, err.message);
34
32
  }
35
- });
33
+ };
36
34
 
37
- childProcess.on("error", (err) => {
35
+ const onError = (err) => {
38
36
  console.error(`[daemon] Agent ${subscriberId} error:`, err.message);
39
37
  this.processes.delete(subscriberId);
38
+ };
39
+ this.processes.set(subscriberId, {
40
+ child: childProcess,
41
+ onExit,
42
+ onError,
40
43
  });
44
+ childProcess.on("exit", onExit);
45
+ childProcess.on("error", onError);
41
46
  }
42
47
 
43
48
  /**
44
49
  * 获取运行中的进程
45
50
  */
46
51
  get(subscriberId) {
47
- return this.processes.get(subscriberId);
52
+ return this.processes.get(subscriberId)?.child;
48
53
  }
49
54
 
50
55
  /**
@@ -57,13 +62,28 @@ class AgentProcessManager {
57
62
  /**
58
63
  * 清理所有子进程
59
64
  */
60
- cleanup() {
61
- for (const [subscriberId, child] of this.processes.entries()) {
62
- try {
63
- child.kill("SIGTERM");
64
- console.log(`[daemon] Killed agent ${subscriberId}`);
65
- } catch {
66
- // ignore
65
+ cleanup(options = {}) {
66
+ const terminate = options.terminate !== false;
67
+ for (const [subscriberId, entry] of this.processes.entries()) {
68
+ const { child, onExit, onError } = entry;
69
+ if (terminate) {
70
+ try {
71
+ child.kill("SIGTERM");
72
+ console.log(`[daemon] Killed agent ${subscriberId}`);
73
+ } catch {
74
+ // ignore
75
+ }
76
+ } else {
77
+ // Global-daemon ownership is logical, not a parent/child lifetime
78
+ // contract. Let the runner survive daemon replacement and reconnect
79
+ // through its durable bus state.
80
+ try {
81
+ child.removeListener("exit", onExit);
82
+ child.removeListener("error", onError);
83
+ if (typeof child.unref === "function") child.unref();
84
+ } catch {
85
+ // best-effort detachment
86
+ }
67
87
  }
68
88
  }
69
89
  this.processes.clear();
@@ -11,7 +11,11 @@ const {
11
11
  const { subscriberToSafeName } = require("../../coordination/bus/utils");
12
12
  const { normalizeReportInput } = require("../../coordination/report/store");
13
13
  const { enqueueAgentReport } = require("./reportControlBus");
14
- const { isRunning, socketPath } = require("./index");
14
+ const { isRunning } = require("./index");
15
+ const {
16
+ resolveDaemonEndpoint,
17
+ routeDaemonRequest,
18
+ } = require("./endpoint");
15
19
  const { IPC_REQUEST_TYPES } = require("../contracts/eventContract");
16
20
  const {
17
21
  applyProjectNicknamePrefix,
@@ -128,11 +132,16 @@ function assertAgentHandle(bus, subscriber, args = {}, options = {}) {
128
132
  }
129
133
 
130
134
  function notifyDaemonRefresh(projectRoot) {
131
- if (!isRunning(projectRoot)) return;
132
- const sock = socketPath(projectRoot);
135
+ const endpoint = resolveDaemonEndpoint(projectRoot);
136
+ const daemonRoot = endpoint.scope === "global"
137
+ ? endpoint.controllerRoot
138
+ : endpoint.projectRoot;
139
+ if (!isRunning(daemonRoot)) return;
133
140
  try {
134
- const client = net.createConnection(sock, () => {
135
- client.write(`${JSON.stringify({ type: IPC_REQUEST_TYPES.REFRESH_STATUS })}\n`);
141
+ const client = net.createConnection(endpoint.socketPath, () => {
142
+ client.write(`${JSON.stringify(routeDaemonRequest(endpoint, {
143
+ type: IPC_REQUEST_TYPES.REFRESH_STATUS,
144
+ }))}\n`);
136
145
  client.end();
137
146
  });
138
147
  client.on("error", () => {});
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+
5
+ const {
6
+ loadConfig,
7
+ normalizeDaemonTopology,
8
+ } = require("../../config");
9
+ const { getUfooPaths } = require("../../coordination/state/paths");
10
+ const {
11
+ normalizeProjectRoot,
12
+ resolveGlobalControllerProjectRoot,
13
+ } = require("../projects");
14
+
15
+ function isSocketFile(filePath) {
16
+ try {
17
+ return fs.statSync(filePath).isSocket();
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function resolveEffectiveDaemonTopology(projectRoot, options = {}) {
24
+ const config = options.config || loadConfig(projectRoot);
25
+ return normalizeDaemonTopology(
26
+ options.topology
27
+ || process.env.UFOO_DAEMON_TOPOLOGY
28
+ || config.daemonTopology
29
+ );
30
+ }
31
+
32
+ function resolveDaemonEndpoint(projectRoot, options = {}) {
33
+ const canonicalRoot = normalizeProjectRoot(projectRoot);
34
+ const controllerRoot = normalizeProjectRoot(
35
+ options.controllerRoot || resolveGlobalControllerProjectRoot()
36
+ );
37
+ const topology = resolveEffectiveDaemonTopology(canonicalRoot, options);
38
+ const projectSocket = getUfooPaths(canonicalRoot).ufooSock;
39
+ const globalSocket = getUfooPaths(controllerRoot).ufooSock;
40
+ let scope = "project";
41
+ let sockPath = projectSocket;
42
+
43
+ if (canonicalRoot === controllerRoot || topology === "global") {
44
+ scope = "global";
45
+ sockPath = globalSocket;
46
+ } else if (topology === "hybrid") {
47
+ const preferCompatibilitySocket = options.preferCompatibilitySocket !== false;
48
+ if (!preferCompatibilitySocket || !isSocketFile(projectSocket)) {
49
+ scope = "global";
50
+ sockPath = globalSocket;
51
+ }
52
+ }
53
+
54
+ return {
55
+ topology,
56
+ scope,
57
+ socketPath: sockPath,
58
+ projectRoot: canonicalRoot,
59
+ controllerRoot,
60
+ routeProjectRoot: scope === "global" && canonicalRoot !== controllerRoot
61
+ ? canonicalRoot
62
+ : "",
63
+ };
64
+ }
65
+
66
+ function routeDaemonRequest(endpoint, request = {}) {
67
+ if (!endpoint || !endpoint.routeProjectRoot) return { ...request };
68
+ return {
69
+ ...request,
70
+ project_root: request.project_root || request.projectRoot || endpoint.routeProjectRoot,
71
+ };
72
+ }
73
+
74
+ module.exports = {
75
+ isSocketFile,
76
+ resolveEffectiveDaemonTopology,
77
+ resolveDaemonEndpoint,
78
+ routeDaemonRequest,
79
+ };