u-foo 3.0.22 → 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.
- package/OPTIONAL_SKILLS/ufoo-bus-poll/SKILL.md +6 -5
- package/README.md +7 -5
- package/dist/tui/darwin-arm64/ufoo-tui +0 -0
- package/dist/tui/darwin-x64/ufoo-tui +0 -0
- package/dist/tui/linux-arm64/ufoo-tui +0 -0
- package/dist/tui/linux-x64/ufoo-tui +0 -0
- package/package.json +1 -1
- package/src/agents/launch/launcher.js +81 -5
- package/src/agents/launch/readyDetector.js +3 -2
- package/src/runtime/contracts/mcpContract.js +8 -0
- package/src/runtime/daemon/controlPlaneService.js +15 -8
- package/src/runtime/daemon/deliveryScheduler.js +166 -6
- package/src/runtime/daemon/mcpConfigure.js +28 -3
- package/src/runtime/daemon/mcpServer.js +14 -7
- package/src/runtime/daemon/projectRuntimeGateway.js +17 -6
|
@@ -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
|
|
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`: `
|
|
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.
|
|
74
|
-
|
|
75
|
-
`after_seq
|
|
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:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -18,7 +18,10 @@ const {
|
|
|
18
18
|
routeDaemonRequest,
|
|
19
19
|
} = require("../../runtime/daemon/endpoint");
|
|
20
20
|
const { createTerminalAdapterRouter } = require("../../runtime/terminal/adapterRouter");
|
|
21
|
-
const {
|
|
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 (
|
|
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.
|
|
84
|
-
|
|
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 =
|
|
26
|
-
const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS =
|
|
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 <
|
|
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 =
|
|
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 {
|
|
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
|
|
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.
|
|
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")
|
|
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)
|
|
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 = ${
|
|
40
|
-
|
|
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) =>
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
647
|
-
//
|
|
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(
|
|
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
|
-
:
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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,
|