u-foo 3.0.17 → 3.0.19

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.
@@ -357,10 +357,12 @@ function createCommandExecutor(options = {}) {
357
357
  }
358
358
 
359
359
  function logMcpHelp() {
360
- logMessage("system", "{cyan-fg}MCP bridge:{/cyan-fg} local stdio bridge for external MCP-capable agents");
361
- logMessage("system", " • Configure client command: ufoo mcp");
360
+ logMessage("system", "{cyan-fg}MCP server:{/cyan-fg} singleton listener in the global controller daemon");
361
+ logMessage("system", " • Configure Codex direct HTTP: ufoo mcp configure codex");
362
+ logMessage("system", " • Compatibility proxy command: ufoo mcp");
362
363
  logMessage("system", " • Disable daemon auto-start: ufoo mcp --no-auto-start");
363
- logMessage("system", " • Topology: one global bridge, project tools route through registered project daemons");
364
+ logMessage("system", " • Listener control: ufoo mcp status | ufoo mcp restart");
365
+ logMessage("system", " • Topology: one global tool router; project calls use registered project runtimes");
364
366
  logMessage("system", " • Chat diagnostics: /mcp status, /mcp tools, /mcp help");
365
367
  }
366
368
 
@@ -417,8 +419,12 @@ function createCommandExecutor(options = {}) {
417
419
  throw new Error("empty MCP status response");
418
420
  }
419
421
  const projects = Array.isArray(status.projects) ? status.projects : [];
420
- logMessage("system", "{cyan-fg}MCP bridge:{/cyan-fg} local stdio server");
421
- logMessage("system", "command: {cyan-fg}ufoo mcp{/cyan-fg}");
422
+ logMessage("system", "{cyan-fg}MCP server:{/cyan-fg} global Streamable HTTP listener");
423
+ logMessage("system", `listener: ${status.http?.running ? "{green-fg}running{/green-fg}" : "{yellow-fg}not running{/yellow-fg}"}`);
424
+ if (status.http?.endpoint) {
425
+ logMessage("system", ` • endpoint: ${escapeBlessed(status.http.endpoint)}`);
426
+ }
427
+ logMessage("system", " • stdio fallback: {cyan-fg}ufoo mcp{/cyan-fg}");
422
428
  logMessage("system", ` • global controller: ${escapeBlessed(status.global_controller_root || "")}`);
423
429
  logMessage("system", ` • daemon: ${status.global_controller_running ? "{green-fg}running{/green-fg}" : "{yellow-fg}not running{/yellow-fg}"}`);
424
430
  logMessage("system", " • client auto-start: enabled by default");
@@ -587,7 +587,7 @@ async function runCli(argv) {
587
587
  });
588
588
  program
589
589
  .command("mcp")
590
- .description("Run the local global ufoo MCP bridge over stdio")
590
+ .description("Run the global MCP stdio proxy; use bin entry for listener status/configuration")
591
591
  .option("--no-auto-start", "Do not auto-start the home-scoped global controller daemon")
592
592
  .action((opts) => {
593
593
  const repoRoot = getPackageRoot();
@@ -1809,8 +1809,7 @@ async function runCli(argv) {
1809
1809
  return;
1810
1810
  }
1811
1811
  if (cmd === "mcp") {
1812
- const mcpArgs = ["mcp"];
1813
- if (rest.includes("--no-auto-start")) mcpArgs.push("--no-auto-start");
1812
+ const mcpArgs = ["mcp", ...rest];
1814
1813
  run(resolveNodeExecutable(), [path.join(repoRoot, "bin", "ufoo.js"), ...mcpArgs]);
1815
1814
  return;
1816
1815
  }
@@ -17,7 +17,7 @@ ufoo integration requirements:
17
17
  - Use shared memory for durable project facts. Read existing memory before writing new memory; do not use it for transient task state.
18
18
  - Support launch/close/resume/inject flows managed by ufoo daemon.
19
19
  - Prefer canonical ufoo commands (`ufoo ctx`, `ufoo bus`, `ufoo memory`, `ufoo report`) for coordination and status sync.
20
- - A nonempty `UFOO_SUBSCRIBER_ID` means the ufoo wrapper/daemon already registered this Agent and can inject directly into its monitored session. Reuse that identity; never call MCP `register_agent`, run bare `ufoo bus join`, or start resident `ufoo bus poll`.
20
+ - A nonempty `UFOO_SUBSCRIBER_ID` in this Agent's inherited launch environment means the ufoo wrapper/daemon already registered this Agent and can inject directly into its monitored session. Reuse that identity; never call MCP `register_agent`, run bare `ufoo bus join`, or start resident `ufoo bus poll`.
21
21
  - After sending a bus message, do not poll `ufoo bus check`, invoke `/ubus`, sleep, or wait for a reply. Continue the current task; any follow-up message will be automatically injected into your prompt/session.
22
22
 
23
23
  Execution protocol:
package/src/config.js CHANGED
@@ -36,6 +36,7 @@ const DEFAULT_CONFIG = {
36
36
  launchMode: "auto",
37
37
  agentProvider: "codex-cli",
38
38
  controllerMode: "main",
39
+ mcpPort: 47631,
39
40
  codexInternalThreadMode: "api",
40
41
  codexAuthPath: "",
41
42
  codexOauthRefreshWindowSec: 300,
@@ -125,6 +126,12 @@ function normalizeCodexInternalThreadMode(value) {
125
126
  return "legacy";
126
127
  }
127
128
 
129
+ function normalizeMcpPort(value) {
130
+ const parsed = Number.parseInt(value, 10);
131
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) return DEFAULT_CONFIG.mcpPort;
132
+ return parsed;
133
+ }
134
+
128
135
  function normalizeCodexAuthPath(value) {
129
136
  return typeof value === "string" ? value.trim() : "";
130
137
  }
@@ -184,6 +191,7 @@ function loadConfig(projectRoot) {
184
191
  controllerMode: Object.prototype.hasOwnProperty.call(raw, "controllerMode")
185
192
  ? normalizeControllerMode(raw.controllerMode)
186
193
  : DEFAULT_CONFIG.controllerMode,
194
+ mcpPort: normalizeMcpPort(raw.mcpPort),
187
195
  codexInternalThreadMode: Object.prototype.hasOwnProperty.call(raw, "codexInternalThreadMode")
188
196
  ? normalizeCodexInternalThreadMode(raw.codexInternalThreadMode)
189
197
  : DEFAULT_CONFIG.codexInternalThreadMode,
@@ -240,6 +248,7 @@ function saveConfig(projectRoot, config) {
240
248
  merged.routerProvider = typeof merged.routerProvider === "string" ? merged.routerProvider.trim() : "";
241
249
  merged.routerModel = typeof merged.routerModel === "string" ? merged.routerModel.trim() : "";
242
250
  merged.controllerMode = normalizeControllerMode(merged.controllerMode);
251
+ merged.mcpPort = normalizeMcpPort(merged.mcpPort);
243
252
  merged.codexInternalThreadMode = normalizeCodexInternalThreadMode(merged.codexInternalThreadMode);
244
253
  merged.codexAuthPath = normalizeCodexAuthPath(merged.codexAuthPath);
245
254
  merged.codexOauthRefreshWindowSec = normalizeCodexOauthRefreshWindowSec(merged.codexOauthRefreshWindowSec);
@@ -291,6 +300,7 @@ module.exports = {
291
300
  defaultRouterProviderForAgentProvider,
292
301
  defaultRouterModelForProvider,
293
302
  normalizeControllerMode,
303
+ normalizeMcpPort,
294
304
  normalizeCodexInternalThreadMode,
295
305
  normalizeCodexAuthPath,
296
306
  normalizeCodexOauthRefreshWindowSec,
@@ -100,6 +100,15 @@ function hasProviderSession(meta) {
100
100
  return typeof meta?.provider_session_id === "string" && meta.provider_session_id.trim() !== "";
101
101
  }
102
102
 
103
+ function isManagedMcpRegistration(meta) {
104
+ return Boolean(meta && meta.mcp_bridge === true && meta.mcp_agent_handle_hash);
105
+ }
106
+
107
+ function isRecentMcpHeartbeat(meta, nowMs = Date.now()) {
108
+ const lastSeenMs = Date.parse(String(meta?.last_seen || ""));
109
+ return Number.isFinite(lastSeenMs) && nowMs - lastSeenMs <= 30 * 1000;
110
+ }
111
+
103
112
  /**
104
113
  * 订阅者管理
105
114
  */
@@ -458,6 +467,29 @@ class SubscriberManager {
458
467
  if (!this.busData.agents) return;
459
468
 
460
469
  for (const [id, meta] of Object.entries(this.busData.agents)) {
470
+ if (isManagedMcpRegistration(meta)) {
471
+ const expiresAtMs = Date.parse(String(meta.mcp_lease_expires_at || ""));
472
+ const expired = !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now();
473
+ if (!expired || isRecentMcpHeartbeat(meta)) {
474
+ continue;
475
+ }
476
+ if (meta.status === "active") {
477
+ this.logRegistry("cleanup_inactive_mark", {
478
+ source: "bus.subscriber.cleanupInactive",
479
+ subscriber: id,
480
+ reason: "mcp_agent_lease_expired",
481
+ status: meta.status || "",
482
+ launch_mode: meta.launch_mode || "",
483
+ last_seen: meta.last_seen || "",
484
+ lease_expires_at: meta.mcp_lease_expires_at || "",
485
+ });
486
+ meta.status = "inactive";
487
+ meta.activity_state = "";
488
+ meta.mcp_revoked_at = getTimestamp();
489
+ this.cleanupSubscriberArtifacts(id);
490
+ }
491
+ continue;
492
+ }
461
493
  if (isInternalLaunchMode(meta)) {
462
494
  const recoverable = hasProviderSession(meta);
463
495
  if (meta.status === "inactive") {
@@ -24,6 +24,8 @@ function getUfooPaths(projectRoot) {
24
24
  const ufooDaemonPid = path.join(runDir, "ufoo-daemon.pid");
25
25
  const ufooDaemonLog = path.join(runDir, "ufoo-daemon.log");
26
26
  const ufooSock = path.join(runDir, "ufoo.sock");
27
+ const mcpToken = path.join(runDir, "mcp-token");
28
+ const mcpEndpoint = path.join(runDir, "mcp-endpoint.json");
27
29
 
28
30
  return {
29
31
  ufooDir,
@@ -46,6 +48,8 @@ function getUfooPaths(projectRoot) {
46
48
  ufooDaemonPid,
47
49
  ufooDaemonLog,
48
50
  ufooSock,
51
+ mcpToken,
52
+ mcpEndpoint,
49
53
  };
50
54
  }
51
55
 
@@ -20,6 +20,10 @@ const IPC_REQUEST_TYPES = {
20
20
  AGENT_REPORT: "agent_report",
21
21
  ASSIGN_ROLE: "assign_role",
22
22
  REFRESH_STATUS: "refresh_status",
23
+ CONTROL_PLANE_CALL: "control_plane_call",
24
+ CONTROL_PLANE_CANCEL: "control_plane_cancel",
25
+ MCP_STATUS: "mcp_status",
26
+ MCP_RESTART: "mcp_restart",
23
27
  };
24
28
 
25
29
  const IPC_RESPONSE_TYPES = {
@@ -29,6 +33,7 @@ const IPC_RESPONSE_TYPES = {
29
33
  ERROR: "error",
30
34
  BUS_SEND_OK: "bus_send_ok",
31
35
  REGISTER_OK: "register_ok",
36
+ CONTROL_PLANE_RESULT: "control_plane_result",
32
37
  };
33
38
 
34
39
  const BUS_STATUS_PHASES = {
@@ -22,6 +22,8 @@ const WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = 600;
22
22
  const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = 600;
23
23
  const WAIT_FOR_MESSAGE_POLL_INTERVAL_MS = 1000;
24
24
  const WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS = 15000;
25
+ const MCP_AGENT_LEASE_TTL_MS = 24 * 60 * 60 * 1000;
26
+ const MCP_AGENT_RECENT_HEARTBEAT_MS = 30 * 1000;
25
27
 
26
28
  function nowIso() {
27
29
  return new Date().toISOString();
@@ -70,6 +72,61 @@ function createCryptoSessionId() {
70
72
  return crypto.randomBytes(4).toString("hex");
71
73
  }
72
74
 
75
+ function createAgentHandle() {
76
+ return crypto.randomBytes(32).toString("base64url");
77
+ }
78
+
79
+ function hashAgentHandle(handle = "") {
80
+ return crypto.createHash("sha256").update(String(handle || ""), "utf8").digest("hex");
81
+ }
82
+
83
+ function leaseExpiryIso(nowMs = Date.now()) {
84
+ return new Date(nowMs + MCP_AGENT_LEASE_TTL_MS).toISOString();
85
+ }
86
+
87
+ function extendMcpAgentLease(meta, nowMs = Date.now()) {
88
+ meta.mcp_lease_expires_at = leaseExpiryIso(nowMs);
89
+ delete meta.mcp_revoked_at;
90
+ return meta.mcp_lease_expires_at;
91
+ }
92
+
93
+ function assertAgentHandle(bus, subscriber, args = {}, options = {}) {
94
+ const meta = assertSubscriberExists(bus, subscriber);
95
+ if (meta.mcp_bridge !== true || !meta.mcp_agent_handle_hash) {
96
+ const err = new Error(`subscriber is not an MCP-registered Agent: ${subscriber}`);
97
+ err.code = "agent_handle_not_available";
98
+ throw err;
99
+ }
100
+ const handle = String(args.agent_handle || args.agentHandle || "").trim();
101
+ if (!handle) {
102
+ const err = new Error("agent_handle is required");
103
+ err.code = "agent_handle_required";
104
+ throw err;
105
+ }
106
+ const actual = Buffer.from(hashAgentHandle(handle), "hex");
107
+ const expected = Buffer.from(String(meta.mcp_agent_handle_hash || ""), "hex");
108
+ if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
109
+ const err = new Error("agent_handle does not own this subscriber");
110
+ err.code = "invalid_agent_handle";
111
+ throw err;
112
+ }
113
+ if (options.allowInactive !== true && meta.status !== "active") {
114
+ const err = new Error(`Agent registration is inactive: ${subscriber}`);
115
+ err.code = "agent_lease_inactive";
116
+ throw err;
117
+ }
118
+ const expiresAtMs = Date.parse(String(meta.mcp_lease_expires_at || ""));
119
+ if (
120
+ options.allowExpired !== true
121
+ && (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now())
122
+ ) {
123
+ const err = new Error(`Agent lease expired: ${subscriber}`);
124
+ err.code = "agent_lease_expired";
125
+ throw err;
126
+ }
127
+ return meta;
128
+ }
129
+
73
130
  function notifyDaemonRefresh(projectRoot) {
74
131
  if (!isRunning(projectRoot)) return;
75
132
  const sock = socketPath(projectRoot);
@@ -99,6 +156,10 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
99
156
  const hostCapabilities = args.hostCapabilities && typeof args.hostCapabilities === "object"
100
157
  ? args.hostCapabilities
101
158
  : capabilities;
159
+ const clientInstanceId = String(
160
+ args.client_instance_id || args.clientInstanceId || ""
161
+ ).trim();
162
+ const bus = ensureBusLoaded(projectRoot);
102
163
 
103
164
  // Session ID: explicit > reuse > generate
104
165
  let sessionId;
@@ -113,7 +174,22 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
113
174
  const reuseProviderSessionId = typeof reuseSession?.providerSessionId === "string"
114
175
  ? reuseSession.providerSessionId.trim() : "";
115
176
 
116
- if (explicitSessionId) {
177
+ const recoveredEntry = !validateParentPid && clientInstanceId
178
+ ? Object.entries(bus.busData.agents || {}).find(([, meta]) => (
179
+ meta
180
+ && meta.mcp_bridge === true
181
+ && meta.agent_type === agentType
182
+ && meta.mcp_client_instance_id === clientInstanceId
183
+ ))
184
+ : null;
185
+ const recoveredSubscriber = recoveredEntry ? recoveredEntry[0] : "";
186
+ const recoveredSessionId = recoveredSubscriber.startsWith(`${agentType}:`)
187
+ ? recoveredSubscriber.slice(agentType.length + 1)
188
+ : "";
189
+
190
+ if (recoveredSessionId) {
191
+ sessionId = recoveredSessionId;
192
+ } else if (explicitSessionId) {
117
193
  sessionId = explicitSessionId;
118
194
  } else if (reuseSessionId && reuseSubscriberId === `${agentType}:${reuseSessionId}`) {
119
195
  sessionId = reuseSessionId;
@@ -165,7 +241,18 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
165
241
  if (reuseSessionId) joinOptions.reuseSessionId = reuseSessionId;
166
242
  if (reuseProviderSessionId) joinOptions.reuseProviderSessionId = reuseProviderSessionId;
167
243
 
168
- const bus = ensureBusLoaded(projectRoot);
244
+ const candidateSubscriber = `${agentType}:${sessionId}`;
245
+ const existingCandidate = bus.subscriberManager.getSubscriber(candidateSubscriber);
246
+ if (
247
+ !validateParentPid
248
+ && existingCandidate
249
+ && existingCandidate.mcp_agent_handle_hash
250
+ && (!clientInstanceId || existingCandidate.mcp_client_instance_id !== clientInstanceId)
251
+ ) {
252
+ const err = new Error(`subscriber is already registered: ${candidateSubscriber}`);
253
+ err.code = "subscriber_already_registered";
254
+ throw err;
255
+ }
169
256
  const result = await bus.subscriberManager.join(sessionId, agentType, finalNickname, joinOptions);
170
257
  const subscriber = result.subscriber;
171
258
  if (finalNickname) {
@@ -175,6 +262,14 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
175
262
  meta.activity_state = String(args.activity_state || "ready");
176
263
  meta.activity_since = nowIso();
177
264
  meta.mcp_bridge = !validateParentPid;
265
+ let agentHandle = "";
266
+ if (!validateParentPid) {
267
+ agentHandle = createAgentHandle();
268
+ meta.mcp_agent_handle_hash = hashAgentHandle(agentHandle);
269
+ meta.mcp_client_instance_id = clientInstanceId;
270
+ meta.mcp_registered_at = meta.mcp_registered_at || nowIso();
271
+ extendMcpAgentLease(meta);
272
+ }
178
273
  if (hostCapabilities) meta.mcp_capabilities = hostCapabilities;
179
274
  bus.saveBusData();
180
275
  notifyDaemonRefresh(projectRoot);
@@ -188,6 +283,12 @@ async function registerAgentFull(projectRoot, args = {}, options = {}) {
188
283
  nickname: meta.nickname || result.nickname || finalNickname || "",
189
284
  scoped_nickname: meta.scoped_nickname || result.scopedNickname || scopedNickname || "",
190
285
  launch_mode: launchMode,
286
+ ...(agentHandle ? {
287
+ agent_handle: agentHandle,
288
+ lease_expires_at: meta.mcp_lease_expires_at,
289
+ client_instance_id: clientInstanceId,
290
+ recovered: Boolean(recoveredSubscriber),
291
+ } : {}),
191
292
  reuseProviderSessionId,
192
293
  skipSessionResolve: !!args.skipSessionResolve,
193
294
  };
@@ -203,9 +304,10 @@ async function registerAgent(projectRoot, args = {}) {
203
304
  async function heartbeatAgent(projectRoot, args = {}) {
204
305
  const subscriber = resolveSubscriberArg(args);
205
306
  const bus = ensureBusLoaded(projectRoot);
206
- const meta = assertSubscriberExists(bus, subscriber);
307
+ const meta = assertAgentHandle(bus, subscriber, args);
207
308
  bus.subscriberManager.updateLastSeen(subscriber);
208
309
  meta.status = "active";
310
+ const leaseExpiresAt = extendMcpAgentLease(meta);
209
311
  bus.saveBusData();
210
312
  notifyDaemonRefresh(projectRoot);
211
313
  return {
@@ -213,6 +315,7 @@ async function heartbeatAgent(projectRoot, args = {}) {
213
315
  project_root: projectRoot,
214
316
  subscriber,
215
317
  last_seen: meta.last_seen,
318
+ lease_expires_at: leaseExpiresAt,
216
319
  };
217
320
  }
218
321
 
@@ -225,7 +328,7 @@ async function publishActivityState(projectRoot, args = {}) {
225
328
  throw err;
226
329
  }
227
330
  const bus = ensureBusLoaded(projectRoot);
228
- const meta = assertSubscriberExists(bus, subscriber);
331
+ const meta = assertAgentHandle(bus, subscriber, args);
229
332
  bus.subscriberManager.updateLastSeen(subscriber);
230
333
  meta.status = "active";
231
334
  meta.activity_state = activityState;
@@ -246,7 +349,7 @@ async function publishActivityState(projectRoot, args = {}) {
246
349
  async function updateAgentMetadata(projectRoot, args = {}) {
247
350
  const subscriber = resolveSubscriberArg(args);
248
351
  const bus = ensureBusLoaded(projectRoot);
249
- const meta = assertSubscriberExists(bus, subscriber);
352
+ const meta = assertAgentHandle(bus, subscriber, args);
250
353
  const nickname = String(args.nickname || "").trim();
251
354
  if (nickname) {
252
355
  await bus.subscriberManager.rename(subscriber, nickname);
@@ -278,7 +381,7 @@ async function pollInbox(projectRoot, args = {}) {
278
381
  ? Math.floor(Number(args.limit))
279
382
  : 50;
280
383
  const bus = ensureBusLoaded(projectRoot);
281
- assertSubscriberExists(bus, subscriber);
384
+ assertAgentHandle(bus, subscriber, args);
282
385
  bus.subscriberManager.updateLastSeen(subscriber);
283
386
  bus.saveBusData();
284
387
  const pending = await bus.messageManager.check(subscriber);
@@ -365,14 +468,15 @@ function eventSeq(event = {}) {
365
468
  return Number.isInteger(seq) && seq > 0 ? seq : 0;
366
469
  }
367
470
 
368
- function touchWaitingSubscriber(bus, subscriber) {
471
+ function touchWaitingSubscriber(bus, subscriber, args = {}) {
369
472
  // Long waits span concurrent metadata updates from other agents. Reload
370
473
  // before writing heartbeat state so a stale in-memory registry cannot
371
474
  // overwrite those updates.
372
475
  bus.loadBusData();
373
- const meta = assertSubscriberExists(bus, subscriber);
476
+ const meta = assertAgentHandle(bus, subscriber, args);
374
477
  meta.status = "active";
375
478
  bus.subscriberManager.updateLastSeen(subscriber);
479
+ extendMcpAgentLease(meta);
376
480
  bus.saveBusData();
377
481
  }
378
482
 
@@ -393,7 +497,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
393
497
  const timeoutMs = timeoutSeconds * 1000;
394
498
 
395
499
  const bus = ensureBusLoaded(projectRoot);
396
- touchWaitingSubscriber(bus, subscriber);
500
+ touchWaitingSubscriber(bus, subscriber, args);
397
501
  const lease = acquirePollLease(path.join(
398
502
  bus.busDir,
399
503
  "pids",
@@ -418,7 +522,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
418
522
  if (unseen.length > 0) {
419
523
  const messages = unseen.slice(0, limit);
420
524
  const lastSeq = Math.max(afterSeq, ...messages.map(eventSeq));
421
- touchWaitingSubscriber(bus, subscriber);
525
+ touchWaitingSubscriber(bus, subscriber, args);
422
526
  return {
423
527
  ok: true,
424
528
  project_root: projectRoot,
@@ -436,7 +540,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
436
540
 
437
541
  const current = now();
438
542
  if (current >= deadline) {
439
- touchWaitingSubscriber(bus, subscriber);
543
+ touchWaitingSubscriber(bus, subscriber, args);
440
544
  return {
441
545
  ok: true,
442
546
  project_root: projectRoot,
@@ -453,7 +557,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
453
557
  }
454
558
 
455
559
  if (current >= nextHeartbeatAt) {
456
- touchWaitingSubscriber(bus, subscriber);
560
+ touchWaitingSubscriber(bus, subscriber, args);
457
561
  nextHeartbeatAt = current + heartbeatIntervalMs;
458
562
  }
459
563
 
@@ -469,6 +573,8 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
469
573
 
470
574
  async function reportAgentStatus(projectRoot, args = {}) {
471
575
  const subscriber = resolveSubscriberArg(args);
576
+ const bus = ensureBusLoaded(projectRoot);
577
+ assertAgentHandle(bus, subscriber, args);
472
578
  const report = normalizeReportInput({
473
579
  ...args,
474
580
  agent_id: subscriber,
@@ -488,6 +594,12 @@ async function reportAgentStatus(projectRoot, args = {}) {
488
594
  async function unregisterAgent(projectRoot, args = {}) {
489
595
  const subscriber = resolveSubscriberArg(args);
490
596
  const bus = ensureBusLoaded(projectRoot);
597
+ const meta = assertAgentHandle(bus, subscriber, args, {
598
+ allowExpired: true,
599
+ allowInactive: true,
600
+ });
601
+ meta.mcp_revoked_at = nowIso();
602
+ meta.mcp_lease_expires_at = meta.mcp_revoked_at;
491
603
  const ok = await bus.subscriberManager.leave(subscriber);
492
604
  bus.saveBusData();
493
605
  notifyDaemonRefresh(projectRoot);
@@ -504,6 +616,10 @@ module.exports = {
504
616
  assertSubscriberExists,
505
617
  resolveSubscriberArg,
506
618
  createSessionId,
619
+ createAgentHandle,
620
+ hashAgentHandle,
621
+ assertAgentHandle,
622
+ extendMcpAgentLease,
507
623
  notifyDaemonRefresh,
508
624
  registerAgentFull,
509
625
  registerAgent,
@@ -517,4 +633,6 @@ module.exports = {
517
633
  normalizeWaitForMessageArgs,
518
634
  WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
519
635
  WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
636
+ MCP_AGENT_LEASE_TTL_MS,
637
+ MCP_AGENT_RECENT_HEARTBEAT_MS,
520
638
  };
@@ -45,6 +45,10 @@ const {
45
45
  checkAndCleanupNickname,
46
46
  } = require("./nicknameScope");
47
47
  const { resolveNodeExecutable } = require("../process/nodeExecutable");
48
+ const {
49
+ createProjectRuntimeControlPlane,
50
+ } = require("./projectRuntimeControlPlane");
51
+ const { loadConfig, normalizeMcpPort } = require("../../config");
48
52
 
49
53
  let providerSessions = null;
50
54
  let sessionResolveHandles = new Map();
@@ -1351,9 +1355,63 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1351
1355
  },
1352
1356
  });
1353
1357
  deliveryScheduler.start();
1358
+ const runtimeControlPlane = createProjectRuntimeControlPlane({ projectRoot });
1359
+ let mcpHttpServer = null;
1360
+ if (isGlobalControllerProjectRoot(projectRoot) && process.env.UFOO_MCP_HTTP_DISABLED !== "1") {
1361
+ const { createGlobalMcpHttpServer } = require("./mcpHttpServer");
1362
+ const config = loadConfig(projectRoot);
1363
+ const configuredPort = process.env.UFOO_MCP_PORT || config.mcpPort;
1364
+ mcpHttpServer = createGlobalMcpHttpServer({
1365
+ projectRoot,
1366
+ port: normalizeMcpPort(configuredPort),
1367
+ log,
1368
+ });
1369
+ mcpHttpServer.start().catch((err) => {
1370
+ logSync(`MCP HTTP startup failed: ${formatFatalReason(err)}`);
1371
+ setImmediate(() => {
1372
+ throw err;
1373
+ });
1374
+ });
1375
+ }
1354
1376
 
1355
1377
  handleIpcRequest = async (req, socket) => {
1356
1378
  if (!req || typeof req !== "object") return;
1379
+ if (await runtimeControlPlane.handleRequest(req, socket)) return;
1380
+ if (req.type === IPC_REQUEST_TYPES.MCP_STATUS || req.type === IPC_REQUEST_TYPES.MCP_RESTART) {
1381
+ if (!isGlobalControllerProjectRoot(projectRoot)) {
1382
+ socket.write(`${JSON.stringify({
1383
+ type: IPC_RESPONSE_TYPES.ERROR,
1384
+ error: "MCP control is owned by the global controller daemon",
1385
+ })}\n`);
1386
+ return;
1387
+ }
1388
+ try {
1389
+ if (req.type === IPC_REQUEST_TYPES.MCP_RESTART) {
1390
+ if (!mcpHttpServer) {
1391
+ throw new Error("MCP HTTP listener is disabled");
1392
+ }
1393
+ await mcpHttpServer.stop();
1394
+ await mcpHttpServer.start();
1395
+ }
1396
+ socket.write(`${JSON.stringify({
1397
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1398
+ data: {
1399
+ ok: true,
1400
+ operation: req.type === IPC_REQUEST_TYPES.MCP_RESTART ? "restart" : "status",
1401
+ mcp: mcpHttpServer
1402
+ ? mcpHttpServer.getStatus()
1403
+ : { running: false, disabled: true },
1404
+ },
1405
+ })}\n`);
1406
+ } catch (err) {
1407
+ socket.write(`${JSON.stringify({
1408
+ type: IPC_RESPONSE_TYPES.ERROR,
1409
+ error: err.message || String(err),
1410
+ code: err.code || "mcp_control_error",
1411
+ })}\n`);
1412
+ }
1413
+ return;
1414
+ }
1357
1415
  if (req.type === IPC_REQUEST_TYPES.STATUS) {
1358
1416
  cleanupInactiveSubscribers();
1359
1417
  const status = buildRuntimeStatus();
@@ -2595,6 +2653,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2595
2653
  }
2596
2654
  daemonGroupOrchestrator = null;
2597
2655
 
2656
+ runtimeControlPlane.stop();
2657
+ if (mcpHttpServer) {
2658
+ void mcpHttpServer.stop().catch((err) => {
2659
+ writeLog(`MCP HTTP shutdown failed: ${formatFatalReason(err)}`);
2660
+ });
2661
+ }
2662
+
2598
2663
  // 清理所有子进程
2599
2664
  processManager.cleanup();
2600
2665