u-foo 3.0.23 → 3.0.24

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.
@@ -22,7 +22,7 @@ one active receive wait per subscriber.
22
22
  Keep idle checks inside a pending tool call or host background task. Do not
23
23
  implement a model-driven timer, repeated chat turn, or fixed
24
24
  `AGENT_LOOP_TICK_*`. Waiting must not invoke the LLM until a message arrives or
25
- the host's maximum pending-call window ends.
25
+ the caller cancels the wait.
26
26
 
27
27
  ## Attach the subscriber
28
28
 
@@ -63,16 +63,17 @@ prints stdout. Keep the wait inside one foreground MCP tool call instead:
63
63
  - `subscriber`: the caller-owned MCP subscriber
64
64
  - `agent_handle`: the opaque handle returned with that registration
65
65
  - `after_seq`: `0` for the first wait, then the last returned `last_seq`
66
- - `timeout_seconds`: `600`
66
+ - `timeout_seconds`: `0` (wait until a message arrives or the call is cancelled)
67
67
  2. Leave that tool call pending. Do not background it and do not start
68
68
  `ufoo bus poll --follow`; while the tool is pending, internal queue checks do
69
69
  not invoke the model or consume model tokens.
70
70
  3. If it returns `status: "message"`, handle every returned message, then call
71
71
  MCP `ack_bus` with `through_seq: <last_seq>`. This preserves messages that
72
72
  arrived after the returned batch. Include the same `agent_handle`.
73
- 4. If it returns `status: "timeout"`, no message was received. If monitoring is
74
- still required, immediately call `wait_for_message` again with the same
75
- `after_seq`. The timeout return is the only periodic model wake.
73
+ 4. The default wait has no periodic timeout result and therefore no periodic
74
+ model wake. If it ends with a cancellation or transport error, do not advance
75
+ `after_seq`; re-arm only when monitoring is still required and the receive
76
+ lease is healthy.
76
77
  5. After handling a message batch and completing any active work, re-arm one
77
78
  wait with the returned `last_seq`. Advance the cursor only after `ack_bus`
78
79
  succeeds; if acknowledgement fails, resolve that failure before re-arming.
package/README.md CHANGED
@@ -266,11 +266,13 @@ capability: do not send it to peers or print it in reports.
266
266
 
267
267
  - **Codex App:** call MCP `wait_for_message` in the foreground with
268
268
  the registered subscriber and handle, `after_seq: 0`, and
269
- `timeout_seconds: 600`. The tool call stays pending inside ufoo; a message
270
- returns immediately and wakes the task without shell stdout. On timeout,
271
- re-arm with the same cursor. After handling a message response, call MCP
272
- `ack_bus` with the same handle and its `last_seq` as `through_seq`, then
273
- re-arm with that `last_seq` when the Agent is idle again.
269
+ `timeout_seconds: 0`. The tool call stays pending inside the dedicated
270
+ `ufoo_wait` MCP connection until a message arrives or the caller cancels it;
271
+ idle time produces no periodic model wake or token consumption. A message
272
+ returns immediately and wakes the task without shell stdout. After handling
273
+ a message response, call MCP `ack_bus` with the same handle and its
274
+ `last_seq` as `through_seq`, then re-arm with that `last_seq` when the Agent
275
+ is idle again.
274
276
  - **Cursor:** bind the MCP subscriber and run
275
277
  `export UFOO_SUBSCRIBER_ID="<subscriber-id>"; exec ufoo bus poll
276
278
  "$UFOO_SUBSCRIBER_ID" --follow --interval 30` through the monitored
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.23",
3
+ "version": "3.0.24",
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",
@@ -18,7 +18,10 @@ const {
18
18
  routeDaemonRequest,
19
19
  } = require("../../runtime/daemon/endpoint");
20
20
  const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
21
- const { probeHostCapabilities } = require("../../runtime/terminal/adapters/hostAdapter");
21
+ const {
22
+ probeHostCapabilities,
23
+ requestSnapshot,
24
+ } = require("../../runtime/terminal/adapters/hostAdapter");
22
25
  const PtyWrapper = require("./ptyWrapper");
23
26
  const ReadyDetector = require("./readyDetector");
24
27
  const {
@@ -112,6 +115,12 @@ function normalizeTty(ttyPath) {
112
115
  return trimmed;
113
116
  }
114
117
 
118
+ function stripTerminalControl(text = "") {
119
+ return String(text)
120
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
121
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
122
+ }
123
+
115
124
  function getEnvTtyOverride() {
116
125
  const override = normalizeTty(process.env.UFOO_TTY_OVERRIDE || "");
117
126
  return override;
@@ -572,7 +581,9 @@ class AgentLauncher {
572
581
  * 直接spawn启动(回退逻辑)
573
582
  * @private
574
583
  */
575
- _spawnDirect(args, subscriberId) {
584
+ _spawnDirect(args, subscriberId, options = {}) {
585
+ const notifier = options.notifier || null;
586
+ const launchMode = resolveLaunchMode();
576
587
  const child = spawn(this.command, args, {
577
588
  cwd: this.cwd,
578
589
  stdio: "inherit",
@@ -582,17 +593,82 @@ class AgentLauncher {
582
593
  },
583
594
  });
584
595
 
585
- if (resolveLaunchMode() === "host" && child.pid) {
596
+ if (launchMode === "host" && child.pid) {
586
597
  const daemonEndpoint = resolveDaemonEndpoint(this.cwd);
587
598
  notifyDaemonAgentReady(daemonEndpoint, subscriberId, child.pid).catch(() => {});
588
599
  }
589
600
 
601
+ // Host direct-spawn inherits stdio, so readiness cannot be observed from
602
+ // the child stream. Ask the host for its terminal snapshot and feed that
603
+ // through the same prompt detector as the PTY path. Never manufacture
604
+ // readiness from elapsed time; the daemon queue-age fallback is the safety
605
+ // net when snapshots are unavailable.
606
+ const hostInjectSock = process.env.UFOO_HOST_INJECT_SOCK
607
+ || process.env.HORIZON_INJECT_SOCK
608
+ || "";
609
+ const snapshotFn = typeof options.requestSnapshot === "function"
610
+ ? options.requestSnapshot
611
+ : requestSnapshot;
612
+ const readyPollIntervalMs = Number.isFinite(options.readyPollIntervalMs)
613
+ ? Math.max(1, options.readyPollIntervalMs)
614
+ : 1000;
615
+ const readyMonitorMaxMs = Number.isFinite(options.readyMonitorMaxMs)
616
+ ? Math.max(1, options.readyMonitorMaxMs)
617
+ : 5 * 60 * 1000;
618
+ let readyPollTimer = null;
619
+ let readyMonitorStopped = false;
620
+
621
+ const stopReadyMonitor = () => {
622
+ readyMonitorStopped = true;
623
+ if (readyPollTimer) {
624
+ clearTimeout(readyPollTimer);
625
+ readyPollTimer = null;
626
+ }
627
+ };
628
+
629
+ if (notifier && launchMode === "host" && hostInjectSock) {
630
+ const detector = new ReadyDetector(this.agentType);
631
+ const monitorStartedAt = Date.now();
632
+ detector.onReady(() => {
633
+ stopReadyMonitor();
634
+ notifier.markLauncherReady();
635
+ notifier.updateActivityState("ready");
636
+ });
637
+
638
+ const pollReady = async () => {
639
+ if (readyMonitorStopped) return;
640
+ try {
641
+ const snapshot = await snapshotFn(hostInjectSock);
642
+ const lines = snapshot && Array.isArray(snapshot.lines) ? snapshot.lines : [];
643
+ if (lines.length > 0) {
644
+ // A snapshot is a complete screen, not a stream continuation.
645
+ // Preserve a line boundary between polls so prompt anchors cannot
646
+ // be glued to the previous snapshot's final character.
647
+ detector.processOutput(`\n${stripTerminalControl(lines.join("\n"))}\n`);
648
+ }
649
+ } catch {
650
+ // A transient host snapshot failure must not manufacture readiness.
651
+ }
652
+ if (!readyMonitorStopped && Date.now() - monitorStartedAt < readyMonitorMaxMs) {
653
+ readyPollTimer = setTimeout(pollReady, readyPollIntervalMs);
654
+ if (readyPollTimer && typeof readyPollTimer.unref === "function") {
655
+ readyPollTimer.unref();
656
+ }
657
+ } else {
658
+ stopReadyMonitor();
659
+ }
660
+ };
661
+ pollReady();
662
+ }
663
+
590
664
  child.on("error", (err) => {
665
+ stopReadyMonitor();
591
666
  console.error(`[${this.command}] Failed to start:`, err.message);
592
667
  process.exit(1);
593
668
  });
594
669
 
595
670
  child.on("exit", async (code, signal) => {
671
+ stopReadyMonitor();
596
672
  // 清理 bus 状态
597
673
  try {
598
674
  const bus = new EventBus(this.cwd);
@@ -1010,11 +1086,11 @@ class AgentLauncher {
1010
1086
  process.on("SIGINT", () => handleTermSignal("SIGINT"));
1011
1087
  } catch (err) {
1012
1088
  console.error(`[PTY] Failed to start, falling back to spawn:`, err.message);
1013
- this._spawnDirect(args, subscriberId);
1089
+ this._spawnDirect(args, subscriberId, { notifier });
1014
1090
  }
1015
1091
  } else {
1016
1092
  // 非PTY环境:tmux、internal、管道、显式禁用等
1017
- this._spawnDirect(args, subscriberId);
1093
+ this._spawnDirect(args, subscriberId, { notifier });
1018
1094
  }
1019
1095
  } catch (err) {
1020
1096
  console.error(`[${this.command}] Error:`, err.message);
@@ -80,8 +80,9 @@ class ReadyDetector {
80
80
  if (text.includes("codex>")) {
81
81
  return true;
82
82
  }
83
- // 2. 行首或行尾的单独 ">" prompt(避免匹配JSON/HTML中的>)
84
- if (/(?:^|\n)>\s*$/.test(text)) {
83
+ // 2. Codex TUI uses the single-chevron "" prompt. Keep both markers
84
+ // anchored to a line start to avoid matching prose, JSON, or HTML.
85
+ if (/(?:^|\n)[ \t]*(?:>[ \t]*$|›(?:[ \t]+[^\n]*)?[ \t]*$)/m.test(text)) {
85
86
  return true;
86
87
  }
87
88
  return false;
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
 
3
3
  const MCP_PROTOCOL_VERSION = "2024-11-05";
4
+ // Zero keeps the receive tool pending until a message arrives or the caller
5
+ // cancels. This preserves the model-token boundary: idle time never produces a
6
+ // timeout result that would wake the Agent. Positive values retain the bounded
7
+ // wait mode for diagnostics and non-Codex MCP clients.
8
+ const MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = 0;
9
+ const MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = 270;
4
10
 
5
11
  const MCP_ERROR_CODES = Object.freeze({
6
12
  PARSE_ERROR: -32700,
@@ -33,6 +39,8 @@ function createJsonRpcError(id, code, message, data = undefined) {
33
39
 
34
40
  module.exports = {
35
41
  MCP_PROTOCOL_VERSION,
42
+ MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
43
+ MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
36
44
  MCP_ERROR_CODES,
37
45
  createJsonRpcResult,
38
46
  createJsonRpcError,
@@ -17,13 +17,17 @@ const {
17
17
  routeDaemonRequest,
18
18
  } = require("./endpoint");
19
19
  const { IPC_REQUEST_TYPES } = require("../contracts/eventContract");
20
+ const {
21
+ MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
22
+ MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
23
+ } = require("../contracts/mcpContract");
20
24
  const {
21
25
  applyProjectNicknamePrefix,
22
26
  checkAndCleanupNickname,
23
27
  } = require("./nicknameScope");
24
28
 
25
- const WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = 600;
26
- const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = 600;
29
+ const WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS;
30
+ const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS;
27
31
  const WAIT_FOR_MESSAGE_POLL_INTERVAL_MS = 1000;
28
32
  const WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS = 15000;
29
33
  const MCP_AGENT_LEASE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -447,11 +451,12 @@ function normalizeWaitForMessageArgs(args = {}) {
447
451
  const timeoutSeconds = Number(rawTimeout);
448
452
  if (
449
453
  !Number.isFinite(timeoutSeconds)
450
- || timeoutSeconds < 1
454
+ || timeoutSeconds < 0
455
+ || (timeoutSeconds > 0 && timeoutSeconds < 1)
451
456
  || timeoutSeconds > WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS
452
457
  ) {
453
458
  const err = new Error(
454
- `wait_for_message timeout_seconds must be between 1 and ${WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS}`
459
+ `wait_for_message timeout_seconds must be 0 (until message) or between 1 and ${WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS}`
455
460
  );
456
461
  err.code = "invalid_timeout";
457
462
  throw err;
@@ -503,7 +508,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
503
508
  1000,
504
509
  Number(options.heartbeatIntervalMs) || WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS
505
510
  );
506
- const timeoutMs = timeoutSeconds * 1000;
511
+ const timeoutMs = timeoutSeconds > 0 ? timeoutSeconds * 1000 : null;
507
512
 
508
513
  const bus = ensureBusLoaded(projectRoot);
509
514
  touchWaitingSubscriber(bus, subscriber, args);
@@ -516,7 +521,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
516
521
  process.once("exit", cleanupLease);
517
522
 
518
523
  const startedAt = now();
519
- const deadline = startedAt + timeoutMs;
524
+ const deadline = timeoutMs == null ? null : startedAt + timeoutMs;
520
525
  let nextHeartbeatAt = startedAt + heartbeatIntervalMs;
521
526
 
522
527
  try {
@@ -548,7 +553,7 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
548
553
  }
549
554
 
550
555
  const current = now();
551
- if (current >= deadline) {
556
+ if (deadline != null && current >= deadline) {
552
557
  touchWaitingSubscriber(bus, subscriber, args);
553
558
  return {
554
559
  ok: true,
@@ -570,7 +575,9 @@ async function waitForMessage(projectRoot, args = {}, options = {}) {
570
575
  nextHeartbeatAt = current + heartbeatIntervalMs;
571
576
  }
572
577
 
573
- const delayMs = Math.max(1, Math.min(pollIntervalMs, deadline - current));
578
+ const delayMs = deadline == null
579
+ ? pollIntervalMs
580
+ : Math.max(1, Math.min(pollIntervalMs, deadline - current));
574
581
  // eslint-disable-next-line no-await-in-loop
575
582
  await sleep(delayMs, signal);
576
583
  }
@@ -23,6 +23,12 @@ const DEFAULT_WARN_INTERVAL_MS = 60 * 1000;
23
23
  // After this long stuck in waiting_input/blocked, deliver anyway (better a
24
24
  // message in a stuck terminal than a lost one). Env UFOO_DELIVERY_BLOCKED_GRACE_MS overrides.
25
25
  const DEFAULT_BLOCKED_GRACE_MS = 15 * 60 * 1000;
26
+ // A queued wrapper message must never be blocked indefinitely by stale
27
+ // activity state. Once the oldest injectable message has waited this long,
28
+ // bypass only the activity gate and attempt direct injection.
29
+ const DEFAULT_FORCE_DELIVERY_AFTER_MS = 5 * 60 * 1000;
30
+ const DEFAULT_FORCE_RETRY_BASE_MS = 5 * 1000;
31
+ const DEFAULT_FORCE_RETRY_MAX_MS = 60 * 1000;
26
32
  // Warn when an inject lock is held longer than this (usually means a stuck inject).
27
33
  const DEFAULT_LOCKED_WARN_AFTER_MS = 60 * 1000;
28
34
 
@@ -75,6 +81,15 @@ class DeliveryScheduler {
75
81
  options.blockedGraceMs,
76
82
  positiveMs(Number(process.env.UFOO_DELIVERY_BLOCKED_GRACE_MS), DEFAULT_BLOCKED_GRACE_MS),
77
83
  );
84
+ this.forceDeliveryAfterMs = positiveMs(
85
+ options.forceDeliveryAfterMs,
86
+ positiveMs(
87
+ Number(process.env.UFOO_DELIVERY_FORCE_AFTER_MS),
88
+ DEFAULT_FORCE_DELIVERY_AFTER_MS,
89
+ ),
90
+ );
91
+ this.forceRetryBaseMs = positiveMs(options.forceRetryBaseMs, DEFAULT_FORCE_RETRY_BASE_MS);
92
+ this.forceRetryMaxMs = positiveMs(options.forceRetryMaxMs, DEFAULT_FORCE_RETRY_MAX_MS);
78
93
  this.lockedWarnAfterMs = positiveMs(options.lockedWarnAfterMs, DEFAULT_LOCKED_WARN_AFTER_MS);
79
94
  this.intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0
80
95
  ? options.intervalMs
@@ -84,6 +99,9 @@ class DeliveryScheduler {
84
99
  this.deferrals = new Map();
85
100
  this.blockedStateSeen = new Map();
86
101
  this.graceWarned = new Map();
102
+ this.pendingSeen = new Map();
103
+ this.forceWarned = new Set();
104
+ this.forceRetries = new Map();
87
105
  this.timer = null;
88
106
  this.running = false;
89
107
  }
@@ -123,6 +141,13 @@ class DeliveryScheduler {
123
141
  this.log(`WARN delivery grace override subscriber=${subscriber} activity_state=${activityState} pending=${this.pendingCount(subscriber)} grace_ms=${this.blockedGraceMs} - agent stuck, delivering anyway`);
124
142
  }
125
143
 
144
+ noteForceOverride(subscriber, activityState, pending) {
145
+ const key = `${subscriber}:${pending.key}`;
146
+ if (this.forceWarned.has(key)) return;
147
+ this.forceWarned.add(key);
148
+ this.log(`WARN delivery queue timeout override subscriber=${subscriber} activity_state=${activityState || "unknown"} seq=${pending.seq || 0} waited_ms=${pending.waitedMs} force_after_ms=${this.forceDeliveryAfterMs} - injecting despite stale activity gate`);
149
+ }
150
+
126
151
  noteLocked(subscriber, lock) {
127
152
  const now = this.now();
128
153
  const lockedMs = now - lock.sinceMs;
@@ -146,6 +171,9 @@ class DeliveryScheduler {
146
171
  if (!meta || meta.status === "inactive") {
147
172
  return { ok: false, reason: "missing_or_inactive" };
148
173
  }
174
+ if (meta.mcp_bridge === true) {
175
+ return { ok: false, reason: "external_receive" };
176
+ }
149
177
  const launchMode = String(meta.launch_mode || "").trim();
150
178
  const adapter = this.adapterRouter.getAdapter({ launchMode, agentId: subscriber, meta });
151
179
  if (!adapter.capabilities.supportsNotifierInjector) {
@@ -161,7 +189,11 @@ class DeliveryScheduler {
161
189
  } else {
162
190
  this.blockedStateSeen.delete(subscriber);
163
191
  }
164
- return { ok: false, reason: activityState || "unknown_activity_state" };
192
+ return {
193
+ ok: false,
194
+ reason: activityState || "unknown_activity_state",
195
+ activityBlocked: true,
196
+ };
165
197
  }
166
198
  this.blockedStateSeen.delete(subscriber);
167
199
  return { ok: true, reason: "deliverable" };
@@ -179,6 +211,108 @@ class DeliveryScheduler {
179
211
  return now;
180
212
  }
181
213
 
214
+ pendingEventKey(event = {}) {
215
+ const seq = Number(event.seq);
216
+ if (Number.isFinite(seq) && seq > 0) return `seq:${seq}`;
217
+ return `event:${JSON.stringify(event)}`;
218
+ }
219
+
220
+ clearPendingTracking(subscriber, event = null) {
221
+ const tracked = this.pendingSeen.get(subscriber);
222
+ const key = event ? this.pendingEventKey(event) : tracked?.key;
223
+ if (key) {
224
+ const trackingKey = `${subscriber}:${key}`;
225
+ this.forceWarned.delete(trackingKey);
226
+ this.forceRetries.delete(trackingKey);
227
+ }
228
+ this.pendingSeen.delete(subscriber);
229
+ }
230
+
231
+ clearTrackedKey(subscriber, key) {
232
+ if (!key) return;
233
+ const trackingKey = `${subscriber}:${key}`;
234
+ this.forceWarned.delete(trackingKey);
235
+ this.forceRetries.delete(trackingKey);
236
+ }
237
+
238
+ noteForceFailure(subscriber, pending) {
239
+ if (!pending || !pending.key) return;
240
+ const key = `${subscriber}:${pending.key}`;
241
+ const previous = this.forceRetries.get(key);
242
+ const attempts = previous ? previous.attempts + 1 : 1;
243
+ const delayMs = Math.min(
244
+ this.forceRetryMaxMs,
245
+ this.forceRetryBaseMs * (2 ** Math.max(0, attempts - 1)),
246
+ );
247
+ this.forceRetries.set(key, {
248
+ attempts,
249
+ nextAtMs: this.now() + delayMs,
250
+ });
251
+ this.log(`WARN forced delivery inject failed subscriber=${subscriber} seq=${pending.seq || 0} retry_in_ms=${delayMs} attempt=${attempts}`);
252
+ }
253
+
254
+ resolvePendingWait(subscriber, queue, eventOverride = null) {
255
+ let event = eventOverride;
256
+ if (!event) {
257
+ try {
258
+ const pending = queue && typeof queue.readPending === "function"
259
+ ? queue.readPending()
260
+ : [];
261
+ event = pending.find((item) => {
262
+ const envelope = normalizeQueueEnvelope(item || {});
263
+ return envelope.event === "message"
264
+ && envelope.delivery
265
+ && envelope.delivery.mode === "inject";
266
+ }) || null;
267
+ } catch {
268
+ event = null;
269
+ }
270
+ }
271
+ if (!event) {
272
+ this.clearPendingTracking(subscriber);
273
+ return null;
274
+ }
275
+
276
+ const key = this.pendingEventKey(event);
277
+ const timestampMs = Date.parse(String(event.timestamp || ""));
278
+ const existing = this.pendingSeen.get(subscriber);
279
+ if (existing && existing.key !== key) {
280
+ this.clearTrackedKey(subscriber, existing.key);
281
+ }
282
+ const sinceMs = Number.isFinite(timestampMs) && timestampMs <= this.now()
283
+ ? timestampMs
284
+ : (existing && existing.key === key ? existing.sinceMs : this.now());
285
+ this.pendingSeen.set(subscriber, { key, sinceMs });
286
+ return {
287
+ key,
288
+ seq: Number.isFinite(Number(event.seq)) ? Number(event.seq) : 0,
289
+ sinceMs,
290
+ waitedMs: Math.max(0, this.now() - sinceMs),
291
+ };
292
+ }
293
+
294
+ resolveGate(subscriber, queue, eventOverride = null) {
295
+ const gate = this.shouldDeliver(subscriber);
296
+ if (gate.ok || !gate.activityBlocked) return gate;
297
+ const pending = this.resolvePendingWait(subscriber, queue, eventOverride);
298
+ if (!pending || pending.waitedMs < this.forceDeliveryAfterMs) return gate;
299
+ const retry = this.forceRetries.get(`${subscriber}:${pending.key}`);
300
+ if (retry && this.now() < retry.nextAtMs) {
301
+ return {
302
+ ok: false,
303
+ reason: "force_retry_backoff",
304
+ retryAtMs: retry.nextAtMs,
305
+ };
306
+ }
307
+ this.noteForceOverride(subscriber, gate.reason, pending);
308
+ return {
309
+ ok: true,
310
+ reason: "queue_timeout_override",
311
+ forceOverride: gate.reason,
312
+ pending,
313
+ };
314
+ }
315
+
182
316
  async deliverSubscriber(subscriber) {
183
317
  if (!subscriber) return { ok: false, delivered: 0, reason: "missing_subscriber" };
184
318
  if (this.locks.has(subscriber)) {
@@ -188,7 +322,8 @@ class DeliveryScheduler {
188
322
 
189
323
  this.locks.set(subscriber, { sinceMs: this.now(), lastWarnAtMs: 0 });
190
324
  try {
191
- const gate = this.shouldDeliver(subscriber);
325
+ const queue = this.queueFactory(subscriber);
326
+ const gate = this.resolveGate(subscriber, queue);
192
327
  if (!gate.ok) {
193
328
  this.noteDeferral(subscriber, gate.reason);
194
329
  return { ok: true, delivered: 0, deferred: true, reason: gate.reason };
@@ -200,9 +335,9 @@ class DeliveryScheduler {
200
335
  }
201
336
  this.clearDeferral(subscriber);
202
337
 
203
- const queue = this.queueFactory(subscriber);
204
338
  const claim = queue.claimNext();
205
339
  if (!claim) {
340
+ this.clearPendingTracking(subscriber);
206
341
  return { ok: true, delivered: 0, reason: "empty" };
207
342
  }
208
343
 
@@ -219,13 +354,17 @@ class DeliveryScheduler {
219
354
  }
220
355
 
221
356
  if (delivery.gate === "idle") {
222
- const secondGate = this.shouldDeliver(subscriber);
357
+ const secondGate = this.resolveGate(subscriber, queue, evt);
223
358
  if (!secondGate.ok) {
224
359
  this.noteDeferral(subscriber, secondGate.reason);
225
360
  queue.restoreClaim(claim);
226
361
  return { ok: true, delivered: 0, deferred: true, reason: secondGate.reason };
227
362
  }
228
363
  if (secondGate.graceOverride) this.noteGraceOverride(subscriber, secondGate.graceOverride);
364
+ if (secondGate.forceOverride && !gate.forceOverride) {
365
+ gate.forceOverride = secondGate.forceOverride;
366
+ gate.pending = secondGate.pending;
367
+ }
229
368
  }
230
369
 
231
370
  const { agents } = this.getAgentMeta(subscriber);
@@ -233,6 +372,7 @@ class DeliveryScheduler {
233
372
  try {
234
373
  await this.injector.inject(subscriber, injectionText);
235
374
  queue.completeClaim(claim);
375
+ this.clearPendingTracking(subscriber, evt);
236
376
  // Close the idle gate immediately. PTY ActivityDetector will refresh
237
377
  // working from output, then quiet-window back to idle; without this
238
378
  // stamp a second pending message can slip through on the next tick
@@ -250,6 +390,7 @@ class DeliveryScheduler {
250
390
  return { ok: true, delivered: 1, event: envelope };
251
391
  } catch (err) {
252
392
  queue.restoreClaim(claim);
393
+ if (gate.forceOverride) this.noteForceFailure(subscriber, gate.pending);
253
394
  await this.emitDelivery({
254
395
  subscriber,
255
396
  event: envelope,
@@ -282,10 +423,29 @@ class DeliveryScheduler {
282
423
  const data = this.readAgents() || { agents: {} };
283
424
  const agents = data.agents && typeof data.agents === "object" ? data.agents : {};
284
425
  const subscribers = [];
426
+ const pendingSubscribers = new Set();
285
427
  for (const [subscriber, meta] of Object.entries(agents)) {
286
- if (!meta || meta.status === "inactive") continue;
428
+ if (!meta || meta.status === "inactive") {
429
+ this.clearPendingTracking(subscriber);
430
+ continue;
431
+ }
432
+ // MCP-registered Agents own their receive path through wait_for_message
433
+ // or an explicitly armed CLI poll. They must never enter direct
434
+ // terminal-injection scheduling.
435
+ if (meta.mcp_bridge === true) {
436
+ this.clearPendingTracking(subscriber);
437
+ continue;
438
+ }
287
439
  const queue = this.queueFactory(subscriber);
288
- if (queue.readPending().length > 0) subscribers.push(subscriber);
440
+ if (queue.readPending().length > 0) {
441
+ subscribers.push(subscriber);
442
+ pendingSubscribers.add(subscriber);
443
+ } else {
444
+ this.clearPendingTracking(subscriber);
445
+ }
446
+ }
447
+ for (const subscriber of this.pendingSeen.keys()) {
448
+ if (!pendingSubscribers.has(subscriber)) this.clearPendingTracking(subscriber);
289
449
  }
290
450
  return subscribers;
291
451
  }
@@ -13,6 +13,12 @@ const {
13
13
 
14
14
  const MANAGED_BLOCK_START = "# >>> ufoo MCP (managed)";
15
15
  const MANAGED_BLOCK_END = "# <<< ufoo MCP (managed)";
16
+ const CODEX_STANDARD_TOOL_TIMEOUT_SECONDS = 610;
17
+ // Codex currently requires a finite server-level MCP timeout. One year is
18
+ // effectively session-lifetime while avoiding periodic model wakeups. Keep the
19
+ // long timeout isolated from normal ufoo tools so a broken short call cannot
20
+ // hang for the same duration.
21
+ const CODEX_WAIT_TOOL_TIMEOUT_SECONDS = 365 * 24 * 60 * 60;
16
22
  const RETIRED_UFOO_SKILL_NAMES = new Set([
17
23
  "ubus",
18
24
  "uctx",
@@ -32,13 +38,25 @@ function codexConfigPath(options = {}) {
32
38
  }
33
39
 
34
40
  function buildCodexManagedBlock(connection) {
41
+ const authorization = tomlString(`Bearer ${connection.token}`);
35
42
  return [
36
43
  MANAGED_BLOCK_START,
37
44
  "[mcp_servers.ufoo]",
38
45
  `url = ${tomlString(connection.endpoint)}`,
39
- `http_headers = { Authorization = ${tomlString(`Bearer ${connection.token}`)} }`,
40
- "tool_timeout_sec = 610",
46
+ `http_headers = { Authorization = ${authorization} }`,
47
+ `tool_timeout_sec = ${CODEX_STANDARD_TOOL_TIMEOUT_SECONDS}`,
48
+ 'disabled_tools = ["wait_for_message"]',
41
49
  "enabled = true",
50
+ "",
51
+ "[mcp_servers.ufoo_wait]",
52
+ `url = ${tomlString(connection.endpoint)}`,
53
+ `http_headers = { Authorization = ${authorization} }`,
54
+ `tool_timeout_sec = ${CODEX_WAIT_TOOL_TIMEOUT_SECONDS}`,
55
+ 'enabled_tools = ["wait_for_message"]',
56
+ "enabled = true",
57
+ "",
58
+ "[mcp_servers.ufoo_wait.tools.wait_for_message]",
59
+ 'approval_mode = "approve"',
42
60
  MANAGED_BLOCK_END,
43
61
  ].join("\n");
44
62
  }
@@ -80,7 +98,12 @@ function isUfooStdioEnvSection(header = "") {
80
98
  function removeLegacyUfooTransportSections(text = "") {
81
99
  const sections = findTomlSections(text);
82
100
  const ranges = sections
83
- .filter((section) => isUfooMainSection(section.header) || isUfooStdioEnvSection(section.header))
101
+ .filter((section) => (
102
+ isUfooMainSection(section.header)
103
+ || isUfooStdioEnvSection(section.header)
104
+ || /^(?:mcp_servers\.ufoo_wait|mcp_servers\."ufoo_wait")(?:\.|$)/
105
+ .test(String(section.header || ""))
106
+ ))
84
107
  .map((section) => [section.start, section.end])
85
108
  .sort((a, b) => b[0] - a[0]);
86
109
  let next = text;
@@ -207,6 +230,8 @@ function runMcpConfigureCli(host, options = {}) {
207
230
  }
208
231
 
209
232
  module.exports = {
233
+ CODEX_STANDARD_TOOL_TIMEOUT_SECONDS,
234
+ CODEX_WAIT_TOOL_TIMEOUT_SECONDS,
210
235
  MANAGED_BLOCK_END,
211
236
  MANAGED_BLOCK_START,
212
237
  buildCodexManagedBlock,
@@ -25,6 +25,8 @@ const {
25
25
  const { CALLER_TIERS } = require("../../tools/types");
26
26
  const {
27
27
  MCP_PROTOCOL_VERSION,
28
+ MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
29
+ MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
28
30
  MCP_ERROR_CODES,
29
31
  createJsonRpcResult,
30
32
  createJsonRpcError,
@@ -153,11 +155,16 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
153
155
  description: "Return only messages with a sequence greater than this cursor.",
154
156
  },
155
157
  timeout_seconds: {
156
- type: "number",
157
- minimum: 1,
158
- maximum: 600,
159
- default: 600,
160
- description: "Keep the tool call pending for at most this many seconds.",
158
+ anyOf: [
159
+ { const: 0 },
160
+ {
161
+ type: "number",
162
+ minimum: 1,
163
+ maximum: MCP_WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
164
+ },
165
+ ],
166
+ default: MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
167
+ description: "Use 0 to wait until a message arrives or the call is cancelled. Positive values enable a bounded diagnostic wait.",
161
168
  },
162
169
  limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
163
170
  },
@@ -643,8 +650,8 @@ class UfooMcpServer {
643
650
  toolCallId: id,
644
651
  signal: abortController.signal,
645
652
  });
646
- // A long-lived wait must not hold the process-global console shim for
647
- // up to ten minutes while unrelated MCP calls continue concurrently.
653
+ // A long-lived wait must not hold the process-global console shim
654
+ // while unrelated MCP calls continue concurrently.
648
655
  result = name === "wait_for_message"
649
656
  ? await runTool()
650
657
  : await suppressConsoleToStderr(runTool);
@@ -7,6 +7,9 @@ const {
7
7
  IPC_REQUEST_TYPES,
8
8
  IPC_RESPONSE_TYPES,
9
9
  } = require("../contracts/eventContract");
10
+ const {
11
+ MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
12
+ } = require("../contracts/mcpContract");
10
13
  const {
11
14
  assertToolAllowedForCallerTier,
12
15
  } = require("../../tools/registry");
@@ -247,10 +250,16 @@ function connectProjectRuntimeSocket(sockPath, timeoutMs = 5000) {
247
250
 
248
251
  function resolveCallTimeoutMs(operation, args = {}, fallbackMs = 15000) {
249
252
  if (operation !== "wait_for_message") return fallbackMs;
250
- const timeoutSeconds = Number(args.timeout_seconds ?? args.timeoutSeconds ?? 600);
253
+ const timeoutSeconds = Number(
254
+ args.timeout_seconds
255
+ ?? args.timeoutSeconds
256
+ ?? MCP_WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS
257
+ );
258
+ if (timeoutSeconds === 0) return null;
251
259
  const waitMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0
252
260
  ? timeoutSeconds * 1000
253
- : 600000;
261
+ : 0;
262
+ if (waitMs === 0) return null;
254
263
  return waitMs + 5000;
255
264
  }
256
265
 
@@ -379,10 +388,12 @@ class SocketProjectRuntimeGateway {
379
388
  ));
380
389
  });
381
390
 
382
- timer = setTimeout(() => {
383
- onAbort();
384
- }, timeoutMs);
385
- if (typeof timer.unref === "function") timer.unref();
391
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
392
+ timer = setTimeout(() => {
393
+ onAbort();
394
+ }, timeoutMs);
395
+ if (typeof timer.unref === "function") timer.unref();
396
+ }
386
397
 
387
398
  socket.write(`${JSON.stringify({
388
399
  type: IPC_REQUEST_TYPES.CONTROL_PLANE_CALL,