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 +13 -7
- package/README.zh-CN.md +11 -7
- package/package.json +1 -1
- package/src/agents/launch/launcher.js +52 -29
- package/src/app/chat/commandExecutor.js +15 -0
- package/src/app/chat/daemonConnection.js +7 -2
- package/src/app/chat/daemonCoordinator.js +1 -0
- package/src/app/chat/daemonTransport.js +21 -5
- package/src/app/chat/projectCloseController.js +6 -1
- package/src/app/chat/transport.js +84 -8
- package/src/app/cli/run.js +44 -7
- package/src/config.js +48 -0
- package/src/runtime/contracts/eventContract.js +1 -0
- package/src/runtime/daemon/agentProcessManager.js +34 -14
- package/src/runtime/daemon/controlPlaneService.js +14 -5
- package/src/runtime/daemon/endpoint.js +79 -0
- package/src/runtime/daemon/globalDaemon.js +394 -0
- package/src/runtime/daemon/groupOrchestrator.js +6 -0
- package/src/runtime/daemon/index.js +272 -109
- package/src/runtime/daemon/mcpConfigure.js +44 -1
- package/src/runtime/daemon/mcpHttpServer.js +5 -0
- package/src/runtime/daemon/ops.js +48 -17
- package/src/runtime/daemon/processLifecycle.js +127 -0
- package/src/runtime/daemon/projectContext.js +46 -0
- package/src/runtime/daemon/projectRuntime.js +221 -0
- package/src/runtime/daemon/projectRuntimeGateway.js +72 -0
- package/src/runtime/daemon/projectRuntimeManager.js +268 -0
- package/src/runtime/daemon/run.js +66 -12
- package/src/runtime/projects/registry.js +26 -4
- package/src/runtime/projects/runtimes.js +1 -1
- package/src/ui/rustChatHost.js +46 -8
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
|
-
-
|
|
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
|
-
->
|
|
131
|
-
->
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
-
->
|
|
120
|
-
->
|
|
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
|
|
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
|
-
|
|
173
|
+
切换。全局 daemon 只启动一次,各项目 runtime 按需激活。
|
|
170
174
|
|
|
171
175
|
### Chat 内命令
|
|
172
176
|
|
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
68
|
+
const request = {
|
|
62
69
|
type: IPC_REQUEST_TYPES.AGENT_READY,
|
|
63
70
|
subscriberId,
|
|
64
71
|
agentPid: parsedAgentPid,
|
|
65
|
-
}
|
|
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
|
|
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 =
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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:
|
|
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
|
|
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
|
|
564
|
-
notifyDaemonAgentReady(
|
|
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
|
|
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(
|
|
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.
|
|
43
|
-
if (autoStart && !isExplicitRestartInProgress && !isRunning(target.
|
|
44
|
-
startDaemon(target.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
const
|
|
59
|
-
?
|
|
60
|
-
:
|
|
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:
|
|
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
|
|
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:
|
|
85
|
-
env: {
|
|
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
|
};
|