u-foo 2.4.9 → 2.4.11
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/bin/ufoo.js +19 -0
- package/package.json +1 -1
- package/src/app/chat/commandExecutor.js +97 -0
- package/src/app/chat/commands.js +15 -0
- package/src/runtime/contracts/eventContract.js +1 -0
- package/src/runtime/daemon/controlPlaneService.js +330 -0
- package/src/runtime/daemon/index.js +19 -135
- package/src/runtime/daemon/mcpServer.js +34 -181
- package/src/runtime/daemon/nicknameScope.js +45 -0
package/bin/ufoo.js
CHANGED
|
@@ -13,6 +13,21 @@ function hasGlobalModeFlag(args = []) {
|
|
|
13
13
|
return args.includes("-g") || args.includes("--global");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function printMcpHelp() {
|
|
17
|
+
console.log("Usage: ufoo mcp [options]");
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log("Run the local global ufoo MCP bridge over stdio.");
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log("Options:");
|
|
22
|
+
console.log(" --no-auto-start Do not auto-start the home-scoped global controller daemon");
|
|
23
|
+
console.log(" -h, --help Display help for the MCP bridge command");
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log("Notes:");
|
|
26
|
+
console.log(" Configure MCP-capable clients with command: ufoo mcp");
|
|
27
|
+
console.log(" Example without daemon auto-start: ufoo mcp --no-auto-start");
|
|
28
|
+
console.log(" Human diagnostics are available in chat: /mcp status, /mcp tools, /mcp help");
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
async function main() {
|
|
17
32
|
const globalMode = hasGlobalModeFlag(rawArgv);
|
|
18
33
|
const argv = rawArgv.filter((arg) => arg !== "-g" && arg !== "--global");
|
|
@@ -28,6 +43,10 @@ async function main() {
|
|
|
28
43
|
return;
|
|
29
44
|
}
|
|
30
45
|
if (cmd === "mcp") {
|
|
46
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
47
|
+
printMcpHelp();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
31
50
|
await runMcpServer({
|
|
32
51
|
autoStart: !argv.includes("--no-auto-start"),
|
|
33
52
|
});
|
package/package.json
CHANGED
|
@@ -24,6 +24,10 @@ const {
|
|
|
24
24
|
inspectDirectAuthStatus,
|
|
25
25
|
formatDirectAuthStatus,
|
|
26
26
|
} = require("../../agents/providers/directAuthStatus");
|
|
27
|
+
const {
|
|
28
|
+
buildToolList: defaultBuildMcpToolList,
|
|
29
|
+
createUfooMcpServer: defaultCreateMcpServer,
|
|
30
|
+
} = require("../../runtime/daemon/mcpServer");
|
|
27
31
|
|
|
28
32
|
function defaultCreateDoctor(projectRoot) {
|
|
29
33
|
const UfooDoctor = require("../cli/features/doctor");
|
|
@@ -147,6 +151,8 @@ function createCommandExecutor(options = {}) {
|
|
|
147
151
|
runGroupCore = runGroupCoreCommand,
|
|
148
152
|
inspectDirectAuth = inspectDirectAuthStatus,
|
|
149
153
|
formatDirectAuth = formatDirectAuthStatus,
|
|
154
|
+
buildMcpToolList = defaultBuildMcpToolList,
|
|
155
|
+
createMcpServer = defaultCreateMcpServer,
|
|
150
156
|
requestCron = null,
|
|
151
157
|
globalMode = false,
|
|
152
158
|
listProjects = () => [],
|
|
@@ -335,6 +341,93 @@ function createCommandExecutor(options = {}) {
|
|
|
335
341
|
);
|
|
336
342
|
}
|
|
337
343
|
|
|
344
|
+
function logMcpHelp() {
|
|
345
|
+
logMessage("system", "{cyan-fg}MCP bridge:{/cyan-fg} local stdio bridge for external MCP-capable agents");
|
|
346
|
+
logMessage("system", " • Configure client command: ufoo mcp");
|
|
347
|
+
logMessage("system", " • Disable daemon auto-start: ufoo mcp --no-auto-start");
|
|
348
|
+
logMessage("system", " • Topology: one global bridge, project tools route through registered project daemons");
|
|
349
|
+
logMessage("system", " • Chat diagnostics: /mcp status, /mcp tools, /mcp help");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function readMcpStatus() {
|
|
353
|
+
const server = createMcpServer({
|
|
354
|
+
autoStart: false,
|
|
355
|
+
validateProjectRoot: true,
|
|
356
|
+
});
|
|
357
|
+
const response = await server.handleRequest({
|
|
358
|
+
jsonrpc: "2.0",
|
|
359
|
+
id: "chat-mcp-status",
|
|
360
|
+
method: "tools/call",
|
|
361
|
+
params: {
|
|
362
|
+
name: "ufoo_mcp_status",
|
|
363
|
+
arguments: {},
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
if (response && response.error) {
|
|
367
|
+
const message = response.error.message || "MCP status request failed";
|
|
368
|
+
throw new Error(message);
|
|
369
|
+
}
|
|
370
|
+
return response && response.result ? response.result.structuredContent : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleMcpCommand(args = []) {
|
|
374
|
+
const subcommand = String(args[0] || "status").trim().toLowerCase();
|
|
375
|
+
|
|
376
|
+
if (subcommand === "help") {
|
|
377
|
+
logMcpHelp();
|
|
378
|
+
renderScreen();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (subcommand === "tools") {
|
|
383
|
+
try {
|
|
384
|
+
const tools = buildMcpToolList();
|
|
385
|
+
logMessage("system", `{cyan-fg}MCP tools:{/cyan-fg} ${tools.length} exposed tool(s)`);
|
|
386
|
+
for (const tool of tools) {
|
|
387
|
+
const name = escapeBlessed(tool.name || "");
|
|
388
|
+
const desc = escapeBlessed(tool.description || "");
|
|
389
|
+
logMessage("system", ` • {cyan-fg}${name}{/cyan-fg}${desc ? ` - ${desc}` : ""}`);
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
logMessage("error", `{white-fg}✗{/white-fg} MCP tools failed: ${escapeBlessed(err.message)}`);
|
|
393
|
+
}
|
|
394
|
+
renderScreen();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (subcommand === "status") {
|
|
399
|
+
try {
|
|
400
|
+
const status = await readMcpStatus();
|
|
401
|
+
if (!status) {
|
|
402
|
+
throw new Error("empty MCP status response");
|
|
403
|
+
}
|
|
404
|
+
const projects = Array.isArray(status.projects) ? status.projects : [];
|
|
405
|
+
logMessage("system", "{cyan-fg}MCP bridge:{/cyan-fg} local stdio server");
|
|
406
|
+
logMessage("system", " • command: {cyan-fg}ufoo mcp{/cyan-fg}");
|
|
407
|
+
logMessage("system", ` • global controller: ${escapeBlessed(status.global_controller_root || "")}`);
|
|
408
|
+
logMessage("system", ` • daemon: ${status.global_controller_running ? "{green-fg}running{/green-fg}" : "{yellow-fg}not running{/yellow-fg}"}`);
|
|
409
|
+
logMessage("system", " • client auto-start: enabled by default");
|
|
410
|
+
logMessage("system", ` • status probe auto-start: ${status.auto_start ? "enabled" : "disabled"}`);
|
|
411
|
+
logMessage("system", ` • registered projects: ${projects.length}`);
|
|
412
|
+
for (const project of projects.slice(0, 5)) {
|
|
413
|
+
const label = project.project_name || project.name || project.project_root || "";
|
|
414
|
+
const root = project.project_root || "";
|
|
415
|
+
logMessage("system", ` - ${escapeBlessed(label)}${root && root !== label ? ` (${escapeBlessed(root)})` : ""}`);
|
|
416
|
+
}
|
|
417
|
+
if (projects.length > 5) {
|
|
418
|
+
logMessage("system", ` - ... ${projects.length - 5} more`);
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
logMessage("error", `{white-fg}✗{/white-fg} MCP status failed: ${escapeBlessed(err.message)}`);
|
|
422
|
+
}
|
|
423
|
+
renderScreen();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
logMessage("error", "{white-fg}✗{/white-fg} Unknown MCP command. Use: status, tools, help");
|
|
428
|
+
renderScreen();
|
|
429
|
+
}
|
|
430
|
+
|
|
338
431
|
async function handleBusCommand(args = []) {
|
|
339
432
|
const subcommand = args[0];
|
|
340
433
|
|
|
@@ -1608,6 +1701,9 @@ function createCommandExecutor(options = {}) {
|
|
|
1608
1701
|
logMessage("error", "{white-fg}✗{/white-fg} Multi-window mode is not available");
|
|
1609
1702
|
}
|
|
1610
1703
|
return true;
|
|
1704
|
+
case "mcp":
|
|
1705
|
+
await handleMcpCommand(args);
|
|
1706
|
+
return true;
|
|
1611
1707
|
case "doctor":
|
|
1612
1708
|
await handleDoctorCommand();
|
|
1613
1709
|
return true;
|
|
@@ -1670,6 +1766,7 @@ function createCommandExecutor(options = {}) {
|
|
|
1670
1766
|
handleDoctorCommand,
|
|
1671
1767
|
handleStatusCommand,
|
|
1672
1768
|
handleDaemonCommand,
|
|
1769
|
+
handleMcpCommand,
|
|
1673
1770
|
handleInitCommand,
|
|
1674
1771
|
handleBusCommand,
|
|
1675
1772
|
handleCtxCommand,
|
package/src/app/chat/commands.js
CHANGED
|
@@ -49,6 +49,14 @@ const COMMAND_TREE = {
|
|
|
49
49
|
},
|
|
50
50
|
"/init": { desc: "Initialize workspace" },
|
|
51
51
|
"/multi": { desc: "Toggle multi-window agent view" },
|
|
52
|
+
"/mcp": {
|
|
53
|
+
desc: "MCP bridge diagnostics",
|
|
54
|
+
children: {
|
|
55
|
+
status: { desc: "Show global MCP bridge status", order: 1 },
|
|
56
|
+
tools: { desc: "List exposed MCP tools", order: 2 },
|
|
57
|
+
help: { desc: "Show MCP setup hints", order: 3 },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
52
60
|
"/open": { desc: "Open project path in global mode" },
|
|
53
61
|
"/launch": {
|
|
54
62
|
desc: "Launch new agent",
|
|
@@ -298,6 +306,13 @@ function describeCommandForChat(text) {
|
|
|
298
306
|
return "Managing cron tasks";
|
|
299
307
|
}
|
|
300
308
|
|
|
309
|
+
if (command === "mcp") {
|
|
310
|
+
if (!sub || sub === "status") return "Checking MCP bridge status";
|
|
311
|
+
if (sub === "tools") return "Listing MCP tools";
|
|
312
|
+
if (sub === "help") return "Showing MCP setup help";
|
|
313
|
+
return `Running /mcp ${sub}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
301
316
|
if (command === "settings") return !sub || sub === "show" ? "Showing settings" : "Updating settings";
|
|
302
317
|
if (command === "ctx") return `Checking context${sub ? ` ${sub}` : ""}`;
|
|
303
318
|
if (command === "doctor") return "Running ufoo diagnostics";
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const net = require("net");
|
|
5
|
+
const EventBus = require("../../coordination/bus");
|
|
6
|
+
const { normalizeReportInput } = require("../../coordination/report/store");
|
|
7
|
+
const { enqueueAgentReport } = require("./reportControlBus");
|
|
8
|
+
const { isRunning, socketPath } = require("./index");
|
|
9
|
+
const { IPC_REQUEST_TYPES } = require("../contracts/eventContract");
|
|
10
|
+
const {
|
|
11
|
+
applyProjectNicknamePrefix,
|
|
12
|
+
checkAndCleanupNickname,
|
|
13
|
+
} = require("./nicknameScope");
|
|
14
|
+
|
|
15
|
+
function nowIso() {
|
|
16
|
+
return new Date().toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeBusAgentType(agentType = "") {
|
|
20
|
+
const value = String(agentType || "").trim().toLowerCase();
|
|
21
|
+
if (!value) return "mcp-agent";
|
|
22
|
+
if (value === "claude") return "claude-code";
|
|
23
|
+
if (value === "ucode" || value === "ufoo") return "ufoo-code";
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureBusLoaded(projectRoot) {
|
|
28
|
+
const bus = new EventBus(projectRoot);
|
|
29
|
+
bus.ensureBus();
|
|
30
|
+
bus.loadBusData();
|
|
31
|
+
return bus;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertSubscriberExists(bus, subscriber) {
|
|
35
|
+
const meta = bus.subscriberManager.getSubscriber(subscriber);
|
|
36
|
+
if (!meta) {
|
|
37
|
+
const err = new Error(`subscriber not found: ${subscriber}`);
|
|
38
|
+
err.code = "subscriber_not_found";
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
return meta;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveSubscriberArg(args = {}) {
|
|
45
|
+
const subscriber = String(args.subscriber || args.source || "").trim();
|
|
46
|
+
if (!subscriber) {
|
|
47
|
+
const err = new Error("subscriber is required");
|
|
48
|
+
err.code = "invalid_subscriber";
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return subscriber;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createSessionId() {
|
|
55
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createCryptoSessionId() {
|
|
59
|
+
return crypto.randomBytes(4).toString("hex");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function notifyDaemonRefresh(projectRoot) {
|
|
63
|
+
if (!isRunning(projectRoot)) return;
|
|
64
|
+
const sock = socketPath(projectRoot);
|
|
65
|
+
try {
|
|
66
|
+
const client = net.createConnection(sock, () => {
|
|
67
|
+
client.write(`${JSON.stringify({ type: IPC_REQUEST_TYPES.REFRESH_STATUS })}\n`);
|
|
68
|
+
client.end();
|
|
69
|
+
});
|
|
70
|
+
client.on("error", () => {});
|
|
71
|
+
} catch {
|
|
72
|
+
// fire-and-forget
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function registerAgentFull(projectRoot, args = {}, options = {}) {
|
|
77
|
+
const {
|
|
78
|
+
validateParentPid = false,
|
|
79
|
+
checkNicknameConflicts = false,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
const agentType = normalizeBusAgentType(args.agent_type || args.agentType || "mcp-agent");
|
|
83
|
+
const nickname = String(args.nickname || "").trim();
|
|
84
|
+
const launchMode = String(args.launch_mode || args.launchMode || "mcp").trim();
|
|
85
|
+
const capabilities = args.capabilities && typeof args.capabilities === "object"
|
|
86
|
+
? args.capabilities
|
|
87
|
+
: null;
|
|
88
|
+
const hostCapabilities = args.hostCapabilities && typeof args.hostCapabilities === "object"
|
|
89
|
+
? args.hostCapabilities
|
|
90
|
+
: capabilities;
|
|
91
|
+
|
|
92
|
+
// Session ID: explicit > reuse > generate
|
|
93
|
+
let sessionId;
|
|
94
|
+
const explicitSessionId = String(args.session_id || args.sessionId || "").trim();
|
|
95
|
+
const reuseSession = args.reuseSession && typeof args.reuseSession === "object"
|
|
96
|
+
? args.reuseSession
|
|
97
|
+
: null;
|
|
98
|
+
const reuseSessionId = typeof reuseSession?.sessionId === "string"
|
|
99
|
+
? reuseSession.sessionId.trim() : "";
|
|
100
|
+
const reuseSubscriberId = typeof reuseSession?.subscriberId === "string"
|
|
101
|
+
? reuseSession.subscriberId.trim() : "";
|
|
102
|
+
const reuseProviderSessionId = typeof reuseSession?.providerSessionId === "string"
|
|
103
|
+
? reuseSession.providerSessionId.trim() : "";
|
|
104
|
+
|
|
105
|
+
if (explicitSessionId) {
|
|
106
|
+
sessionId = explicitSessionId;
|
|
107
|
+
} else if (reuseSessionId && reuseSubscriberId === `${agentType}:${reuseSessionId}`) {
|
|
108
|
+
sessionId = reuseSessionId;
|
|
109
|
+
} else {
|
|
110
|
+
sessionId = validateParentPid ? createCryptoSessionId() : createSessionId();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// parentPid validation
|
|
114
|
+
const parentPid = Number.parseInt(args.parentPid, 10);
|
|
115
|
+
if (validateParentPid) {
|
|
116
|
+
if (!Number.isFinite(parentPid) || parentPid <= 0) {
|
|
117
|
+
const err = new Error("register_agent requires valid parentPid");
|
|
118
|
+
err.code = "invalid_parent_pid";
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Nickname scope and conflict check
|
|
124
|
+
let finalNickname = nickname;
|
|
125
|
+
let scopedNickname = nickname
|
|
126
|
+
? applyProjectNicknamePrefix(projectRoot, nickname, { agentType })
|
|
127
|
+
: "";
|
|
128
|
+
if (checkNicknameConflicts && finalNickname) {
|
|
129
|
+
const nickCheck = checkAndCleanupNickname(projectRoot, finalNickname, {
|
|
130
|
+
tty: String(args.tty || ""),
|
|
131
|
+
agentType,
|
|
132
|
+
scopedNickname,
|
|
133
|
+
});
|
|
134
|
+
if (nickCheck.existing) {
|
|
135
|
+
finalNickname = "";
|
|
136
|
+
scopedNickname = "";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Bus join
|
|
141
|
+
const joinOptions = {
|
|
142
|
+
parentPid: Number.isFinite(parentPid) && parentPid > 0 ? parentPid : process.pid,
|
|
143
|
+
launchMode,
|
|
144
|
+
tmuxPane: String(args.tmuxPane || ""),
|
|
145
|
+
tty: String(args.tty || ""),
|
|
146
|
+
hostInjectSock: String(args.hostInjectSock || ""),
|
|
147
|
+
hostDaemonSock: String(args.hostDaemonSock || ""),
|
|
148
|
+
hostName: String(args.host_name || args.hostName || "ufoo-mcp"),
|
|
149
|
+
hostSessionId: String(args.hostSessionId || `mcp-${process.pid}`),
|
|
150
|
+
hostCapabilities: hostCapabilities,
|
|
151
|
+
scopedNickname: scopedNickname || String(args.scoped_nickname || args.scopedNickname || finalNickname || "").trim(),
|
|
152
|
+
};
|
|
153
|
+
if (args.skipSessionResolve) joinOptions.skipSessionResolve = true;
|
|
154
|
+
if (reuseSessionId) joinOptions.reuseSessionId = reuseSessionId;
|
|
155
|
+
if (reuseProviderSessionId) joinOptions.reuseProviderSessionId = reuseProviderSessionId;
|
|
156
|
+
|
|
157
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
158
|
+
const result = await bus.subscriberManager.join(sessionId, agentType, finalNickname, joinOptions);
|
|
159
|
+
const subscriber = result.subscriber;
|
|
160
|
+
if (finalNickname) {
|
|
161
|
+
bus.subscriberManager.rename(subscriber, finalNickname, "ufoo-agent", { scopedNickname });
|
|
162
|
+
}
|
|
163
|
+
const meta = bus.subscriberManager.getSubscriber(subscriber) || {};
|
|
164
|
+
meta.activity_state = String(args.activity_state || "ready");
|
|
165
|
+
meta.activity_since = nowIso();
|
|
166
|
+
meta.mcp_bridge = !validateParentPid;
|
|
167
|
+
if (hostCapabilities) meta.mcp_capabilities = hostCapabilities;
|
|
168
|
+
bus.saveBusData();
|
|
169
|
+
notifyDaemonRefresh(projectRoot);
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
project_root: projectRoot,
|
|
173
|
+
subscriber_id: subscriber,
|
|
174
|
+
subscriber,
|
|
175
|
+
session_id: sessionId,
|
|
176
|
+
agent_type: agentType,
|
|
177
|
+
nickname: meta.nickname || result.nickname || finalNickname || "",
|
|
178
|
+
scoped_nickname: meta.scoped_nickname || result.scopedNickname || scopedNickname || "",
|
|
179
|
+
launch_mode: launchMode,
|
|
180
|
+
reuseProviderSessionId,
|
|
181
|
+
skipSessionResolve: !!args.skipSessionResolve,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function registerAgent(projectRoot, args = {}) {
|
|
186
|
+
return registerAgentFull(projectRoot, args, {
|
|
187
|
+
validateParentPid: false,
|
|
188
|
+
checkNicknameConflicts: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function heartbeatAgent(projectRoot, args = {}) {
|
|
193
|
+
const subscriber = resolveSubscriberArg(args);
|
|
194
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
195
|
+
const meta = assertSubscriberExists(bus, subscriber);
|
|
196
|
+
bus.subscriberManager.updateLastSeen(subscriber);
|
|
197
|
+
meta.status = "active";
|
|
198
|
+
bus.saveBusData();
|
|
199
|
+
notifyDaemonRefresh(projectRoot);
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
project_root: projectRoot,
|
|
203
|
+
subscriber,
|
|
204
|
+
last_seen: meta.last_seen,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function publishActivityState(projectRoot, args = {}) {
|
|
209
|
+
const subscriber = resolveSubscriberArg(args);
|
|
210
|
+
const activityState = String(args.activity_state || args.activityState || "").trim();
|
|
211
|
+
if (!activityState) {
|
|
212
|
+
const err = new Error("activity_state is required");
|
|
213
|
+
err.code = "invalid_activity_state";
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
217
|
+
const meta = assertSubscriberExists(bus, subscriber);
|
|
218
|
+
bus.subscriberManager.updateLastSeen(subscriber);
|
|
219
|
+
meta.status = "active";
|
|
220
|
+
meta.activity_state = activityState;
|
|
221
|
+
meta.activity_detail = String(args.detail || "").trim();
|
|
222
|
+
meta.activity_since = String(args.since || "").trim() || nowIso();
|
|
223
|
+
bus.saveBusData();
|
|
224
|
+
notifyDaemonRefresh(projectRoot);
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
project_root: projectRoot,
|
|
228
|
+
subscriber,
|
|
229
|
+
activity_state: meta.activity_state,
|
|
230
|
+
activity_detail: meta.activity_detail,
|
|
231
|
+
activity_since: meta.activity_since,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function updateAgentMetadata(projectRoot, args = {}) {
|
|
236
|
+
const subscriber = resolveSubscriberArg(args);
|
|
237
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
238
|
+
const meta = assertSubscriberExists(bus, subscriber);
|
|
239
|
+
const nickname = String(args.nickname || "").trim();
|
|
240
|
+
if (nickname) {
|
|
241
|
+
await bus.subscriberManager.rename(subscriber, nickname);
|
|
242
|
+
}
|
|
243
|
+
const metadata = args.metadata && typeof args.metadata === "object" ? args.metadata : {};
|
|
244
|
+
if (Object.keys(metadata).length > 0) {
|
|
245
|
+
meta.mcp_metadata = {
|
|
246
|
+
...(meta.mcp_metadata && typeof meta.mcp_metadata === "object" ? meta.mcp_metadata : {}),
|
|
247
|
+
...metadata,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
bus.subscriberManager.updateLastSeen(subscriber);
|
|
251
|
+
bus.saveBusData();
|
|
252
|
+
notifyDaemonRefresh(projectRoot);
|
|
253
|
+
const nextMeta = bus.subscriberManager.getSubscriber(subscriber) || meta;
|
|
254
|
+
return {
|
|
255
|
+
ok: true,
|
|
256
|
+
project_root: projectRoot,
|
|
257
|
+
subscriber,
|
|
258
|
+
nickname: nextMeta.nickname || "",
|
|
259
|
+
scoped_nickname: nextMeta.scoped_nickname || nextMeta.nickname || "",
|
|
260
|
+
metadata: nextMeta.mcp_metadata || {},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function pollInbox(projectRoot, args = {}) {
|
|
265
|
+
const subscriber = resolveSubscriberArg(args);
|
|
266
|
+
const limit = Number.isFinite(Number(args.limit)) && Number(args.limit) > 0
|
|
267
|
+
? Math.floor(Number(args.limit))
|
|
268
|
+
: 50;
|
|
269
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
270
|
+
assertSubscriberExists(bus, subscriber);
|
|
271
|
+
bus.subscriberManager.updateLastSeen(subscriber);
|
|
272
|
+
bus.saveBusData();
|
|
273
|
+
const pending = await bus.messageManager.check(subscriber);
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
project_root: projectRoot,
|
|
277
|
+
subscriber,
|
|
278
|
+
count: pending.length,
|
|
279
|
+
messages: pending.slice(0, limit),
|
|
280
|
+
truncated: pending.length > limit,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function reportAgentStatus(projectRoot, args = {}) {
|
|
285
|
+
const subscriber = resolveSubscriberArg(args);
|
|
286
|
+
const report = normalizeReportInput({
|
|
287
|
+
...args,
|
|
288
|
+
agent_id: subscriber,
|
|
289
|
+
source: "mcp",
|
|
290
|
+
});
|
|
291
|
+
const queued = await enqueueAgentReport(projectRoot, report, { publisher: subscriber });
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
project_root: projectRoot,
|
|
295
|
+
status: "queued",
|
|
296
|
+
request_id: queued.request_id,
|
|
297
|
+
report,
|
|
298
|
+
queued,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function unregisterAgent(projectRoot, args = {}) {
|
|
303
|
+
const subscriber = resolveSubscriberArg(args);
|
|
304
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
305
|
+
const ok = await bus.subscriberManager.leave(subscriber);
|
|
306
|
+
bus.saveBusData();
|
|
307
|
+
notifyDaemonRefresh(projectRoot);
|
|
308
|
+
return {
|
|
309
|
+
ok,
|
|
310
|
+
project_root: projectRoot,
|
|
311
|
+
subscriber,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
normalizeBusAgentType,
|
|
317
|
+
ensureBusLoaded,
|
|
318
|
+
assertSubscriberExists,
|
|
319
|
+
resolveSubscriberArg,
|
|
320
|
+
createSessionId,
|
|
321
|
+
notifyDaemonRefresh,
|
|
322
|
+
registerAgentFull,
|
|
323
|
+
registerAgent,
|
|
324
|
+
heartbeatAgent,
|
|
325
|
+
publishActivityState,
|
|
326
|
+
updateAgentMetadata,
|
|
327
|
+
pollInbox,
|
|
328
|
+
reportAgentStatus,
|
|
329
|
+
unregisterAgent,
|
|
330
|
+
};
|
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
applyProjectNicknamePrefix,
|
|
41
41
|
resolveDisplayNickname,
|
|
42
42
|
resolveScopedNickname,
|
|
43
|
+
checkAndCleanupNickname,
|
|
43
44
|
} = require("./nicknameScope");
|
|
44
45
|
const { resolveNodeExecutable } = require("../process/nodeExecutable");
|
|
45
46
|
|
|
@@ -474,57 +475,6 @@ async function waitForNewSubscriber(projectRoot, agentType, existing, timeoutMs
|
|
|
474
475
|
return null;
|
|
475
476
|
}
|
|
476
477
|
|
|
477
|
-
function checkAndCleanupNickname(projectRoot, nickname, { tty = "", agentType = "", scopedNickname = "" } = {}) {
|
|
478
|
-
const conflictNickname = scopedNickname || applyProjectNicknamePrefix(projectRoot, nickname, {
|
|
479
|
-
agentType,
|
|
480
|
-
force: true,
|
|
481
|
-
});
|
|
482
|
-
if (!conflictNickname) return { existing: null, cleaned: false };
|
|
483
|
-
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
484
|
-
try {
|
|
485
|
-
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
486
|
-
const entries = Object.entries(bus.agents || {})
|
|
487
|
-
.filter(([, meta]) => {
|
|
488
|
-
const candidate = resolveScopedNickname(projectRoot, meta);
|
|
489
|
-
return meta && candidate === conflictNickname;
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
if (entries.length === 0) {
|
|
493
|
-
return { existing: null, cleaned: false };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Check for active agent with same nickname
|
|
497
|
-
const activeAgent = entries.find(([, meta]) => meta.status === "active");
|
|
498
|
-
if (activeAgent) {
|
|
499
|
-
const [existingId, existingMeta] = activeAgent;
|
|
500
|
-
// Allow takeover when the existing holder is a pre-registered stub
|
|
501
|
-
// (same agent type, no TTY) or occupies the same TTY — the new
|
|
502
|
-
// registration is the real agent replacing the placeholder.
|
|
503
|
-
const sameType = agentType && existingMeta.agent_type === agentType;
|
|
504
|
-
// A stub is a pre-registered entry with no TTY AND no meaningful activity
|
|
505
|
-
// state. Internal-mode agents also lack a TTY but will have activity_state
|
|
506
|
-
// set once they start working — don't evict those.
|
|
507
|
-
const isStub = sameType && !existingMeta.tty && !existingMeta.activity_state;
|
|
508
|
-
const sameTty = tty && existingMeta.tty === tty;
|
|
509
|
-
if (isStub || sameTty) {
|
|
510
|
-
delete bus.agents[existingId];
|
|
511
|
-
fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
|
|
512
|
-
return { existing: null, cleaned: true };
|
|
513
|
-
}
|
|
514
|
-
return { existing: existingId, cleaned: false };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Clean up offline agents with same nickname
|
|
518
|
-
for (const [agentId] of entries) {
|
|
519
|
-
delete bus.agents[agentId];
|
|
520
|
-
}
|
|
521
|
-
fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
|
|
522
|
-
return { existing: null, cleaned: true };
|
|
523
|
-
} catch {
|
|
524
|
-
return { existing: null, cleaned: false };
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
478
|
function resolveSubscriberNickname(projectRoot, subscriberId) {
|
|
529
479
|
if (!subscriberId) return "";
|
|
530
480
|
try {
|
|
@@ -2331,19 +2281,10 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
2331
2281
|
return;
|
|
2332
2282
|
}
|
|
2333
2283
|
if (req.type === IPC_REQUEST_TYPES.REGISTER_AGENT) {
|
|
2334
|
-
// Manual agent launch requests daemon to register it
|
|
2335
2284
|
const {
|
|
2336
2285
|
agentType,
|
|
2337
2286
|
nickname,
|
|
2338
2287
|
parentPid,
|
|
2339
|
-
launchMode,
|
|
2340
|
-
tmuxPane,
|
|
2341
|
-
tty,
|
|
2342
|
-
hostInjectSock,
|
|
2343
|
-
hostDaemonSock,
|
|
2344
|
-
hostName,
|
|
2345
|
-
hostSessionId,
|
|
2346
|
-
hostCapabilities,
|
|
2347
2288
|
skipSessionResolve,
|
|
2348
2289
|
} = req;
|
|
2349
2290
|
if (!agentType) {
|
|
@@ -2357,85 +2298,22 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
2357
2298
|
return;
|
|
2358
2299
|
}
|
|
2359
2300
|
try {
|
|
2360
|
-
const
|
|
2361
|
-
const
|
|
2362
|
-
|
|
2363
|
-
:
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
:
|
|
2367
|
-
|
|
2368
|
-
? requestedReuse.subscriberId.trim()
|
|
2369
|
-
: "";
|
|
2370
|
-
const reuseProviderSessionId = typeof requestedReuse?.providerSessionId === "string"
|
|
2371
|
-
? requestedReuse.providerSessionId.trim()
|
|
2372
|
-
: "";
|
|
2373
|
-
|
|
2374
|
-
let sessionId = crypto.randomBytes(4).toString("hex");
|
|
2375
|
-
let subscriberId = `${agentType}:${sessionId}`;
|
|
2376
|
-
if (reuseSessionId && reuseSubscriberId === `${agentType}:${reuseSessionId}`) {
|
|
2377
|
-
sessionId = reuseSessionId;
|
|
2378
|
-
subscriberId = reuseSubscriberId;
|
|
2379
|
-
} else if (reuseSessionId || reuseSubscriberId) {
|
|
2380
|
-
log(`register_agent ignored invalid reuseSession for ${agentType}`);
|
|
2381
|
-
}
|
|
2382
|
-
|
|
2383
|
-
// Daemon registers the agent in bus
|
|
2384
|
-
const eventBus = new EventBus(projectRoot);
|
|
2385
|
-
await eventBus.init();
|
|
2386
|
-
eventBus.loadBusData();
|
|
2387
|
-
const parsedParentPid = Number.parseInt(parentPid, 10);
|
|
2388
|
-
if (!Number.isFinite(parsedParentPid) || parsedParentPid <= 0) {
|
|
2389
|
-
throw new Error("register_agent requires valid parentPid");
|
|
2390
|
-
}
|
|
2391
|
-
const joinOptions = {
|
|
2392
|
-
parentPid: Number.isFinite(parsedParentPid) ? parsedParentPid : undefined,
|
|
2393
|
-
launchMode: launchMode || "",
|
|
2394
|
-
tmuxPane: tmuxPane || "",
|
|
2395
|
-
tty: tty || "",
|
|
2396
|
-
hostInjectSock: hostInjectSock || "",
|
|
2397
|
-
hostDaemonSock: hostDaemonSock || "",
|
|
2398
|
-
hostName: hostName || "",
|
|
2399
|
-
hostSessionId: hostSessionId || "",
|
|
2400
|
-
hostCapabilities: hostCapabilities && typeof hostCapabilities === "object"
|
|
2401
|
-
? hostCapabilities
|
|
2402
|
-
: null,
|
|
2403
|
-
reuseSessionId,
|
|
2404
|
-
reuseProviderSessionId,
|
|
2405
|
-
};
|
|
2406
|
-
if (skipSessionResolve) joinOptions.skipSessionResolve = true;
|
|
2407
|
-
|
|
2408
|
-
let finalNickname = nickname || "";
|
|
2409
|
-
let scopedNickname = applyProjectNicknamePrefix(projectRoot, finalNickname, {
|
|
2410
|
-
agentType: normalizeBusAgentType(agentType),
|
|
2301
|
+
const controlPlane = require("./controlPlaneService");
|
|
2302
|
+
const result = await controlPlane.registerAgentFull(projectRoot, {
|
|
2303
|
+
...req,
|
|
2304
|
+
agent_type: agentType,
|
|
2305
|
+
parentPid,
|
|
2306
|
+
}, {
|
|
2307
|
+
validateParentPid: true,
|
|
2308
|
+
checkNicknameConflicts: true,
|
|
2411
2309
|
});
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
tty: tty || "",
|
|
2415
|
-
agentType: normalizeBusAgentType(agentType),
|
|
2416
|
-
scopedNickname,
|
|
2417
|
-
});
|
|
2418
|
-
if (nickCheck.existing) {
|
|
2419
|
-
finalNickname = "";
|
|
2420
|
-
scopedNickname = "";
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2423
|
-
await eventBus.join(
|
|
2424
|
-
sessionId,
|
|
2425
|
-
normalizeBusAgentType(agentType),
|
|
2426
|
-
finalNickname,
|
|
2427
|
-
{ ...joinOptions, scopedNickname },
|
|
2428
|
-
);
|
|
2429
|
-
if (finalNickname) {
|
|
2430
|
-
eventBus.rename(subscriberId, finalNickname, "ufoo-agent", { scopedNickname });
|
|
2431
|
-
}
|
|
2432
|
-
eventBus.saveBusData();
|
|
2433
|
-
const resolvedNickname = resolveSubscriberNickname(projectRoot, subscriberId) || finalNickname || "";
|
|
2310
|
+
const subscriberId = result.subscriber;
|
|
2311
|
+
const resolvedNickname = resolveSubscriberNickname(projectRoot, subscriberId) || result.nickname || "";
|
|
2434
2312
|
|
|
2435
|
-
if (!skipSessionResolve && reuseProviderSessionId) {
|
|
2313
|
+
if (!skipSessionResolve && result.reuseProviderSessionId) {
|
|
2436
2314
|
if (providerSessions) {
|
|
2437
2315
|
providerSessions.set(subscriberId, {
|
|
2438
|
-
sessionId: reuseProviderSessionId,
|
|
2316
|
+
sessionId: result.reuseProviderSessionId,
|
|
2439
2317
|
source: "reuse",
|
|
2440
2318
|
updated_at: new Date().toISOString(),
|
|
2441
2319
|
});
|
|
@@ -2547,6 +2425,12 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
2547
2425
|
tryResolveSession(1);
|
|
2548
2426
|
return;
|
|
2549
2427
|
}
|
|
2428
|
+
if (req.type === IPC_REQUEST_TYPES.REFRESH_STATUS) {
|
|
2429
|
+
cleanupInactiveSubscribers();
|
|
2430
|
+
const status = buildRuntimeStatus();
|
|
2431
|
+
ipcServer.sendToSockets({ type: IPC_RESPONSE_TYPES.STATUS, data: status });
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2550
2434
|
};
|
|
2551
2435
|
|
|
2552
2436
|
ipcServer.listen(socketPath(projectRoot));
|
|
@@ -5,11 +5,9 @@ const net = require("net");
|
|
|
5
5
|
const path = require("path");
|
|
6
6
|
const { spawn } = require("child_process");
|
|
7
7
|
|
|
8
|
-
const EventBus = require("../../coordination/bus");
|
|
9
8
|
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
10
|
-
const { normalizeReportInput } = require("../../coordination/report/store");
|
|
11
|
-
const { enqueueAgentReport } = require("./reportControlBus");
|
|
12
9
|
const { isRunning, socketPath } = require("./index");
|
|
10
|
+
const controlPlane = require("./controlPlaneService");
|
|
13
11
|
const {
|
|
14
12
|
normalizeProjectRoot,
|
|
15
13
|
resolveGlobalControllerProjectRoot,
|
|
@@ -171,18 +169,6 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
171
169
|
},
|
|
172
170
|
]);
|
|
173
171
|
|
|
174
|
-
function normalizeBusAgentType(agentType = "") {
|
|
175
|
-
const value = String(agentType || "").trim().toLowerCase();
|
|
176
|
-
if (!value) return "mcp-agent";
|
|
177
|
-
if (value === "claude") return "claude-code";
|
|
178
|
-
if (value === "ucode" || value === "ufoo") return "ufoo-code";
|
|
179
|
-
return value;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function nowIso() {
|
|
183
|
-
return new Date().toISOString();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
172
|
function cloneJson(value) {
|
|
187
173
|
return JSON.parse(JSON.stringify(value || {}));
|
|
188
174
|
}
|
|
@@ -286,10 +272,6 @@ async function suppressConsoleToStderr(fn) {
|
|
|
286
272
|
}
|
|
287
273
|
}
|
|
288
274
|
|
|
289
|
-
function createSessionId() {
|
|
290
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
275
|
function listRegisteredProjectRows() {
|
|
294
276
|
return listProjectRuntimes({ validate: true, cleanupTmp: true })
|
|
295
277
|
.filter((row) => !isGlobalControllerProjectRoot(row && row.project_root));
|
|
@@ -315,33 +297,6 @@ function resolveRegisteredProjectRoot(args = {}, options = {}) {
|
|
|
315
297
|
return match.project_root || normalized;
|
|
316
298
|
}
|
|
317
299
|
|
|
318
|
-
function ensureBusLoaded(projectRoot) {
|
|
319
|
-
const bus = new EventBus(projectRoot);
|
|
320
|
-
bus.ensureBus();
|
|
321
|
-
bus.loadBusData();
|
|
322
|
-
return bus;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function assertSubscriberExists(bus, subscriber) {
|
|
326
|
-
const meta = bus.subscriberManager.getSubscriber(subscriber);
|
|
327
|
-
if (!meta) {
|
|
328
|
-
const err = new Error(`subscriber not found: ${subscriber}`);
|
|
329
|
-
err.code = "subscriber_not_found";
|
|
330
|
-
throw err;
|
|
331
|
-
}
|
|
332
|
-
return meta;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function resolveSubscriberArg(args = {}) {
|
|
336
|
-
const subscriber = String(args.subscriber || args.source || "").trim();
|
|
337
|
-
if (!subscriber) {
|
|
338
|
-
const err = new Error("subscriber is required");
|
|
339
|
-
err.code = "invalid_subscriber";
|
|
340
|
-
throw err;
|
|
341
|
-
}
|
|
342
|
-
return subscriber;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
300
|
function connectSocket(sockPath, timeoutMs = 500) {
|
|
346
301
|
return new Promise((resolve, reject) => {
|
|
347
302
|
let timer = null;
|
|
@@ -438,165 +393,37 @@ async function handleMcpStatus(ctx = {}) {
|
|
|
438
393
|
|
|
439
394
|
async function handleRegisterAgent(ctx = {}, args = {}) {
|
|
440
395
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
441
|
-
|
|
442
|
-
const sessionId = String(args.session_id || args.sessionId || createSessionId()).trim();
|
|
443
|
-
const nickname = String(args.nickname || "").trim();
|
|
444
|
-
const launchMode = String(args.launch_mode || args.launchMode || "mcp").trim();
|
|
445
|
-
const capabilities = args.capabilities && typeof args.capabilities === "object"
|
|
446
|
-
? args.capabilities
|
|
447
|
-
: null;
|
|
448
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
449
|
-
const result = await bus.subscriberManager.join(sessionId, agentType, nickname, {
|
|
450
|
-
parentPid: process.pid,
|
|
451
|
-
launchMode,
|
|
452
|
-
scopedNickname: String(args.scoped_nickname || args.scopedNickname || nickname || "").trim(),
|
|
453
|
-
hostName: "ufoo-mcp",
|
|
454
|
-
hostSessionId: `mcp-${process.pid}`,
|
|
455
|
-
hostCapabilities: capabilities,
|
|
456
|
-
});
|
|
457
|
-
const subscriber = result.subscriber;
|
|
458
|
-
const meta = bus.subscriberManager.getSubscriber(subscriber) || {};
|
|
459
|
-
meta.activity_state = String(args.activity_state || "ready");
|
|
460
|
-
meta.activity_since = nowIso();
|
|
461
|
-
meta.mcp_bridge = true;
|
|
462
|
-
if (capabilities) meta.mcp_capabilities = capabilities;
|
|
463
|
-
bus.saveBusData();
|
|
464
|
-
return {
|
|
465
|
-
ok: true,
|
|
466
|
-
project_root: projectRoot,
|
|
467
|
-
subscriber_id: subscriber,
|
|
468
|
-
subscriber,
|
|
469
|
-
session_id: sessionId,
|
|
470
|
-
agent_type: agentType,
|
|
471
|
-
nickname: meta.nickname || result.nickname || "",
|
|
472
|
-
scoped_nickname: meta.scoped_nickname || result.scopedNickname || "",
|
|
473
|
-
launch_mode: launchMode,
|
|
474
|
-
};
|
|
396
|
+
return controlPlane.registerAgent(projectRoot, args);
|
|
475
397
|
}
|
|
476
398
|
|
|
477
399
|
async function handleHeartbeatAgent(ctx = {}, args = {}) {
|
|
478
400
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
479
|
-
|
|
480
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
481
|
-
const meta = assertSubscriberExists(bus, subscriber);
|
|
482
|
-
bus.subscriberManager.updateLastSeen(subscriber);
|
|
483
|
-
meta.status = "active";
|
|
484
|
-
bus.saveBusData();
|
|
485
|
-
return {
|
|
486
|
-
ok: true,
|
|
487
|
-
project_root: projectRoot,
|
|
488
|
-
subscriber,
|
|
489
|
-
last_seen: meta.last_seen,
|
|
490
|
-
};
|
|
401
|
+
return controlPlane.heartbeatAgent(projectRoot, args);
|
|
491
402
|
}
|
|
492
403
|
|
|
493
404
|
async function handlePublishActivityState(ctx = {}, args = {}) {
|
|
494
405
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
495
|
-
|
|
496
|
-
const activityState = String(args.activity_state || args.activityState || "").trim();
|
|
497
|
-
if (!activityState) {
|
|
498
|
-
const err = new Error("activity_state is required");
|
|
499
|
-
err.code = "invalid_activity_state";
|
|
500
|
-
throw err;
|
|
501
|
-
}
|
|
502
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
503
|
-
const meta = assertSubscriberExists(bus, subscriber);
|
|
504
|
-
bus.subscriberManager.updateLastSeen(subscriber);
|
|
505
|
-
meta.status = "active";
|
|
506
|
-
meta.activity_state = activityState;
|
|
507
|
-
meta.activity_detail = String(args.detail || "").trim();
|
|
508
|
-
meta.activity_since = String(args.since || "").trim() || nowIso();
|
|
509
|
-
bus.saveBusData();
|
|
510
|
-
return {
|
|
511
|
-
ok: true,
|
|
512
|
-
project_root: projectRoot,
|
|
513
|
-
subscriber,
|
|
514
|
-
activity_state: meta.activity_state,
|
|
515
|
-
activity_detail: meta.activity_detail,
|
|
516
|
-
activity_since: meta.activity_since,
|
|
517
|
-
};
|
|
406
|
+
return controlPlane.publishActivityState(projectRoot, args);
|
|
518
407
|
}
|
|
519
408
|
|
|
520
409
|
async function handleUpdateAgentMetadata(ctx = {}, args = {}) {
|
|
521
410
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
522
|
-
|
|
523
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
524
|
-
const meta = assertSubscriberExists(bus, subscriber);
|
|
525
|
-
const nickname = String(args.nickname || "").trim();
|
|
526
|
-
if (nickname) {
|
|
527
|
-
await bus.subscriberManager.rename(subscriber, nickname);
|
|
528
|
-
}
|
|
529
|
-
const metadata = args.metadata && typeof args.metadata === "object" ? args.metadata : {};
|
|
530
|
-
if (Object.keys(metadata).length > 0) {
|
|
531
|
-
meta.mcp_metadata = {
|
|
532
|
-
...(meta.mcp_metadata && typeof meta.mcp_metadata === "object" ? meta.mcp_metadata : {}),
|
|
533
|
-
...metadata,
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
bus.subscriberManager.updateLastSeen(subscriber);
|
|
537
|
-
bus.saveBusData();
|
|
538
|
-
const nextMeta = bus.subscriberManager.getSubscriber(subscriber) || meta;
|
|
539
|
-
return {
|
|
540
|
-
ok: true,
|
|
541
|
-
project_root: projectRoot,
|
|
542
|
-
subscriber,
|
|
543
|
-
nickname: nextMeta.nickname || "",
|
|
544
|
-
scoped_nickname: nextMeta.scoped_nickname || nextMeta.nickname || "",
|
|
545
|
-
metadata: nextMeta.mcp_metadata || {},
|
|
546
|
-
};
|
|
411
|
+
return controlPlane.updateAgentMetadata(projectRoot, args);
|
|
547
412
|
}
|
|
548
413
|
|
|
549
414
|
async function handlePollInbox(ctx = {}, args = {}) {
|
|
550
415
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
551
|
-
|
|
552
|
-
const limit = Number.isFinite(Number(args.limit)) && Number(args.limit) > 0
|
|
553
|
-
? Math.floor(Number(args.limit))
|
|
554
|
-
: 50;
|
|
555
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
556
|
-
assertSubscriberExists(bus, subscriber);
|
|
557
|
-
bus.subscriberManager.updateLastSeen(subscriber);
|
|
558
|
-
bus.saveBusData();
|
|
559
|
-
const pending = await bus.messageManager.check(subscriber);
|
|
560
|
-
return {
|
|
561
|
-
ok: true,
|
|
562
|
-
project_root: projectRoot,
|
|
563
|
-
subscriber,
|
|
564
|
-
count: pending.length,
|
|
565
|
-
messages: pending.slice(0, limit),
|
|
566
|
-
truncated: pending.length > limit,
|
|
567
|
-
};
|
|
416
|
+
return controlPlane.pollInbox(projectRoot, args);
|
|
568
417
|
}
|
|
569
418
|
|
|
570
419
|
async function handleReportAgentStatus(ctx = {}, args = {}) {
|
|
571
420
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
572
|
-
|
|
573
|
-
const report = normalizeReportInput({
|
|
574
|
-
...args,
|
|
575
|
-
agent_id: subscriber,
|
|
576
|
-
source: "mcp",
|
|
577
|
-
});
|
|
578
|
-
const queued = await enqueueAgentReport(projectRoot, report, { publisher: subscriber });
|
|
579
|
-
return {
|
|
580
|
-
ok: true,
|
|
581
|
-
project_root: projectRoot,
|
|
582
|
-
status: "queued",
|
|
583
|
-
request_id: queued.request_id,
|
|
584
|
-
report,
|
|
585
|
-
queued,
|
|
586
|
-
};
|
|
421
|
+
return controlPlane.reportAgentStatus(projectRoot, args);
|
|
587
422
|
}
|
|
588
423
|
|
|
589
424
|
async function handleUnregisterAgent(ctx = {}, args = {}) {
|
|
590
425
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
591
|
-
|
|
592
|
-
const bus = ensureBusLoaded(projectRoot);
|
|
593
|
-
const ok = await bus.subscriberManager.leave(subscriber);
|
|
594
|
-
bus.saveBusData();
|
|
595
|
-
return {
|
|
596
|
-
ok,
|
|
597
|
-
project_root: projectRoot,
|
|
598
|
-
subscriber,
|
|
599
|
-
};
|
|
426
|
+
return controlPlane.unregisterAgent(projectRoot, args);
|
|
600
427
|
}
|
|
601
428
|
|
|
602
429
|
function findCustomTool(name) {
|
|
@@ -643,6 +470,7 @@ class UfooMcpServer {
|
|
|
643
470
|
};
|
|
644
471
|
this.initialized = false;
|
|
645
472
|
this.startup = null;
|
|
473
|
+
this.registeredSubscribers = [];
|
|
646
474
|
}
|
|
647
475
|
|
|
648
476
|
async ensureStarted() {
|
|
@@ -717,6 +545,17 @@ class UfooMcpServer {
|
|
|
717
545
|
...this.options,
|
|
718
546
|
toolCallId: id,
|
|
719
547
|
}));
|
|
548
|
+
if (name === "register_agent" && result && result.subscriber && result.project_root) {
|
|
549
|
+
this.registeredSubscribers.push({
|
|
550
|
+
subscriber: result.subscriber,
|
|
551
|
+
projectRoot: result.project_root,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
if (name === "unregister_agent" && result && result.subscriber) {
|
|
555
|
+
this.registeredSubscribers = this.registeredSubscribers.filter(
|
|
556
|
+
(entry) => entry.subscriber !== result.subscriber
|
|
557
|
+
);
|
|
558
|
+
}
|
|
720
559
|
return createJsonRpcResult(id, createMcpContent(result));
|
|
721
560
|
}
|
|
722
561
|
|
|
@@ -729,6 +568,17 @@ class UfooMcpServer {
|
|
|
729
568
|
return createJsonRpcError(id, MCP_ERROR_CODES.INTERNAL_ERROR, err.message || String(err), data);
|
|
730
569
|
}
|
|
731
570
|
}
|
|
571
|
+
|
|
572
|
+
cleanup() {
|
|
573
|
+
for (const { subscriber, projectRoot } of this.registeredSubscribers) {
|
|
574
|
+
try {
|
|
575
|
+
controlPlane.unregisterAgent(projectRoot, { subscriber });
|
|
576
|
+
} catch {
|
|
577
|
+
// best-effort cleanup
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.registeredSubscribers = [];
|
|
581
|
+
}
|
|
732
582
|
}
|
|
733
583
|
|
|
734
584
|
function createUfooMcpServer(options = {}) {
|
|
@@ -772,6 +622,9 @@ async function runMcpServer(options = {}) {
|
|
|
772
622
|
}
|
|
773
623
|
});
|
|
774
624
|
|
|
625
|
+
input.on("end", () => server.cleanup());
|
|
626
|
+
input.on("close", () => server.cleanup());
|
|
627
|
+
|
|
775
628
|
return server;
|
|
776
629
|
}
|
|
777
630
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
|
+
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
5
6
|
|
|
6
7
|
function asTrimmedString(value) {
|
|
7
8
|
if (typeof value !== "string") return "";
|
|
@@ -109,6 +110,49 @@ function resolveScopedNickname(projectRoot, meta = {}, fallback = "") {
|
|
|
109
110
|
return applyProjectNicknamePrefix(projectRoot, fallbackValue, { force: true });
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
function checkAndCleanupNickname(projectRoot, nickname, { tty = "", agentType = "", scopedNickname = "" } = {}) {
|
|
114
|
+
const conflictNickname = scopedNickname || applyProjectNicknamePrefix(projectRoot, nickname, {
|
|
115
|
+
agentType,
|
|
116
|
+
force: true,
|
|
117
|
+
});
|
|
118
|
+
if (!conflictNickname) return { existing: null, cleaned: false };
|
|
119
|
+
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
120
|
+
try {
|
|
121
|
+
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
122
|
+
const entries = Object.entries(bus.agents || {})
|
|
123
|
+
.filter(([, meta]) => {
|
|
124
|
+
const candidate = resolveScopedNickname(projectRoot, meta);
|
|
125
|
+
return meta && candidate === conflictNickname;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (entries.length === 0) {
|
|
129
|
+
return { existing: null, cleaned: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const activeAgent = entries.find(([, meta]) => meta.status === "active");
|
|
133
|
+
if (activeAgent) {
|
|
134
|
+
const [existingId, existingMeta] = activeAgent;
|
|
135
|
+
const sameType = agentType && existingMeta.agent_type === agentType;
|
|
136
|
+
const isStub = sameType && !existingMeta.tty && !existingMeta.activity_state;
|
|
137
|
+
const sameTty = tty && existingMeta.tty === tty;
|
|
138
|
+
if (isStub || sameTty) {
|
|
139
|
+
delete bus.agents[existingId];
|
|
140
|
+
fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
|
|
141
|
+
return { existing: null, cleaned: true };
|
|
142
|
+
}
|
|
143
|
+
return { existing: existingId, cleaned: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [agentId] of entries) {
|
|
147
|
+
delete bus.agents[agentId];
|
|
148
|
+
}
|
|
149
|
+
fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
|
|
150
|
+
return { existing: null, cleaned: true };
|
|
151
|
+
} catch {
|
|
152
|
+
return { existing: null, cleaned: false };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
112
156
|
module.exports = {
|
|
113
157
|
normalizeNicknameSegment,
|
|
114
158
|
buildProjectNicknamePrefix,
|
|
@@ -117,4 +161,5 @@ module.exports = {
|
|
|
117
161
|
stripProjectNicknamePrefix,
|
|
118
162
|
resolveDisplayNickname,
|
|
119
163
|
resolveScopedNickname,
|
|
164
|
+
checkAndCleanupNickname,
|
|
120
165
|
};
|