u-foo 3.0.20 → 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.
package/README.md CHANGED
@@ -18,8 +18,8 @@ Package: [u-foo on npm](https://www.npmjs.com/package/u-foo)
18
18
  ## Highlights
19
19
 
20
20
  - One TUI dashboard for launching, watching, messaging, and resuming agents.
21
- - Project daemon over `.ufoo/run/ufoo.sock` for launch/resume, reports, groups,
22
- cron, and controller routing.
21
+ - One user-scoped daemon over `~/.ufoo/run/ufoo.sock`, hosting isolated project
22
+ runtimes for launch/resume, reports, groups, cron, and controller routing.
23
23
  - Project-local event bus for agent-to-agent messages, wakeups, queue checks,
24
24
  and activation.
25
25
  - Shared context primitives: decisions, durable memory, prompt history, reports,
@@ -127,8 +127,9 @@ singleton listener with `ufoo mcp status` and `ufoo mcp restart`.
127
127
  ```text
128
128
  ufoo / ufoo chat
129
129
  -> src/app/chat + src/ui/rustChatHost + crates/ufoo-tui
130
- -> project daemon over .ufoo/run/ufoo.sock
131
- -> runtime daemon launch/resume/recover/reports/cron/groups
130
+ -> global daemon over ~/.ufoo/run/ufoo.sock
131
+ -> isolated ProjectRuntime selected by project_root
132
+ -> runtime launch/resume/recover/reports/cron/groups
132
133
  -> orchestration router, group templates, solo roles
133
134
  -> agents launch/providers/internal/controller/activity
134
135
  -> coordination bus/context/memory/history/report/state/status
@@ -139,10 +140,14 @@ ufoo mcp stdio proxy ----------------------+
139
140
  -> home-scoped global controller daemon
140
141
  (one MCP listener and tool router)
141
142
  -> ~/.ufoo/projects/runtime
142
- -> ProjectRuntimeGateway
143
- -> selected project daemon for bus/report/activity/wait state
143
+ -> managed ProjectRuntimeGateway
144
+ -> selected in-process ProjectRuntime for bus/report/activity/wait state
144
145
  ```
145
146
 
147
+ Global topology is the default. `ufoo daemon --topology hybrid` temporarily
148
+ enables project compatibility sockets, and `--topology project` selects the
149
+ legacy per-project daemon rollback path.
150
+
146
151
  ### Agent Delivery Modes
147
152
 
148
153
  ufoo supports two Agent delivery modes, selected only from the host Agent's
@@ -182,7 +187,8 @@ ufoo -g
182
187
  ```
183
188
 
184
189
  `ufoo` opens the current project chat. `ufoo -g` opens global chat for switching
185
- between registered projects. The project daemon is started as needed.
190
+ between registered projects. The global daemon starts once and project runtimes
191
+ activate lazily.
186
192
 
187
193
  ### Chat Commands
188
194
 
package/README.zh-CN.md CHANGED
@@ -17,8 +17,8 @@ npm 包:[u-foo](https://www.npmjs.com/package/u-foo)
17
17
  ## 亮点
18
18
 
19
19
  - 一个 TUI 仪表盘,用来启动、观察、消息通知和恢复多个 Agent。
20
- - 项目 daemon 通过 `.ufoo/run/ufoo.sock` 管理启动/恢复、report、group、
21
- cron 和 controller 路由。
20
+ - 唯一的用户级 daemon 通过 `~/.ufoo/run/ufoo.sock` 托管隔离的项目
21
+ runtime,管理启动/恢复、report、group、cron 和 controller 路由。
22
22
  - 项目内事件总线支持 Agent 间消息、唤醒、队列检查和终端激活。
23
23
  - 共享上下文能力:decision、durable memory、prompt history、report 和
24
24
  agent registry state。
@@ -116,8 +116,9 @@ Agent 注册或项目状态。用 `ufoo mcp status` 检查唯一 listener,用
116
116
  ```text
117
117
  ufoo / ufoo chat
118
118
  -> src/app/chat + src/ui/rustChatHost + crates/ufoo-tui
119
- -> project daemon over .ufoo/run/ufoo.sock
120
- -> runtime daemon launch/resume/recover/reports/cron/groups
119
+ -> global daemon over ~/.ufoo/run/ufoo.sock
120
+ -> isolated ProjectRuntime selected by project_root
121
+ -> runtime launch/resume/recover/reports/cron/groups
121
122
  -> orchestration router, group templates, solo roles
122
123
  -> agents launch/providers/internal/controller/activity
123
124
  -> coordination bus/context/memory/history/report/state/status
@@ -128,10 +129,13 @@ ufoo mcp stdio proxy ----------------------+
128
129
  -> home-scoped global controller daemon
129
130
  -> one MCP listener and tool router
130
131
  -> ~/.ufoo/projects/runtime
131
- -> ProjectRuntimeGateway
132
- -> selected project daemon for bus/report/activity/wait state
132
+ -> managed ProjectRuntimeGateway
133
+ -> selected in-process ProjectRuntime for bus/report/activity/wait state
133
134
  ```
134
135
 
136
+ 默认使用 global topology。`ufoo daemon --topology hybrid` 可临时启用项目
137
+ 兼容 socket,`--topology project` 则切回旧的逐项目 daemon 回滚路径。
138
+
135
139
  ### Agent 消息投递模式
136
140
 
137
141
  ufoo 支持两种 Agent 投递模式,只根据启动辅助 terminal 之前,宿主 Agent
@@ -166,7 +170,7 @@ ufoo -g
166
170
  ```
167
171
 
168
172
  `ufoo` 打开当前项目 chat。`ufoo -g` 打开全局 chat,用于在已注册项目之间
169
- 切换。项目 daemon 会按需启动。
173
+ 切换。全局 daemon 只启动一次,各项目 runtime 按需激活。
170
174
 
171
175
  ### Chat 内命令
172
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.20",
3
+ "version": "3.0.22",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -13,6 +13,10 @@ const { createActivityStatePublisher } = require("../activity/activityStatePubli
13
13
  const { reconcileDetectorOwnedActivity } = require("../activity/activityReconcile");
14
14
  const { detectLaunchEnvironment } = require("./launchEnvironment");
15
15
  const { getUfooPaths } = require("../../coordination/state/paths");
16
+ const {
17
+ resolveDaemonEndpoint,
18
+ routeDaemonRequest,
19
+ } = require("../../runtime/daemon/endpoint");
16
20
  const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
17
21
  const { probeHostCapabilities } = require("../../runtime/terminal/adapters/hostAdapter");
18
22
  const PtyWrapper = require("./ptyWrapper");
@@ -43,7 +47,10 @@ async function connectWithRetry(sockPath, retries, delayMs) {
43
47
  return null;
44
48
  }
45
49
 
46
- async function notifyDaemonAgentReady(daemonSockPath, subscriberId, agentPid) {
50
+ async function notifyDaemonAgentReady(daemonEndpoint, subscriberId, agentPid) {
51
+ const daemonSockPath = typeof daemonEndpoint === "string"
52
+ ? daemonEndpoint
53
+ : daemonEndpoint && daemonEndpoint.socketPath;
47
54
  if (!daemonSockPath || !subscriberId) return false;
48
55
  const parsedAgentPid = Number.parseInt(agentPid, 10);
49
56
  if (!Number.isFinite(parsedAgentPid) || parsedAgentPid <= 0) return false;
@@ -58,11 +65,16 @@ async function notifyDaemonAgentReady(daemonSockPath, subscriberId, agentPid) {
58
65
  return false;
59
66
  }
60
67
 
61
- daemonSock.write(`${JSON.stringify({
68
+ const request = {
62
69
  type: IPC_REQUEST_TYPES.AGENT_READY,
63
70
  subscriberId,
64
71
  agentPid: parsedAgentPid,
65
- })}\n`);
72
+ };
73
+ daemonSock.write(`${JSON.stringify(
74
+ typeof daemonEndpoint === "string"
75
+ ? request
76
+ : routeDaemonRequest(daemonEndpoint, request)
77
+ )}\n`);
66
78
  daemonSock.end();
67
79
 
68
80
  if (process.env.UFOO_DEBUG) {
@@ -366,9 +378,13 @@ class AgentLauncher {
366
378
  * 确保 daemon 正在运行
367
379
  */
368
380
  async ensureDaemon() {
369
- const paths = getUfooPaths(this.cwd);
381
+ const endpoint = resolveDaemonEndpoint(this.cwd);
382
+ const daemonRoot = endpoint.scope === "global"
383
+ ? endpoint.controllerRoot
384
+ : endpoint.projectRoot;
385
+ const paths = getUfooPaths(daemonRoot);
370
386
  const pidFile = paths.ufooDaemonPid;
371
- const sockFile = paths.ufooSock;
387
+ const sockFile = endpoint.socketPath;
372
388
 
373
389
  const existingProbe = await probeDaemonSocket(sockFile);
374
390
  if (existingProbe.ok) return "running";
@@ -377,30 +393,36 @@ class AgentLauncher {
377
393
  return "running";
378
394
  }
379
395
 
380
- // Stale daemon runtime markers can block restart with false-positive "running".
381
- // Clean local runtime markers only when socket probe failed.
382
- try {
383
- if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
384
- } catch {
385
- // ignore
386
- }
387
- try {
388
- if (fs.existsSync(sockFile)) fs.unlinkSync(sockFile);
389
- } catch {
390
- // ignore
391
- }
392
- try {
393
- const lockFile = path.join(paths.runDir, "daemon.lock");
394
- if (fs.existsSync(lockFile)) fs.unlinkSync(lockFile);
395
- } catch {
396
- // ignore
396
+ if (endpoint.scope === "project") {
397
+ // Stale project-daemon markers can block the legacy fallback. A wrapper
398
+ // must never unlink the machine-wide global daemon's control files.
399
+ try {
400
+ if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
401
+ } catch {
402
+ // ignore
403
+ }
404
+ try {
405
+ if (fs.existsSync(sockFile)) fs.unlinkSync(sockFile);
406
+ } catch {
407
+ // ignore
408
+ }
409
+ try {
410
+ const lockFile = path.join(paths.runDir, "daemon.lock");
411
+ if (fs.existsSync(lockFile)) fs.unlinkSync(lockFile);
412
+ } catch {
413
+ // ignore
414
+ }
397
415
  }
398
416
 
399
417
  // Start daemon using correct command
400
418
  spawnSync("ufoo", ["daemon", "start"], {
401
- cwd: this.cwd,
419
+ cwd: daemonRoot,
402
420
  stdio: "ignore",
403
421
  detached: true,
422
+ env: {
423
+ ...process.env,
424
+ UFOO_DAEMON_TOPOLOGY: endpoint.topology,
425
+ },
404
426
  });
405
427
 
406
428
  // Wait for daemon socket to be ready and reachable
@@ -426,7 +448,8 @@ class AgentLauncher {
426
448
  * 通过 daemon socket 注册 agent
427
449
  */
428
450
  async registerWithDaemon(nickname) {
429
- const sockFile = getUfooPaths(this.cwd).ufooSock;
451
+ const endpoint = resolveDaemonEndpoint(this.cwd);
452
+ const sockFile = endpoint.socketPath;
430
453
  const client = await connectWithRetry(sockFile, 25, 200);
431
454
  if (!client) {
432
455
  throw new Error("Failed to connect to ufoo daemon");
@@ -541,7 +564,7 @@ class AgentLauncher {
541
564
  }
542
565
  });
543
566
 
544
- client.write(`${JSON.stringify(req)}\n`);
567
+ client.write(`${JSON.stringify(routeDaemonRequest(endpoint, req))}\n`);
545
568
  });
546
569
  }
547
570
 
@@ -560,8 +583,8 @@ class AgentLauncher {
560
583
  });
561
584
 
562
585
  if (resolveLaunchMode() === "host" && child.pid) {
563
- const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
564
- notifyDaemonAgentReady(daemonSockPath, subscriberId, child.pid).catch(() => {});
586
+ const daemonEndpoint = resolveDaemonEndpoint(this.cwd);
587
+ notifyDaemonAgentReady(daemonEndpoint, subscriberId, child.pid).catch(() => {});
565
588
  }
566
589
 
567
590
  child.on("error", (err) => {
@@ -689,7 +712,7 @@ class AgentLauncher {
689
712
  subscriber: subscriberId,
690
713
  projectRoot: this.cwd,
691
714
  });
692
- const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
715
+ const daemonEndpoint = resolveDaemonEndpoint(this.cwd);
693
716
  launcherActivityDetector.onChange((newState, oldState) => {
694
717
  const snap = launcherActivityDetector.getState();
695
718
  launcherPublisher.publish(newState, {
@@ -769,7 +792,7 @@ class AgentLauncher {
769
792
  await injectPtyCommand(wrapper, this.agentType, startupBootstrapText, "startup-bootstrap");
770
793
  }
771
794
 
772
- await notifyDaemonAgentReady(daemonSockPath, subscriberId, wrapper.pty ? wrapper.pty.pid : 0);
795
+ await notifyDaemonAgentReady(daemonEndpoint, subscriberId, wrapper.pty ? wrapper.pty.pid : 0);
773
796
  });
774
797
 
775
798
  // Fallback:如果10秒后还没检测到ready,强制标记为ready
@@ -110,6 +110,17 @@ function collectTerminalLaunchRequestContext(resolveTerminalApp = defaultResolve
110
110
  return {};
111
111
  }
112
112
 
113
+ function collectTmuxLaunchRequestContext(env = process.env) {
114
+ const tmuxTarget = String(env.UFOO_TMUX_TARGET || "").trim();
115
+ const tmuxPane = String(env.UFOO_TMUX_PANE || env.TMUX_PANE || "").trim();
116
+ const tmuxSession = String(env.UFOO_TMUX_SESSION || "").trim();
117
+ const context = {};
118
+ if (tmuxTarget) context.tmux_target = tmuxTarget;
119
+ if (tmuxPane) context.tmux_pane = tmuxPane;
120
+ if (tmuxSession) context.tmux_session = tmuxSession;
121
+ return context;
122
+ }
123
+
113
124
  async function withCapturedConsole(capture, fn) {
114
125
  const originalLog = console.log;
115
126
  const originalError = console.error;
@@ -707,6 +718,7 @@ function createCommandExecutor(options = {}) {
707
718
  launch_scope: launchScope,
708
719
  ...collectHostLaunchRequestContext(),
709
720
  ...collectTerminalLaunchRequestContext(resolveTerminalApp),
721
+ ...collectTmuxLaunchRequestContext(),
710
722
  };
711
723
  send(request);
712
724
  schedule(requestStatus, 1000);
@@ -849,6 +861,7 @@ function createCommandExecutor(options = {}) {
849
861
  launch_scope: launchScope,
850
862
  ...collectHostLaunchRequestContext(),
851
863
  ...collectTerminalLaunchRequestContext(resolveTerminalApp),
864
+ ...collectTmuxLaunchRequestContext(),
852
865
  });
853
866
  schedule(requestStatus, 1000);
854
867
  } catch (err) {
@@ -1350,6 +1363,7 @@ function createCommandExecutor(options = {}) {
1350
1363
  dry_run: dryRun,
1351
1364
  ...collectHostLaunchRequestContext(),
1352
1365
  ...collectTerminalLaunchRequestContext(resolveTerminalApp),
1366
+ ...collectTmuxLaunchRequestContext(),
1353
1367
  });
1354
1368
  schedule(requestStatus, 1000);
1355
1369
  return;
@@ -2018,4 +2032,5 @@ module.exports = {
2018
2032
  createCommandExecutor,
2019
2033
  collectHostLaunchRequestContext,
2020
2034
  collectTerminalLaunchRequestContext,
2035
+ collectTmuxLaunchRequestContext,
2021
2036
  };
@@ -7,10 +7,12 @@ function createDaemonConnection(options = {}) {
7
7
  queueStatusLine,
8
8
  resolveStatusLine,
9
9
  logMessage,
10
+ transformRequest: transformRequestOption = (request) => request,
10
11
  switchConnectionTimeoutMs = 18000,
11
12
  } = options;
12
13
 
13
14
  let connectClient = connectClientOption;
15
+ let transformRequest = transformRequestOption;
14
16
  let client = null;
15
17
  let reconnectPromise = null;
16
18
  let exitRequested = false;
@@ -59,7 +61,7 @@ function createDaemonConnection(options = {}) {
59
61
  if (!client || client.destroyed) return;
60
62
  while (pendingRequests.length > 0) {
61
63
  const req = pendingRequests.shift();
62
- client.write(`${JSON.stringify(req)}\n`);
64
+ client.write(`${JSON.stringify(transformRequest(req))}\n`);
63
65
  }
64
66
  }
65
67
 
@@ -172,6 +174,9 @@ function createDaemonConnection(options = {}) {
172
174
  return { ok: false, error: "Failed to connect target daemon" };
173
175
  }
174
176
  connectClient = nextConnectClient;
177
+ if (typeof next.transformRequest === "function") {
178
+ transformRequest = next.transformRequest;
179
+ }
175
180
  attachClient(nextClient);
176
181
  if (next.callRequestStatus !== false) {
177
182
  requestStatus();
@@ -196,7 +201,7 @@ function createDaemonConnection(options = {}) {
196
201
  void ensureConnected();
197
202
  return;
198
203
  }
199
- client.write(`${JSON.stringify(req)}\n`);
204
+ client.write(`${JSON.stringify(transformRequest(req))}\n`);
200
205
  }
201
206
 
202
207
  function requestStatus() {
@@ -62,6 +62,7 @@ function createDaemonCoordinator(options = {}) {
62
62
  const result = await connection.switchConnection({
63
63
  connectClient: () => daemonTransport.connectClientForTarget(target),
64
64
  callRequestStatus: false,
65
+ transformRequest: target.transformRequest,
65
66
  });
66
67
  if (!result || result.ok !== true) {
67
68
  return {
@@ -19,11 +19,14 @@ function createDaemonTransport(options = {}) {
19
19
 
20
20
  let activeProjectRoot = projectRoot;
21
21
  let activeSockPath = sockPath;
22
+ let activeDaemonRoot = options.daemonRoot || projectRoot;
23
+ let daemonRootFollowsProject = !options.daemonRoot;
22
24
 
23
25
  function resolveTarget(override = {}) {
24
26
  return {
25
27
  projectRoot: override.projectRoot || activeProjectRoot,
26
28
  sockPath: override.sockPath || activeSockPath,
29
+ daemonRoot: override.daemonRoot || activeDaemonRoot,
27
30
  };
28
31
  }
29
32
 
@@ -39,9 +42,9 @@ function createDaemonTransport(options = {}) {
39
42
  if (!client) {
40
43
  // Retry once with a fresh daemon start and longer wait.
41
44
  // Check if a restart is already in progress via the explicit restart flow.
42
- const isExplicitRestartInProgress = restartLocks.get(target.projectRoot);
43
- if (autoStart && !isExplicitRestartInProgress && !isRunning(target.projectRoot)) {
44
- startDaemon(target.projectRoot);
45
+ const isExplicitRestartInProgress = restartLocks.get(target.daemonRoot);
46
+ if (autoStart && !isExplicitRestartInProgress && !isRunning(target.daemonRoot)) {
47
+ startDaemon(target.daemonRoot);
45
48
  await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
46
49
  }
47
50
  client = await connectWithRetry(
@@ -59,16 +62,29 @@ function createDaemonTransport(options = {}) {
59
62
  }
60
63
 
61
64
  function setTarget(next = {}) {
62
- if (next.projectRoot) activeProjectRoot = next.projectRoot;
65
+ if (next.projectRoot) {
66
+ activeProjectRoot = next.projectRoot;
67
+ if (daemonRootFollowsProject && !next.daemonRoot) {
68
+ activeDaemonRoot = next.projectRoot;
69
+ }
70
+ }
63
71
  if (next.sockPath) activeSockPath = next.sockPath;
72
+ if (next.daemonRoot) {
73
+ activeDaemonRoot = next.daemonRoot;
74
+ daemonRootFollowsProject = false;
75
+ }
64
76
  return getTarget();
65
77
  }
66
78
 
67
79
  function getTarget() {
68
- return {
80
+ const target = {
69
81
  projectRoot: activeProjectRoot,
70
82
  sockPath: activeSockPath,
71
83
  };
84
+ if (activeDaemonRoot !== activeProjectRoot) {
85
+ target.daemonRoot = activeDaemonRoot;
86
+ }
87
+ return target;
72
88
  }
73
89
 
74
90
  return {
@@ -18,6 +18,7 @@ function createProjectCloseController(options = {}) {
18
18
  resolveProjectRoot = defaultResolveProjectRoot,
19
19
  isRunning = () => false,
20
20
  stopDaemon = () => false,
21
+ closeProject = null,
21
22
  switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
22
23
  refreshProjects = () => {},
23
24
  renderDashboard = () => {},
@@ -86,7 +87,11 @@ function createProjectCloseController(options = {}) {
86
87
  }
87
88
 
88
89
  const wasRunning = Boolean(isRunning(projectRoot));
89
- stopDaemon(projectRoot, { source: `project-close:${projectRoot}` });
90
+ if (typeof closeProject === "function") {
91
+ await Promise.resolve(closeProject(projectRoot));
92
+ } else {
93
+ stopDaemon(projectRoot, { source: `project-close:${projectRoot}` });
94
+ }
90
95
 
91
96
  refreshProjects();
92
97
  renderDashboard();
@@ -3,6 +3,8 @@ const path = require("path");
3
3
  const fs = require("fs");
4
4
  const { spawn, spawnSync } = require("child_process");
5
5
  const { resolveNodeExecutable } = require("../../runtime/process/nodeExecutable");
6
+ const { resolveDaemonEndpoint } = require("../../runtime/daemon/endpoint");
7
+ const { routeDaemonRequest } = require("../../runtime/daemon/endpoint");
6
8
 
7
9
  function connectSocket(sockPath, options = {}) {
8
10
  const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
@@ -54,14 +56,20 @@ function resolveProjectFile(projectRoot, relativePath, fallbackRelativePath) {
54
56
  }
55
57
 
56
58
  function startDaemon(projectRoot, options = {}) {
57
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
58
- const env = options.forceResume
59
- ? { ...process.env, UFOO_FORCE_RESUME: "1" }
60
- : process.env;
59
+ const endpoint = resolveDaemonEndpoint(projectRoot, options);
60
+ const daemonRoot = endpoint.scope === "global"
61
+ ? endpoint.controllerRoot
62
+ : endpoint.projectRoot;
63
+ const daemonBin = resolveProjectFile(daemonRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
64
+ const env = {
65
+ ...process.env,
66
+ UFOO_DAEMON_TOPOLOGY: endpoint.topology,
67
+ ...(options.forceResume ? { UFOO_FORCE_RESUME: "1" } : {}),
68
+ };
61
69
  const child = spawn(resolveNodeExecutable(), [daemonBin, "daemon", "--start"], {
62
70
  detached: true,
63
71
  stdio: "ignore",
64
- cwd: projectRoot,
72
+ cwd: daemonRoot,
65
73
  env,
66
74
  });
67
75
  child.on("error", (err) => {
@@ -74,15 +82,23 @@ function startDaemon(projectRoot, options = {}) {
74
82
  }
75
83
 
76
84
  function stopDaemon(projectRoot, options = {}) {
77
- const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
85
+ const endpoint = resolveDaemonEndpoint(projectRoot, options);
86
+ const daemonRoot = endpoint.scope === "global"
87
+ ? endpoint.controllerRoot
88
+ : endpoint.projectRoot;
89
+ const daemonBin = resolveProjectFile(daemonRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
78
90
  const source = String(
79
91
  options.source
80
92
  || `chat-transport pid=${process.pid} cwd=${process.cwd()} argv=${process.argv.join(" ")}`
81
93
  );
82
94
  const result = spawnSync(resolveNodeExecutable(), [daemonBin, "daemon", "--stop"], {
83
95
  stdio: "ignore",
84
- cwd: projectRoot,
85
- env: { ...process.env, UFOO_DAEMON_STOP_SOURCE: source },
96
+ cwd: daemonRoot,
97
+ env: {
98
+ ...process.env,
99
+ UFOO_DAEMON_STOP_SOURCE: source,
100
+ UFOO_DAEMON_TOPOLOGY: endpoint.topology,
101
+ },
86
102
  });
87
103
  return Boolean(result && !result.error && result.status === 0);
88
104
  }
@@ -101,10 +117,70 @@ async function connectWithRetry(sockPath, retries, delayMs, options = {}) {
101
117
  return null;
102
118
  }
103
119
 
120
+ async function requestDaemon(projectRoot, payload, options = {}) {
121
+ const endpoint = resolveDaemonEndpoint(projectRoot, options);
122
+ const client = await connectWithRetry(
123
+ endpoint.socketPath,
124
+ Number(options.retries) || 25,
125
+ Number(options.retryDelayMs) || 200,
126
+ { timeoutMs: Number(options.connectTimeoutMs) || 2000 }
127
+ );
128
+ if (!client) throw new Error("Failed to connect to ufoo daemon");
129
+ return new Promise((resolve, reject) => {
130
+ let buffer = "";
131
+ let settled = false;
132
+ const finish = (err, value) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ clearTimeout(timer);
136
+ client.removeAllListeners();
137
+ try {
138
+ client.end();
139
+ } catch {
140
+ // ignore
141
+ }
142
+ if (err) reject(err);
143
+ else resolve(value);
144
+ };
145
+ const timer = setTimeout(() => {
146
+ finish(new Error("Daemon request timeout"));
147
+ }, Number(options.timeoutMs) || 10000);
148
+ if (typeof timer.unref === "function") timer.unref();
149
+ client.on("data", (chunk) => {
150
+ buffer += chunk.toString("utf8");
151
+ const lines = buffer.split(/\r?\n/);
152
+ buffer = lines.pop() || "";
153
+ for (const line of lines) {
154
+ if (!line.trim()) continue;
155
+ let response;
156
+ try {
157
+ response = JSON.parse(line);
158
+ } catch {
159
+ continue;
160
+ }
161
+ if (response.type === "error") {
162
+ const err = new Error(response.error || "Daemon request failed");
163
+ err.code = response.code || "daemon_request_failed";
164
+ finish(err);
165
+ return;
166
+ }
167
+ if (response.type === "response") {
168
+ finish(null, response.data || {});
169
+ return;
170
+ }
171
+ }
172
+ });
173
+ client.once("error", (err) => finish(err));
174
+ client.once("close", () => finish(new Error("Daemon request connection closed")));
175
+ client.write(`${JSON.stringify(routeDaemonRequest(endpoint, payload))}\n`);
176
+ });
177
+ }
178
+
104
179
  module.exports = {
105
180
  connectSocket,
106
181
  connectWithRetry,
107
182
  resolveProjectFile,
108
183
  startDaemon,
109
184
  stopDaemon,
185
+ requestDaemon,
110
186
  };