u-foo 2.4.8 → 2.4.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.4.8",
3
+ "version": "2.4.10",
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",
package/src/code/tui.js CHANGED
@@ -5,6 +5,7 @@ const {
5
5
  StreamBuffer,
6
6
  UCODE_BANNER_LINES,
7
7
  UCODE_VERSION,
8
+ appendToolMergeEntry,
8
9
  buildMergedToolExpandedLines,
9
10
  buildMergedToolSummaryText,
10
11
  buildUcodeBannerLines,
@@ -31,6 +32,7 @@ const {
31
32
  shouldClearAgentSelectionOnUp,
32
33
  shouldEnterAgentSelection,
33
34
  shouldUseUcodeTui,
35
+ splitStreamingLogChunk,
34
36
  stripLeakedEscapeTags,
35
37
  } = fmt;
36
38
 
@@ -64,8 +66,10 @@ module.exports = {
64
66
  resolveHistoryDownTransition,
65
67
  filterSelectableAgents,
66
68
  stripLeakedEscapeTags,
69
+ splitStreamingLogChunk,
67
70
  createEscapeTagStripper,
68
71
  formatPendingElapsed,
72
+ appendToolMergeEntry,
69
73
  normalizeBashToolCommand,
70
74
  normalizeToolMergeEntry,
71
75
  buildMergedToolSummaryText,
@@ -19,6 +19,7 @@ const IPC_REQUEST_TYPES = {
19
19
  AGENT_READY: "agent_ready",
20
20
  AGENT_REPORT: "agent_report",
21
21
  ASSIGN_ROLE: "assign_role",
22
+ REFRESH_STATUS: "refresh_status",
22
23
  };
23
24
 
24
25
  const IPC_RESPONSE_TYPES = {
@@ -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 crypto = require("crypto");
2361
- const requestedReuse = req.reuseSession && typeof req.reuseSession === "object"
2362
- ? req.reuseSession
2363
- : null;
2364
- const reuseSessionId = typeof requestedReuse?.sessionId === "string"
2365
- ? requestedReuse.sessionId.trim()
2366
- : "";
2367
- const reuseSubscriberId = typeof requestedReuse?.subscriberId === "string"
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
- if (finalNickname) {
2413
- const nickCheck = checkAndCleanupNickname(projectRoot, finalNickname, {
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
- const agentType = normalizeBusAgentType(args.agent_type || args.agentType || "mcp-agent");
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
- const subscriber = resolveSubscriberArg(args);
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
- const subscriber = resolveSubscriberArg(args);
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
- const subscriber = resolveSubscriberArg(args);
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
- const subscriber = resolveSubscriberArg(args);
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
- const subscriber = resolveSubscriberArg(args);
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
- const subscriber = resolveSubscriberArg(args);
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
  };
@@ -518,6 +518,24 @@ function normalizeToolMergeEntry(entry = {}) {
518
518
  };
519
519
  }
520
520
 
521
+ function appendToolMergeEntry(currentMerge = null, entry = {}, scope = 0, nextId = 1) {
522
+ const toolEntry = normalizeToolMergeEntry(entry);
523
+ const current = currentMerge && typeof currentMerge === "object" ? currentMerge : null;
524
+ const normalizedScope = Number.isFinite(Number(scope)) ? Number(scope) : 0;
525
+ if (current && current.scope === normalizedScope && Array.isArray(current.entries)) {
526
+ return {
527
+ ...current,
528
+ entries: current.entries.concat([toolEntry]),
529
+ };
530
+ }
531
+ return {
532
+ id: Number.isFinite(Number(nextId)) ? Number(nextId) : 1,
533
+ scope: normalizedScope,
534
+ entries: [toolEntry],
535
+ expanded: false,
536
+ };
537
+ }
538
+
521
539
  function buildMergedToolSummaryText(entries = []) {
522
540
  const list = Array.isArray(entries)
523
541
  ? entries.map((item) => normalizeToolMergeEntry(item))
@@ -551,6 +569,27 @@ function buildMergedToolExpandedLines(entries = []) {
551
569
  });
552
570
  }
553
571
 
572
+ function splitStreamingLogChunk(buffer = "", chunk = "", options = {}) {
573
+ const previous = String(buffer || "");
574
+ const text = String(chunk || "");
575
+ const combined = `${previous}${text}`;
576
+ const parts = combined.split(/\r?\n/);
577
+ const lines = parts.slice(0, -1);
578
+ const dropLeadingBlank = Boolean(options.dropLeadingBlank) && previous === "";
579
+
580
+ if (dropLeadingBlank) {
581
+ while (lines.length > 0 && lines[0] === "") {
582
+ lines.shift();
583
+ }
584
+ }
585
+
586
+ return {
587
+ lines,
588
+ buffer: parts[parts.length - 1] || "",
589
+ sawVisible: /[^\s]/.test(text),
590
+ };
591
+ }
592
+
554
593
  // Composed live-row text for an in-flight tool group: shows the merged
555
594
  // summary, plus a "(Ctrl+O expand)" hint once at least two entries are
556
595
  // present.
@@ -937,6 +976,7 @@ module.exports = {
937
976
  TOOL_LABELS,
938
977
  UCODE_BANNER_LINES,
939
978
  UCODE_VERSION,
979
+ appendToolMergeEntry,
940
980
  buildMergedToolExpandedLines,
941
981
  buildMergedToolSummaryText,
942
982
  buildToolMergeRowText,
@@ -970,5 +1010,6 @@ module.exports = {
970
1010
  shouldClearAgentSelectionOnUp,
971
1011
  shouldEnterAgentSelection,
972
1012
  shouldUseUcodeTui,
1013
+ splitStreamingLogChunk,
973
1014
  stripLeakedEscapeTags,
974
1015
  };
@@ -78,6 +78,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
78
78
  const { stdout } = useStdout();
79
79
  const lineSeqRef = useRef(banner.length + 1);
80
80
  const mergeIdRef = useRef(0);
81
+ const toolMergeScopeRef = useRef(0);
81
82
 
82
83
  const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
83
84
  ? agents[selectedAgentIndex]
@@ -261,13 +262,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
261
262
  const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
262
263
 
263
264
  setActiveMerge((current) => {
264
- let next;
265
- if (current) {
266
- next = { ...current, entries: current.entries.concat([toolEntry]) };
267
- } else {
265
+ const scope = toolMergeScopeRef.current;
266
+ const isNewScope = !(current && current.scope === scope);
267
+ if (isNewScope) {
268
268
  mergeIdRef.current += 1;
269
- next = { id: mergeIdRef.current, entries: [toolEntry], expanded: false };
270
269
  }
270
+ const next = fmt.appendToolMergeEntry(current, toolEntry, scope, mergeIdRef.current);
271
271
  if (next.entries.length >= 2) lastMergeRef.current = next;
272
272
  return next;
273
273
  });
@@ -313,6 +313,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
313
313
  const executeLine = useCallback(async (rawValue) => {
314
314
  const normalized = String(rawValue || "").replace(/\r?\n/g, " ").trim();
315
315
  if (!normalized) return;
316
+ toolMergeScopeRef.current += 1;
317
+ flushActiveMerge();
316
318
  appendLogLine(`› ${normalized}`);
317
319
 
318
320
  const runtimeWorkspace = String(
@@ -459,6 +461,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
459
461
  setNlStatus("Waiting for model...");
460
462
  let streamBuf = "";
461
463
  let sawStreamText = false;
464
+ let streamStarted = false;
465
+ let dropLeadingStreamBlank = false;
462
466
  let nlResult = null;
463
467
  try {
464
468
  nlResult = await props.runNaturalLanguageTask(result.task, props.state, {
@@ -477,13 +481,21 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
477
481
  onDelta: (delta) => {
478
482
  const text = String(delta || "");
479
483
  if (!text) return;
480
- if (/[^\s]/.test(text)) sawStreamText = true;
481
- streamBuf += text;
482
- const parts = streamBuf.split(/\r?\n/);
483
- while (parts.length > 1) {
484
- appendLogLine(parts.shift());
484
+ if (!streamStarted) {
485
+ flushActiveMerge();
486
+ streamStarted = true;
487
+ }
488
+ const split = fmt.splitStreamingLogChunk(streamBuf, text, {
489
+ dropLeadingBlank: dropLeadingStreamBlank,
490
+ });
491
+ if (split.sawVisible) {
492
+ sawStreamText = true;
493
+ dropLeadingStreamBlank = false;
494
+ }
495
+ for (const line of split.lines) {
496
+ appendLogLine(line);
485
497
  }
486
- streamBuf = parts[0];
498
+ streamBuf = split.buffer;
487
499
  },
488
500
  onToolLog: (entry) => {
489
501
  if (!entry || typeof entry !== "object") return;
@@ -491,6 +503,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
491
503
  const label = fmt.TOOL_LABELS[String(entry.tool || "").toLowerCase()] ||
492
504
  `Calling ${entry.tool}`;
493
505
  setNlStatus(`${label}...`);
506
+ dropLeadingStreamBlank = true;
494
507
  }
495
508
  logToolHint(entry, entry.result);
496
509
  },
@@ -516,6 +529,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
516
529
  const summary = props.formatNlResult(nlResult, false);
517
530
  if (summary) appendLogText(summary);
518
531
  }
532
+ flushActiveMerge();
519
533
  try {
520
534
  const persisted = props.persistSessionState(props.state);
521
535
  if (persisted && persisted.ok === false) {
@@ -531,7 +545,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
531
545
  default:
532
546
  if (result.output) appendLogText(result.output);
533
547
  }
534
- }, [appendLogLine, appendLogText, exit, props, logToolHint]);
548
+ }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge]);
535
549
  // ^ `props` is captured by the createUcodeApp closure on a single mount,
536
550
  // so its reference is stable across renders even though it looks like a
537
551
  // changing dep to React's exhaustive-deps lint.