u-foo 3.0.16 → 3.0.17
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 +118 -34
- package/OPTIONAL_SKILLS/ufoo-bus-poll/agents/openai.yaml +2 -2
- package/README.md +49 -12
- package/README.zh-CN.md +40 -11
- package/SKILLS/ufoo/SKILL.md +31 -9
- package/SKILLS/ufoo-bus/SKILL.md +58 -23
- package/SKILLS/ufoo-bus/agents/openai.yaml +2 -2
- package/package.json +1 -1
- package/src/agents/prompts/groupBootstrap.js +5 -0
- package/src/agents/prompts/native/ufoo.js +1 -0
- package/src/code/UCODE_PROMPT.md +1 -0
- package/src/coordination/bus/index.js +4 -14
- package/src/coordination/bus/poll.js +3 -42
- package/src/runtime/daemon/controlPlaneService.js +190 -0
- package/src/runtime/daemon/mcpServer.js +71 -5
- package/src/tools/handlers/ackBus.js +13 -1
- package/src/tools/schemaFixtures.js +6 -0
|
@@ -1,60 +1,144 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ufoo-bus-poll
|
|
3
3
|
description: >-
|
|
4
|
-
|
|
5
|
-
MCP-registered
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
Establish no-token idle bus monitoring and self-wake for an externally
|
|
5
|
+
hosted, MCP-registered Agent with no wrapper-provided UFOO_SUBSCRIBER_ID.
|
|
6
|
+
Select the host App's native wake mechanism: pending MCP wait_for_message for
|
|
7
|
+
Codex App or monitored background stdout for Cursor. Do not use for
|
|
8
|
+
wrapper-managed Agents.
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# ufoo bus poll
|
|
12
12
|
|
|
13
|
-
Attach one
|
|
14
|
-
|
|
13
|
+
Attach one subscriber to the host App's own wait primitive. Keep queue reads
|
|
14
|
+
non-mutating so the Agent acknowledges only after handling work.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
This is the standard receive channel for an external MCP Agent. It is not an
|
|
17
|
+
ad-hoc reply wait and is not selected by Agent type. The runtime permits only
|
|
18
|
+
one active receive wait per subscriber.
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
## Preserve the token boundary
|
|
21
|
+
|
|
22
|
+
Keep idle checks inside a pending tool call or host background task. Do not
|
|
23
|
+
implement a model-driven timer, repeated chat turn, or fixed
|
|
24
|
+
`AGENT_LOOP_TICK_*`. Waiting must not invoke the LLM until a message arrives or
|
|
25
|
+
the host's maximum pending-call window ends.
|
|
26
|
+
|
|
27
|
+
## Attach the subscriber
|
|
28
|
+
|
|
29
|
+
1. Inspect `UFOO_SUBSCRIBER_ID`. If it is nonempty, stop this workflow: the
|
|
30
|
+
ufoo wrapper already registered the Agent and direct injection is its receive
|
|
31
|
+
path. Do not call MCP `register_agent` or arm a poll.
|
|
32
|
+
2. When the variable is absent, reuse the exact subscriber already returned to
|
|
33
|
+
this session by MCP `register_agent`, or call `register_agent` once. Do not
|
|
34
|
+
call bare `ufoo bus join`, borrow another subscriber, or export the MCP
|
|
35
|
+
result as `UFOO_SUBSCRIBER_ID`.
|
|
36
|
+
3. Select the receive mechanism from the host App's actual wake capability,
|
|
37
|
+
never from Agent type:
|
|
38
|
+
|
|
39
|
+
- Codex App: use the pending MCP workflow below.
|
|
40
|
+
- Cursor with `notify_on_output`: use the monitored stdout workflow below.
|
|
41
|
+
- Another App: use its documented pending-tool or output-notification
|
|
42
|
+
primitive. If neither exists, automatic self-wake is unavailable; use an
|
|
43
|
+
explicit `$ufoo-bus` check or a user-approved scheduled fallback.
|
|
44
|
+
|
|
45
|
+
Agent type and subscriber prefix are not capability signals. An externally
|
|
46
|
+
hosted MCP subscriber named `codex:*`, `claude-code:*`, or any other type is
|
|
47
|
+
eligible because the absent environment variable—not its name—selected this
|
|
48
|
+
delivery mode.
|
|
49
|
+
|
|
50
|
+
## Use Codex App pending MCP wait
|
|
51
|
+
|
|
52
|
+
Codex App does not resume the current task when a background shell or PTY later
|
|
53
|
+
prints stdout. Keep the wait inside one foreground MCP tool call instead:
|
|
54
|
+
|
|
55
|
+
1. When active work is complete and the inbox should remain armed, call MCP
|
|
56
|
+
`wait_for_message` with:
|
|
57
|
+
|
|
58
|
+
- `project_root`: the registered project root
|
|
59
|
+
- `subscriber`: the caller-owned MCP subscriber
|
|
60
|
+
- `after_seq`: `0` for the first wait, then the last returned `last_seq`
|
|
61
|
+
- `timeout_seconds`: `600`
|
|
62
|
+
2. Leave that tool call pending. Do not background it and do not start
|
|
63
|
+
`ufoo bus poll --follow`; while the tool is pending, internal queue checks do
|
|
64
|
+
not invoke the model or consume model tokens.
|
|
65
|
+
3. If it returns `status: "message"`, handle every returned message, then call
|
|
66
|
+
MCP `ack_bus` with `through_seq: <last_seq>`. This preserves messages that
|
|
67
|
+
arrived after the returned batch.
|
|
68
|
+
4. If it returns `status: "timeout"`, no message was received. If monitoring is
|
|
69
|
+
still required, immediately call `wait_for_message` again with the same
|
|
70
|
+
`after_seq`. The timeout return is the only periodic model wake.
|
|
71
|
+
5. After handling a message batch and completing any active work, re-arm one
|
|
72
|
+
wait with the returned `last_seq`. Advance the cursor only after `ack_bus`
|
|
73
|
+
succeeds; if acknowledgement fails, resolve that failure before re-arming.
|
|
74
|
+
On cancellation or an explicit stop, leave it disarmed.
|
|
75
|
+
|
|
76
|
+
Never run two pending waits for the same subscriber.
|
|
77
|
+
|
|
78
|
+
## Configure Cursor wake-on-output
|
|
79
|
+
|
|
80
|
+
1. Check for an existing `ufoo bus poll ... --follow` process for the literal
|
|
81
|
+
MCP subscriber. Keep one healthy process; the CLI rejects a duplicate
|
|
82
|
+
receive lease.
|
|
83
|
+
2. Start this command through Cursor's monitored background shell:
|
|
22
84
|
|
|
23
85
|
```bash
|
|
24
|
-
ufoo bus poll "<subscriber-id>" --follow --interval
|
|
86
|
+
ufoo bus poll "<mcp-subscriber-id>" --follow --interval 30
|
|
25
87
|
```
|
|
26
88
|
|
|
27
|
-
|
|
28
|
-
Do not use `nohup`, shell `&`, or an OS-detached daemon
|
|
29
|
-
|
|
89
|
+
Use `30` seconds by default; accept `15`–`120` when the user requests a
|
|
90
|
+
different latency. Do not use `nohup`, shell `&`, or an OS-detached daemon.
|
|
91
|
+
3. Start the monitored task with
|
|
92
|
+
`block_until_ms: 0`.
|
|
93
|
+
4. Configure `notify_on_output` with:
|
|
94
|
+
|
|
95
|
+
- pattern: `\[ufoo\]`
|
|
96
|
+
- reason: `ufoo bus pending`
|
|
97
|
+
5. Inspect the terminal once to confirm the process started. Idle success is
|
|
98
|
+
a running task with no output; do not wait for a heartbeat.
|
|
99
|
+
6. If pending output appears during startup, handle it as the first stream
|
|
100
|
+
batch. Do not run a parallel foreground `ufoo bus check`.
|
|
101
|
+
|
|
102
|
+
Empty intervals emit nothing, so they neither wake Cursor nor consume model
|
|
103
|
+
tokens. Match the general `[ufoo]` protocol prefix rather than a specific
|
|
104
|
+
`<from:...>` shape so every message delivered by ufoo can wake the session.
|
|
105
|
+
Startup and lifecycle checks also emit nothing; process health comes from the
|
|
106
|
+
host task state and the single-subscriber lease.
|
|
107
|
+
|
|
108
|
+
This process wakes only its own Cursor session. A peer that must react while
|
|
109
|
+
unattended needs its own armed poll or another real wake channel.
|
|
30
110
|
|
|
31
|
-
|
|
32
|
-
the current pending batch, waits for that batch to be acknowledged, then emits
|
|
33
|
-
the next batch. It never acknowledges, claims, injects, or clears messages.
|
|
111
|
+
## Handle delivered batches
|
|
34
112
|
|
|
35
|
-
|
|
113
|
+
For Codex App, read the structured `messages` returned by `wait_for_message`.
|
|
114
|
+
For Cursor, handle every `[ufoo]<from:...>` event in the emitted batch.
|
|
36
115
|
|
|
37
|
-
|
|
116
|
+
1. Read each event's `data.message` or printed `Content.message`, then execute
|
|
117
|
+
actionable work.
|
|
118
|
+
2. Acknowledge only after handling:
|
|
38
119
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
printed by the stream:
|
|
120
|
+
- Codex App: MCP `ack_bus` with the returned `last_seq` as `through_seq`.
|
|
121
|
+
- Cursor: run the exact command printed by the stream:
|
|
42
122
|
|
|
43
123
|
```bash
|
|
44
124
|
ufoo bus ack "<subscriber-id>" --through <seq>
|
|
45
125
|
```
|
|
46
126
|
|
|
47
|
-
Preserve
|
|
127
|
+
Preserve the sequence boundary so later messages stay pending.
|
|
48
128
|
3. Reply only with a requested result, answer, blocker, or fact the sender
|
|
49
|
-
needs
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ufoo bus send "<sender-id>" "<substantive result>"
|
|
53
|
-
```
|
|
129
|
+
needs. Use MCP `dispatch_message` with the same `project_root`,
|
|
130
|
+
caller-owned subscriber, sender ID as `target`, and substantive result as
|
|
131
|
+
`message`.
|
|
54
132
|
|
|
55
133
|
Do not reply to greetings, thanks, or acknowledgement-only messages. After
|
|
56
|
-
sending, continue the current task
|
|
57
|
-
|
|
134
|
+
sending, continue the current task. Keep the existing Cursor stream running, or
|
|
135
|
+
re-arm one Codex App wait only after active work is complete.
|
|
136
|
+
|
|
137
|
+
## Stop resident watching
|
|
138
|
+
|
|
139
|
+
When the user asks to stop:
|
|
58
140
|
|
|
59
|
-
|
|
60
|
-
|
|
141
|
+
1. Codex App: cancel the pending `wait_for_message` call and do not re-arm it.
|
|
142
|
+
2. Cursor: stop the tracked poll process or matching terminal task and consume
|
|
143
|
+
its shell-completion notification.
|
|
144
|
+
3. Confirm that the App-specific receive wait stopped.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "ufoo bus poll"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "Use $ufoo-bus-poll to
|
|
3
|
+
short_description: "Wake external Agents with App-native idle waits"
|
|
4
|
+
default_prompt: "Use $ufoo-bus-poll to arm this external Agent with Codex App's pending MCP wait or Cursor's monitored stdout wake path."
|
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ Package: [u-foo on npm](https://www.npmjs.com/package/u-foo)
|
|
|
27
27
|
- Launch modes for internal, tmux, host, Terminal.app, and iTerm2 workflows.
|
|
28
28
|
- Built-in group templates for launching and orchestrating multi-agent workflows.
|
|
29
29
|
- `ucode`, a native ufoo coding-agent runtime.
|
|
30
|
-
- `ufoo mcp`, a local global MCP bridge for external
|
|
30
|
+
- `ufoo mcp`, a local global MCP bridge with pending-call bus wake for external
|
|
31
|
+
MCP-capable agents.
|
|
31
32
|
|
|
32
33
|
## Requirements
|
|
33
34
|
|
|
@@ -127,6 +128,27 @@ ufoo mcp
|
|
|
127
128
|
-> selected project daemon for bus/report/activity state
|
|
128
129
|
```
|
|
129
130
|
|
|
131
|
+
### Agent Delivery Modes
|
|
132
|
+
|
|
133
|
+
ufoo supports two Agent delivery modes, selected only by the presence of
|
|
134
|
+
`UFOO_SUBSCRIBER_ID`:
|
|
135
|
+
|
|
136
|
+
- Wrapper-managed Agents start through `ucodex`, `uclaude`, `uagy`, `ukimi`, or
|
|
137
|
+
`ucode`. The wrapper provides `UFOO_SUBSCRIBER_ID`; ufoo monitors the shell
|
|
138
|
+
activity and injection endpoint, so bus messages can be injected directly.
|
|
139
|
+
These Agents reuse the environment identity and do not register through MCP
|
|
140
|
+
or run a resident bus poll.
|
|
141
|
+
- Externally hosted Agents have no wrapper-provided subscriber environment.
|
|
142
|
+
They register themselves once through MCP `register_agent`, retain the
|
|
143
|
+
returned subscriber, and select the host App's native no-token wait:
|
|
144
|
+
Codex App keeps MCP `wait_for_message` pending, while Cursor monitors
|
|
145
|
+
`ufoo bus poll --follow` background output with `notify_on_output`.
|
|
146
|
+
|
|
147
|
+
Agent type names and subscriber prefixes are routing metadata, not capability
|
|
148
|
+
signals. External Agents must not export the MCP subscriber as
|
|
149
|
+
`UFOO_SUBSCRIBER_ID`, because that variable specifically marks the
|
|
150
|
+
wrapper-managed path.
|
|
151
|
+
|
|
130
152
|
Chat is a UI client. The daemon owns project runtime state. Agents communicate
|
|
131
153
|
through bus queues, prompt injection, shared memory, reports, and tool handlers
|
|
132
154
|
instead of importing chat UI code.
|
|
@@ -204,23 +226,38 @@ Use `/bus status` to find the real subscriber ID or resolvable nickname
|
|
|
204
226
|
before sending. Agents should handle pending work, reply to the sender, and
|
|
205
227
|
acknowledge their queue.
|
|
206
228
|
|
|
207
|
-
|
|
208
|
-
queue-read-only
|
|
229
|
+
Externally hosted Agents with no `UFOO_SUBSCRIBER_ID` use the opt-in
|
|
230
|
+
`ufoo-bus-poll` skill to select their host App's queue-read-only wait and
|
|
231
|
+
self-wake mechanism:
|
|
209
232
|
|
|
210
233
|
```bash
|
|
211
234
|
ufoo skills list --optional
|
|
212
235
|
ufoo skills install ufoo-bus-poll --target /path/to/that/agent/skills
|
|
213
|
-
ufoo bus poll "<subscriber-id>" --follow --interval 2
|
|
214
236
|
```
|
|
215
237
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
Register once through MCP `register_agent`, retain its returned subscriber, and
|
|
239
|
+
do not export it as `UFOO_SUBSCRIBER_ID`.
|
|
240
|
+
|
|
241
|
+
- **Codex App:** call MCP `wait_for_message` in the foreground with
|
|
242
|
+
`after_seq: 0` and `timeout_seconds: 600`. The tool call stays pending inside
|
|
243
|
+
ufoo; a message returns immediately and wakes the task without shell stdout.
|
|
244
|
+
On timeout, re-arm with the same cursor. After handling a message response,
|
|
245
|
+
call MCP `ack_bus` with its `last_seq` as `through_seq`, then re-arm with that
|
|
246
|
+
`last_seq` when the Agent is idle again.
|
|
247
|
+
- **Cursor:** run
|
|
248
|
+
`ufoo bus poll "<subscriber-id>" --follow --interval 30` through the monitored
|
|
249
|
+
background shell with `block_until_ms: 0`, and configure
|
|
250
|
+
`notify_on_output` to match `\[ufoo\]`. Startup and empty intervals are
|
|
251
|
+
silent; only ufoo-delivered messages wake the model.
|
|
252
|
+
|
|
253
|
+
Both paths keep idle queue checks outside the LLM. A background PTY alone is
|
|
254
|
+
not a wake mechanism in Codex App.
|
|
255
|
+
|
|
256
|
+
The poll skill is not installed by postinstall or `skills install all`.
|
|
257
|
+
Wrapper-managed Agents skip MCP registration and external waiting whenever
|
|
258
|
+
`UFOO_SUBSCRIBER_ID` is present. Receive-path selection depends on host App
|
|
259
|
+
capabilities, never on whether the external Agent calls itself Codex, Claude,
|
|
260
|
+
Cursor, or another type.
|
|
224
261
|
|
|
225
262
|
### Context, Memory, History, Reports
|
|
226
263
|
|
package/README.zh-CN.md
CHANGED
|
@@ -25,7 +25,8 @@ npm 包:[u-foo](https://www.npmjs.com/package/u-foo)
|
|
|
25
25
|
- 支持 internal、tmux、host、Terminal.app、iTerm2 等启动模式。
|
|
26
26
|
- 内置 group 模板,用于启动和编排多 Agent 工作流。
|
|
27
27
|
- 提供原生 ufoo coding-agent 运行时 `ucode`。
|
|
28
|
-
- 提供 `ufoo mcp` 本机 global MCP bridge
|
|
28
|
+
- 提供 `ufoo mcp` 本机 global MCP bridge,并用 pending tool call 唤醒支持
|
|
29
|
+
MCP 的外部 Agent。
|
|
29
30
|
|
|
30
31
|
## 环境要求
|
|
31
32
|
|
|
@@ -124,6 +125,23 @@ ufoo mcp
|
|
|
124
125
|
-> selected project daemon for bus/report/activity state
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
### Agent 消息投递模式
|
|
129
|
+
|
|
130
|
+
ufoo 支持两种 Agent 投递模式,只根据 `UFOO_SUBSCRIBER_ID` 是否存在来选择:
|
|
131
|
+
|
|
132
|
+
- 包装器托管的 Agent 通过 `ucodex`、`uclaude`、`uagy`、`ukimi` 或 `ucode`
|
|
133
|
+
启动。包装器会提供 `UFOO_SUBSCRIBER_ID`;ufoo 能监控其 shell 活动并定位
|
|
134
|
+
注入端点,因此可以直接注入 bus 消息。这类 Agent 复用环境中的身份,
|
|
135
|
+
不通过 MCP 重复注册,也不运行常驻 bus poll。
|
|
136
|
+
- 外部 host 托管的 Agent 没有包装器提供的 subscriber 环境变量。它通过 MCP
|
|
137
|
+
`register_agent` 注册一次,保留返回的 subscriber,再选择宿主 App 原生的
|
|
138
|
+
无 token 等待方式:Codex App 挂起 MCP `wait_for_message`,Cursor 则用
|
|
139
|
+
`notify_on_output` 监控 `ufoo bus poll --follow` 的后台输出。
|
|
140
|
+
|
|
141
|
+
Agent 类型名和 subscriber 前缀只是路由元数据,不是能力判断条件。外部 Agent
|
|
142
|
+
不要把 MCP 返回的 subscriber 导出成 `UFOO_SUBSCRIBER_ID`,因为该变量专门
|
|
143
|
+
表示包装器托管链路。
|
|
144
|
+
|
|
127
145
|
Chat 是 UI client。daemon 拥有项目运行态。Agent 通过 bus queue、prompt
|
|
128
146
|
injection、shared memory、report 和 tool handler 协作,而不是直接依赖
|
|
129
147
|
chat UI 代码。
|
|
@@ -197,22 +215,33 @@ ufoo -g
|
|
|
197
215
|
发送消息前,先用 `/bus status` 查看真实 subscriber ID 或可解析昵称。
|
|
198
216
|
Agent 应处理 pending work、回复发送方,并 ack 自己的队列。
|
|
199
217
|
|
|
200
|
-
|
|
201
|
-
|
|
218
|
+
没有 `UFOO_SUBSCRIBER_ID` 的外部 Agent 使用可选的 `ufoo-bus-poll` skill,
|
|
219
|
+
由它选择宿主 App 对应的队列只读等待和自身唤醒方式:
|
|
202
220
|
|
|
203
221
|
```bash
|
|
204
222
|
ufoo skills list --optional
|
|
205
223
|
ufoo skills install ufoo-bus-poll --target /path/to/that/agent/skills
|
|
206
|
-
ufoo bus poll "<subscriber-id>" --follow --interval 2
|
|
207
224
|
```
|
|
208
225
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
先通过 MCP `register_agent` 注册一次并保留返回的 subscriber,不要将它
|
|
227
|
+
导出成 `UFOO_SUBSCRIBER_ID`。
|
|
228
|
+
|
|
229
|
+
- **Codex App:**前台调用 MCP `wait_for_message`,首次使用
|
|
230
|
+
`after_seq: 0`、`timeout_seconds: 600`。工具调用在 ufoo 内保持 pending;
|
|
231
|
+
有消息立即返回并唤醒当前任务,不依赖 shell stdout。超时后用相同游标续挂;
|
|
232
|
+
处理消息后,用返回的 `last_seq` 作为 MCP `ack_bus.through_seq`,Agent
|
|
233
|
+
再次空闲时以该 `last_seq` 续挂。
|
|
234
|
+
- **Cursor:**通过 monitored background shell 运行
|
|
235
|
+
`ufoo bus poll "<subscriber-id>" --follow --interval 30`,设置
|
|
236
|
+
`block_until_ms: 0`,并让 `notify_on_output` 匹配 `\[ufoo\]`。启动和
|
|
237
|
+
空轮询保持静默,只有 ufoo 投递消息会唤醒模型。
|
|
238
|
+
|
|
239
|
+
两条路径都把空闲检查留在 LLM 之外。仅有后台 PTY 输出并不能唤醒 Codex App。
|
|
240
|
+
|
|
241
|
+
poll skill 不会由 postinstall 或 `skills install all` 安装。只要环境中存在
|
|
242
|
+
`UFOO_SUBSCRIBER_ID`,包装器托管的 Agent 就跳过 MCP 注册和外部等待。
|
|
243
|
+
接收链路由宿主 App 的能力决定,与外部 Agent 叫 Codex、Claude、Cursor
|
|
244
|
+
或其他类型无关。
|
|
216
245
|
|
|
217
246
|
### Context、Memory、History、Report
|
|
218
247
|
|
package/SKILLS/ufoo/SKILL.md
CHANGED
|
@@ -12,6 +12,26 @@ description: >-
|
|
|
12
12
|
Apply the unified workspace protocol. Use `$ufoo-bus`, `$ufoo-context`, or
|
|
13
13
|
`$ufoo-online` when the task needs the focused workflow.
|
|
14
14
|
|
|
15
|
+
## Resolve the bus delivery mode
|
|
16
|
+
|
|
17
|
+
Use the shell environment as the only delivery-mode signal. Do not infer
|
|
18
|
+
capabilities from the Agent type or subscriber prefix.
|
|
19
|
+
|
|
20
|
+
- If `UFOO_SUBSCRIBER_ID` is nonempty, the Agent was started by a ufoo wrapper
|
|
21
|
+
and is already registered. Reuse that identity. Do not call MCP
|
|
22
|
+
`register_agent`, run `ufoo bus join`, or start `ufoo bus poll`; the wrapper
|
|
23
|
+
and daemon deliver follow-ups by direct prompt injection.
|
|
24
|
+
- If `UFOO_SUBSCRIBER_ID` is absent, the Agent is externally hosted. Reuse the
|
|
25
|
+
subscriber already returned to this session by MCP `register_agent`, or call
|
|
26
|
+
`register_agent` once if none exists. Keep that returned subscriber as the
|
|
27
|
+
session identity, do not export it as `UFOO_SUBSCRIBER_ID`, and invoke
|
|
28
|
+
`$ufoo-bus-poll` to establish the receive path using this host App's own
|
|
29
|
+
no-token wait and self-wake primitive.
|
|
30
|
+
|
|
31
|
+
In the sections below, `<subscriber-id>` means the wrapper-provided
|
|
32
|
+
`UFOO_SUBSCRIBER_ID` or the subscriber returned by MCP, according to this
|
|
33
|
+
branch.
|
|
34
|
+
|
|
15
35
|
## Synchronize workspace state
|
|
16
36
|
|
|
17
37
|
1. Read open decisions before related work:
|
|
@@ -25,11 +45,12 @@ Apply the unified workspace protocol. Use `$ufoo-bus`, `$ufoo-context`, or
|
|
|
25
45
|
3. Consume relevant shared memory before writing new memory.
|
|
26
46
|
4. Keep the default as no new decision and no new memory entry.
|
|
27
47
|
|
|
28
|
-
## Handle
|
|
48
|
+
## Handle delivered messages
|
|
29
49
|
|
|
30
50
|
Treat these prompt prefixes as work inputs:
|
|
31
51
|
|
|
32
|
-
- `[ufoo]<from:id(nickname)>` — event-bus delivery from
|
|
52
|
+
- `[ufoo]<from:id(nickname)>` — event-bus delivery from direct injection or the
|
|
53
|
+
resident external-Agent stream.
|
|
33
54
|
- `[manual]<to:id(nickname)>` — manual work directed to this agent.
|
|
34
55
|
|
|
35
56
|
For each received bus task:
|
|
@@ -38,7 +59,7 @@ For each received bus task:
|
|
|
38
59
|
2. Acknowledge it only after handling:
|
|
39
60
|
|
|
40
61
|
```bash
|
|
41
|
-
ufoo bus ack "
|
|
62
|
+
ufoo bus ack "<subscriber-id>"
|
|
42
63
|
```
|
|
43
64
|
|
|
44
65
|
3. Reply only with a requested answer, delegated result, or fact the sender
|
|
@@ -46,12 +67,13 @@ For each received bus task:
|
|
|
46
67
|
4. Emit a concise runtime report for delegated work:
|
|
47
68
|
|
|
48
69
|
```bash
|
|
49
|
-
ufoo report done "<summary>" --agent "
|
|
70
|
+
ufoo report done "<summary>" --agent "<subscriber-id>"
|
|
50
71
|
```
|
|
51
72
|
|
|
52
|
-
After sending or broadcasting
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
After sending or broadcasting, continue the current task. Do not start an
|
|
74
|
+
ad-hoc check or poll, sleep, or wait for a reply. A wrapper-managed Agent
|
|
75
|
+
receives follow-ups by direct injection; an external Agent leaves its existing
|
|
76
|
+
App-specific `$ufoo-bus-poll` receive wait armed when idle.
|
|
55
77
|
|
|
56
78
|
## Preserve shared knowledge
|
|
57
79
|
|
|
@@ -92,5 +114,5 @@ Initialize only the required targets:
|
|
|
92
114
|
ufoo init --targets context,bus --project "$(pwd)"
|
|
93
115
|
```
|
|
94
116
|
|
|
95
|
-
|
|
96
|
-
|
|
117
|
+
Initialization does not choose or create an Agent identity. Establish identity
|
|
118
|
+
only through the delivery-mode branch above.
|
package/SKILLS/ufoo-bus/SKILL.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
name: ufoo-bus
|
|
3
3
|
description: >-
|
|
4
4
|
Operate the local ufoo event bus: check and acknowledge pending messages,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
establish the correct wrapper or MCP subscriber, inspect status, resolve
|
|
6
|
+
targets, send results, broadcast updates, and hand external Agents to
|
|
7
|
+
$ufoo-bus-poll for host App-specific no-token wait and self-wake delivery.
|
|
8
|
+
Use when asked to handle bus messages or perform local agent-to-agent routing.
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# ufoo bus
|
|
@@ -12,7 +13,7 @@ description: >-
|
|
|
12
13
|
Operate the project-local event bus without creating reply loops or competing
|
|
13
14
|
with automatic prompt injection.
|
|
14
15
|
|
|
15
|
-
## Establish the subscriber
|
|
16
|
+
## Establish the delivery mode and subscriber
|
|
16
17
|
|
|
17
18
|
Verify `.ufoo/bus/` exists. If it does not, initialize it:
|
|
18
19
|
|
|
@@ -20,23 +21,34 @@ Verify `.ufoo/bus/` exists. If it does not, initialize it:
|
|
|
20
21
|
ufoo init --targets bus --project "$(pwd)"
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Use `UFOO_SUBSCRIBER_ID` as the only delivery-mode signal:
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
- Nonempty: this is a wrapper-managed Agent. The wrapper already registered
|
|
27
|
+
the subscriber and the daemon can inject into its monitored shell. Reuse the
|
|
28
|
+
value. Do not call MCP `register_agent`, run `ufoo bus join`, or arm resident
|
|
29
|
+
polling.
|
|
30
|
+
- Absent: this is an externally hosted Agent. Reuse this session's
|
|
31
|
+
MCP-registered subscriber or call MCP `register_agent` exactly once. Keep the
|
|
32
|
+
returned value as `<subscriber-id>` for later MCP and CLI operations; do not
|
|
33
|
+
export it as `UFOO_SUBSCRIBER_ID`. Invoke `$ufoo-bus-poll` once at session
|
|
34
|
+
start to select the receive path supported by the host App.
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
Never choose a delivery mode from an Agent type such as `codex`, `cursor`, or
|
|
37
|
+
`claude-code`. Agents do not self-register with bare `ufoo bus join`.
|
|
31
38
|
|
|
32
39
|
## Handle pending messages
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
If a wrapper-managed Agent was explicitly asked for a manual inbox check, run:
|
|
35
42
|
|
|
36
43
|
```bash
|
|
37
|
-
ufoo bus check "
|
|
44
|
+
ufoo bus check "<subscriber-id>"
|
|
38
45
|
```
|
|
39
46
|
|
|
47
|
+
For an external Agent, use MCP `poll_inbox` with its `project_root` and
|
|
48
|
+
caller-owned `<subscriber-id>` only when an explicit manual check is required.
|
|
49
|
+
If `$ufoo-bus-poll` woke the session with an emitted pending batch, handle that
|
|
50
|
+
batch directly. Do not run a second check for the same wake.
|
|
51
|
+
|
|
40
52
|
For every pending batch:
|
|
41
53
|
|
|
42
54
|
1. Read the sender from `[ufoo]<from:id(nickname)>`.
|
|
@@ -44,12 +56,14 @@ For every pending batch:
|
|
|
44
56
|
3. Acknowledge after handling:
|
|
45
57
|
|
|
46
58
|
```bash
|
|
47
|
-
ufoo bus ack "
|
|
59
|
+
ufoo bus ack "<subscriber-id>"
|
|
48
60
|
```
|
|
49
61
|
|
|
50
62
|
When a batch supplies a final sequence, prefer
|
|
51
|
-
`ufoo bus ack "
|
|
52
|
-
pending.
|
|
63
|
+
`ufoo bus ack "<subscriber-id>" --through <seq>` so later arrivals stay
|
|
64
|
+
pending. For a `$ufoo-bus-poll` wake, run the exact `ack --through <seq>`
|
|
65
|
+
line printed by the Cursor stream. A Codex App wait returns `last_seq`; pass
|
|
66
|
+
it to MCP `ack_bus` as `through_seq`.
|
|
53
67
|
4. Reply only when the sender requested an answer, delegated work whose result
|
|
54
68
|
is needed, or needs a discovered blocker or fact.
|
|
55
69
|
|
|
@@ -62,31 +76,52 @@ Inspect identities and nicknames before sending:
|
|
|
62
76
|
|
|
63
77
|
```bash
|
|
64
78
|
ufoo bus status
|
|
65
|
-
ufoo bus resolve "
|
|
79
|
+
ufoo bus resolve "<subscriber-id>" <target>
|
|
66
80
|
```
|
|
67
81
|
|
|
68
|
-
|
|
82
|
+
For a wrapper-managed Agent, send to an exact subscriber ID, unique nickname,
|
|
83
|
+
or Agent type with:
|
|
69
84
|
|
|
70
85
|
```bash
|
|
71
86
|
ufoo bus send "<target>" "<substantive message>"
|
|
72
87
|
ufoo bus broadcast "<substantive message>"
|
|
73
88
|
```
|
|
74
89
|
|
|
90
|
+
For an external Agent, call MCP `dispatch_message` with its `project_root`,
|
|
91
|
+
caller-owned `subscriber`, `target`, and `message`. Use target `*` for a
|
|
92
|
+
broadcast. This preserves the registered sender identity instead of creating a
|
|
93
|
+
CLI-side identity.
|
|
94
|
+
|
|
75
95
|
Target resolution order is exact ID, nickname, agent type, then `*`.
|
|
76
96
|
|
|
77
97
|
After sending or broadcasting, continue the current task. Do not run
|
|
78
|
-
`ufoo bus check`, poll, sleep, or wait for a reply.
|
|
79
|
-
|
|
98
|
+
`ufoo bus check`, start another poll, sleep, or wait for a reply.
|
|
99
|
+
Wrapper-managed Agents receive follow-ups by direct injection; external Agents
|
|
100
|
+
keep their App-specific receive wait armed when idle.
|
|
101
|
+
|
|
102
|
+
## Delegate resident watching
|
|
103
|
+
|
|
104
|
+
For an externally hosted Agent with no `UFOO_SUBSCRIBER_ID`, invoke
|
|
105
|
+
`$ufoo-bus-poll` at session start and follow it end-to-end. This is its normal
|
|
106
|
+
receive path, not an Agent-type fallback. That skill selects pending MCP
|
|
107
|
+
`wait_for_message` for Codex App or monitored background stdout for Cursor.
|
|
108
|
+
Never invoke it when the environment variable is present.
|
|
109
|
+
|
|
110
|
+
Do not implement a timer, tick, sleep/check loop, or second resident mechanism
|
|
111
|
+
in this skill. Do not arm resident watching merely because a message was sent
|
|
112
|
+
and a reply is expected. After an App-specific wake, handle the delivered batch
|
|
113
|
+
using the rules above and leave re-arming or stream lifecycle to
|
|
114
|
+
`$ufoo-bus-poll`.
|
|
80
115
|
|
|
81
116
|
## Report delegated work
|
|
82
117
|
|
|
83
118
|
Use the shared report contract when bus work represents a task:
|
|
84
119
|
|
|
85
120
|
```bash
|
|
86
|
-
ufoo report start "<task>" --task <id> --agent "
|
|
87
|
-
ufoo report progress "<detail>" --task <id> --agent "
|
|
88
|
-
ufoo report done "<summary>" --task <id> --agent "
|
|
89
|
-
ufoo report error "<reason>" --task <id> --agent "
|
|
121
|
+
ufoo report start "<task>" --task <id> --agent "<subscriber-id>"
|
|
122
|
+
ufoo report progress "<detail>" --task <id> --agent "<subscriber-id>"
|
|
123
|
+
ufoo report done "<summary>" --task <id> --agent "<subscriber-id>"
|
|
124
|
+
ufoo report error "<reason>" --task <id> --agent "<subscriber-id>"
|
|
90
125
|
```
|
|
91
126
|
|
|
92
127
|
Use `--scope private` only for helper-internal reports.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "ufoo bus"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "Use $ufoo-bus to
|
|
3
|
+
short_description: "Route wrapper injection and App-native MCP waits"
|
|
4
|
+
default_prompt: "Use $ufoo-bus to select wrapper injection or the external host App receive path and handle pending bus work."
|
package/package.json
CHANGED
|
@@ -38,6 +38,11 @@ const SHARED_UFOO_PROTOCOL = [
|
|
|
38
38
|
"coordination — never for greetings, acknowledgments, or emoji alone.",
|
|
39
39
|
"Those create reply loops between agents.",
|
|
40
40
|
"",
|
|
41
|
+
"A nonempty `UFOO_SUBSCRIBER_ID` means the ufoo wrapper/daemon already",
|
|
42
|
+
"registered this Agent and can inject into its monitored session. Reuse that",
|
|
43
|
+
"identity; never call MCP `register_agent`, run bare `ufoo bus join`, or",
|
|
44
|
+
"start resident `ufoo bus poll`.",
|
|
45
|
+
"",
|
|
41
46
|
"`ufoo bus send <target> \"<message>\"`",
|
|
42
47
|
"",
|
|
43
48
|
"After sending, do not poll `ufoo bus check`, invoke `/ubus`, sleep, or wait",
|
|
@@ -9,6 +9,7 @@ Participate in multi-agent coordination through the ufoo bus/context system:
|
|
|
9
9
|
- Use shared memory for durable project facts. Read existing memory before writing new memory; do not use it for transient task state.
|
|
10
10
|
- Support launch/close/resume/inject flows managed by ufoo daemon.
|
|
11
11
|
- Prefer canonical ufoo commands (\`ufoo ctx\`, \`ufoo bus\`, \`ufoo memory\`, \`ufoo report\`) for coordination and status sync.
|
|
12
|
+
- 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\`.
|
|
12
13
|
- 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.
|
|
13
14
|
|
|
14
15
|
Execution protocol:
|
package/src/code/UCODE_PROMPT.md
CHANGED
|
@@ -17,6 +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
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.
|
|
21
22
|
|
|
22
23
|
Execution protocol:
|
|
@@ -25,7 +25,6 @@ const Injector = require("./inject");
|
|
|
25
25
|
const { BusStore } = require("./store");
|
|
26
26
|
const {
|
|
27
27
|
acquirePollLease,
|
|
28
|
-
assertFollowPollAllowed,
|
|
29
28
|
releasePollLease,
|
|
30
29
|
runPendingPoll,
|
|
31
30
|
} = require("./poll");
|
|
@@ -420,9 +419,10 @@ class EventBus {
|
|
|
420
419
|
/**
|
|
421
420
|
* Observe pending messages continuously without claiming or acknowledging.
|
|
422
421
|
*
|
|
423
|
-
* This is
|
|
424
|
-
* output
|
|
425
|
-
*
|
|
422
|
+
* This is the resident receive path for external agent hosts whose
|
|
423
|
+
* background-task output wakes the model. The protocol selects wrapper-native
|
|
424
|
+
* delivery versus MCP registration from UFOO_SUBSCRIBER_ID; agent type names
|
|
425
|
+
* are not an admission policy for this queue reader.
|
|
426
426
|
*/
|
|
427
427
|
async poll(subscriber, options = {}) {
|
|
428
428
|
this.ensureBus();
|
|
@@ -432,16 +432,11 @@ class EventBus {
|
|
|
432
432
|
throw new Error("poll --follow requires <subscriber-id>");
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
// Reject known built-in delivery IDs before loading or writing any shared
|
|
436
|
-
// bus state. This keeps accidental invocation side-effect free for them.
|
|
437
|
-
assertFollowPollAllowed(target);
|
|
438
|
-
|
|
439
435
|
this.loadBusData();
|
|
440
436
|
const meta = this.subscriberManager.getSubscriber(target);
|
|
441
437
|
if (!meta) {
|
|
442
438
|
throw new Error(`poll --follow requires a joined subscriber: ${target}`);
|
|
443
439
|
}
|
|
444
|
-
assertFollowPollAllowed(target, meta);
|
|
445
440
|
|
|
446
441
|
const intervalSeconds = Number(options.intervalSeconds);
|
|
447
442
|
const intervalMs = Math.max(
|
|
@@ -465,10 +460,6 @@ class EventBus {
|
|
|
465
460
|
this.subscriberManager.updateLastSeen(target);
|
|
466
461
|
this.saveBusData();
|
|
467
462
|
|
|
468
|
-
console.log(
|
|
469
|
-
`[ufoo-poll]<subscriber:${target}> following every ${intervalMs / 1000}s`
|
|
470
|
-
);
|
|
471
|
-
|
|
472
463
|
return await runPendingPoll({
|
|
473
464
|
intervalMs,
|
|
474
465
|
signal: options.signal,
|
|
@@ -476,7 +467,6 @@ class EventBus {
|
|
|
476
467
|
maxIterations: options.maxIterations,
|
|
477
468
|
readPending: () => this.queueManager.peekPending(target),
|
|
478
469
|
onEvents: async (events) => {
|
|
479
|
-
console.log(`[ufoo-poll] ${events.length} new pending event(s)`);
|
|
480
470
|
for (const event of events) {
|
|
481
471
|
const publisherMeta = this.busData.agents?.[event.publisher];
|
|
482
472
|
const nick = publisherMeta?.nickname;
|
|
@@ -3,43 +3,6 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
|
|
6
|
-
const BUILTIN_DELIVERY_AGENT_TYPES = new Set([
|
|
7
|
-
"agy",
|
|
8
|
-
"antigravity",
|
|
9
|
-
"claude",
|
|
10
|
-
"claude-code",
|
|
11
|
-
"codex",
|
|
12
|
-
"kimi",
|
|
13
|
-
"kimi-cli",
|
|
14
|
-
"kimi-code",
|
|
15
|
-
"ucode",
|
|
16
|
-
"ufoo",
|
|
17
|
-
"ufoo-agent",
|
|
18
|
-
"ufoo-code",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
function normalizeAgentType(value = "") {
|
|
22
|
-
return String(value || "").trim().toLowerCase();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function resolveSubscriberAgentType(subscriber, meta = null) {
|
|
26
|
-
const explicit = normalizeAgentType(meta && meta.agent_type);
|
|
27
|
-
if (explicit) return explicit;
|
|
28
|
-
const id = String(subscriber || "").trim();
|
|
29
|
-
const separator = id.indexOf(":");
|
|
30
|
-
return normalizeAgentType(separator === -1 ? id : id.slice(0, separator));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function assertFollowPollAllowed(subscriber, meta = null) {
|
|
34
|
-
const agentType = resolveSubscriberAgentType(subscriber, meta);
|
|
35
|
-
if (BUILTIN_DELIVERY_AGENT_TYPES.has(agentType)) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`poll --follow is disabled for built-in delivery agent type "${agentType}"`
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
return agentType;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
6
|
function eventIdentity(event = {}) {
|
|
44
7
|
const seq = Number(event && event.seq);
|
|
45
8
|
if (Number.isFinite(seq) && seq > 0) {
|
|
@@ -76,6 +39,7 @@ function defaultIsPidAlive(pid) {
|
|
|
76
39
|
|
|
77
40
|
function acquirePollLease(pidFile, options = {}) {
|
|
78
41
|
const pid = Number(options.pid) || process.pid;
|
|
42
|
+
const operation = String(options.operation || "poll --follow");
|
|
79
43
|
const isAlive = typeof options.isAlive === "function"
|
|
80
44
|
? options.isAlive
|
|
81
45
|
: defaultIsPidAlive;
|
|
@@ -90,7 +54,7 @@ function acquirePollLease(pidFile, options = {}) {
|
|
|
90
54
|
existing = 0;
|
|
91
55
|
}
|
|
92
56
|
if (Number.isFinite(existing) && existing > 0 && isAlive(existing)) {
|
|
93
|
-
throw new Error(
|
|
57
|
+
throw new Error(`${operation} is already running (pid=${existing})`);
|
|
94
58
|
}
|
|
95
59
|
fs.rmSync(pidFile, { force: true });
|
|
96
60
|
}
|
|
@@ -101,7 +65,7 @@ function acquirePollLease(pidFile, options = {}) {
|
|
|
101
65
|
fs.writeFileSync(fd, `${pid}\n`, "utf8");
|
|
102
66
|
} catch (err) {
|
|
103
67
|
if (err && err.code === "EEXIST") {
|
|
104
|
-
throw new Error(
|
|
68
|
+
throw new Error(`${operation} is already starting for this subscriber`);
|
|
105
69
|
}
|
|
106
70
|
throw err;
|
|
107
71
|
} finally {
|
|
@@ -179,12 +143,9 @@ async function runPendingPoll(options = {}) {
|
|
|
179
143
|
}
|
|
180
144
|
|
|
181
145
|
module.exports = {
|
|
182
|
-
BUILTIN_DELIVERY_AGENT_TYPES,
|
|
183
146
|
acquirePollLease,
|
|
184
|
-
assertFollowPollAllowed,
|
|
185
147
|
enumerateEventKeys,
|
|
186
148
|
eventIdentity,
|
|
187
149
|
releasePollLease,
|
|
188
|
-
resolveSubscriberAgentType,
|
|
189
150
|
runPendingPoll,
|
|
190
151
|
};
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
4
|
const net = require("net");
|
|
5
|
+
const path = require("path");
|
|
5
6
|
const EventBus = require("../../coordination/bus");
|
|
7
|
+
const {
|
|
8
|
+
acquirePollLease,
|
|
9
|
+
releasePollLease,
|
|
10
|
+
} = require("../../coordination/bus/poll");
|
|
11
|
+
const { subscriberToSafeName } = require("../../coordination/bus/utils");
|
|
6
12
|
const { normalizeReportInput } = require("../../coordination/report/store");
|
|
7
13
|
const { enqueueAgentReport } = require("./reportControlBus");
|
|
8
14
|
const { isRunning, socketPath } = require("./index");
|
|
@@ -12,6 +18,11 @@ const {
|
|
|
12
18
|
checkAndCleanupNickname,
|
|
13
19
|
} = require("./nicknameScope");
|
|
14
20
|
|
|
21
|
+
const WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
22
|
+
const WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS = 600;
|
|
23
|
+
const WAIT_FOR_MESSAGE_POLL_INTERVAL_MS = 1000;
|
|
24
|
+
const WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS = 15000;
|
|
25
|
+
|
|
15
26
|
function nowIso() {
|
|
16
27
|
return new Date().toISOString();
|
|
17
28
|
}
|
|
@@ -281,6 +292,181 @@ async function pollInbox(projectRoot, args = {}) {
|
|
|
281
292
|
};
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
function waitForMessageCancelledError() {
|
|
296
|
+
const err = new Error("wait_for_message was cancelled");
|
|
297
|
+
err.code = "request_cancelled";
|
|
298
|
+
return err;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function throwIfWaitCancelled(signal) {
|
|
302
|
+
if (signal && signal.aborted) {
|
|
303
|
+
throw waitForMessageCancelledError();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function waitWithSignal(ms, signal) {
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
let timer = null;
|
|
310
|
+
const onAbort = () => {
|
|
311
|
+
if (timer) clearTimeout(timer);
|
|
312
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
313
|
+
reject(waitForMessageCancelledError());
|
|
314
|
+
};
|
|
315
|
+
timer = setTimeout(() => {
|
|
316
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
317
|
+
resolve();
|
|
318
|
+
}, ms);
|
|
319
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeWaitForMessageArgs(args = {}) {
|
|
324
|
+
const rawAfterSeq = args.after_seq ?? args.afterSeq ?? 0;
|
|
325
|
+
const afterSeq = Number(rawAfterSeq);
|
|
326
|
+
if (!Number.isInteger(afterSeq) || afterSeq < 0) {
|
|
327
|
+
const err = new Error("wait_for_message after_seq must be a non-negative integer");
|
|
328
|
+
err.code = "invalid_after_seq";
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const rawTimeout = args.timeout_seconds
|
|
333
|
+
?? args.timeoutSeconds
|
|
334
|
+
?? WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS;
|
|
335
|
+
const timeoutSeconds = Number(rawTimeout);
|
|
336
|
+
if (
|
|
337
|
+
!Number.isFinite(timeoutSeconds)
|
|
338
|
+
|| timeoutSeconds < 1
|
|
339
|
+
|| timeoutSeconds > WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS
|
|
340
|
+
) {
|
|
341
|
+
const err = new Error(
|
|
342
|
+
`wait_for_message timeout_seconds must be between 1 and ${WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS}`
|
|
343
|
+
);
|
|
344
|
+
err.code = "invalid_timeout";
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const rawLimit = args.limit ?? 50;
|
|
349
|
+
const limit = Number(rawLimit);
|
|
350
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
351
|
+
const err = new Error("wait_for_message limit must be an integer between 1 and 100");
|
|
352
|
+
err.code = "invalid_limit";
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
afterSeq,
|
|
358
|
+
timeoutSeconds,
|
|
359
|
+
limit,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function eventSeq(event = {}) {
|
|
364
|
+
const seq = Number(event && event.seq);
|
|
365
|
+
return Number.isInteger(seq) && seq > 0 ? seq : 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function touchWaitingSubscriber(bus, subscriber) {
|
|
369
|
+
// Long waits span concurrent metadata updates from other agents. Reload
|
|
370
|
+
// before writing heartbeat state so a stale in-memory registry cannot
|
|
371
|
+
// overwrite those updates.
|
|
372
|
+
bus.loadBusData();
|
|
373
|
+
const meta = assertSubscriberExists(bus, subscriber);
|
|
374
|
+
meta.status = "active";
|
|
375
|
+
bus.subscriberManager.updateLastSeen(subscriber);
|
|
376
|
+
bus.saveBusData();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function waitForMessage(projectRoot, args = {}, options = {}) {
|
|
380
|
+
const subscriber = resolveSubscriberArg(args);
|
|
381
|
+
const { afterSeq, timeoutSeconds, limit } = normalizeWaitForMessageArgs(args);
|
|
382
|
+
const signal = options.signal || null;
|
|
383
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
384
|
+
const sleep = typeof options.sleep === "function" ? options.sleep : waitWithSignal;
|
|
385
|
+
const pollIntervalMs = Math.max(
|
|
386
|
+
50,
|
|
387
|
+
Number(options.pollIntervalMs) || WAIT_FOR_MESSAGE_POLL_INTERVAL_MS
|
|
388
|
+
);
|
|
389
|
+
const heartbeatIntervalMs = Math.max(
|
|
390
|
+
1000,
|
|
391
|
+
Number(options.heartbeatIntervalMs) || WAIT_FOR_MESSAGE_HEARTBEAT_INTERVAL_MS
|
|
392
|
+
);
|
|
393
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
394
|
+
|
|
395
|
+
const bus = ensureBusLoaded(projectRoot);
|
|
396
|
+
touchWaitingSubscriber(bus, subscriber);
|
|
397
|
+
const lease = acquirePollLease(path.join(
|
|
398
|
+
bus.busDir,
|
|
399
|
+
"pids",
|
|
400
|
+
`poll-${subscriberToSafeName(subscriber)}.pid`
|
|
401
|
+
), { operation: "wait_for_message" });
|
|
402
|
+
const cleanupLease = () => releasePollLease(lease);
|
|
403
|
+
process.once("exit", cleanupLease);
|
|
404
|
+
|
|
405
|
+
const startedAt = now();
|
|
406
|
+
const deadline = startedAt + timeoutMs;
|
|
407
|
+
let nextHeartbeatAt = startedAt + heartbeatIntervalMs;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
while (true) {
|
|
411
|
+
throwIfWaitCancelled(signal);
|
|
412
|
+
|
|
413
|
+
// Queue reads are non-mutating: the Agent acknowledges only after work is
|
|
414
|
+
// handled, and after_seq suppresses re-delivery within a re-armed wait.
|
|
415
|
+
// eslint-disable-next-line no-await-in-loop
|
|
416
|
+
const pending = await bus.queueManager.peekPending(subscriber);
|
|
417
|
+
const unseen = pending.filter((event) => eventSeq(event) > afterSeq);
|
|
418
|
+
if (unseen.length > 0) {
|
|
419
|
+
const messages = unseen.slice(0, limit);
|
|
420
|
+
const lastSeq = Math.max(afterSeq, ...messages.map(eventSeq));
|
|
421
|
+
touchWaitingSubscriber(bus, subscriber);
|
|
422
|
+
return {
|
|
423
|
+
ok: true,
|
|
424
|
+
project_root: projectRoot,
|
|
425
|
+
subscriber,
|
|
426
|
+
status: "message",
|
|
427
|
+
timed_out: false,
|
|
428
|
+
count: messages.length,
|
|
429
|
+
messages,
|
|
430
|
+
truncated: unseen.length > messages.length,
|
|
431
|
+
after_seq: afterSeq,
|
|
432
|
+
last_seq: lastSeq,
|
|
433
|
+
waited_ms: Math.max(0, now() - startedAt),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const current = now();
|
|
438
|
+
if (current >= deadline) {
|
|
439
|
+
touchWaitingSubscriber(bus, subscriber);
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
project_root: projectRoot,
|
|
443
|
+
subscriber,
|
|
444
|
+
status: "timeout",
|
|
445
|
+
timed_out: true,
|
|
446
|
+
count: 0,
|
|
447
|
+
messages: [],
|
|
448
|
+
truncated: false,
|
|
449
|
+
after_seq: afterSeq,
|
|
450
|
+
last_seq: afterSeq,
|
|
451
|
+
waited_ms: Math.max(0, current - startedAt),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (current >= nextHeartbeatAt) {
|
|
456
|
+
touchWaitingSubscriber(bus, subscriber);
|
|
457
|
+
nextHeartbeatAt = current + heartbeatIntervalMs;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const delayMs = Math.max(1, Math.min(pollIntervalMs, deadline - current));
|
|
461
|
+
// eslint-disable-next-line no-await-in-loop
|
|
462
|
+
await sleep(delayMs, signal);
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
process.removeListener("exit", cleanupLease);
|
|
466
|
+
cleanupLease();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
284
470
|
async function reportAgentStatus(projectRoot, args = {}) {
|
|
285
471
|
const subscriber = resolveSubscriberArg(args);
|
|
286
472
|
const report = normalizeReportInput({
|
|
@@ -325,6 +511,10 @@ module.exports = {
|
|
|
325
511
|
publishActivityState,
|
|
326
512
|
updateAgentMetadata,
|
|
327
513
|
pollInbox,
|
|
514
|
+
waitForMessage,
|
|
328
515
|
reportAgentStatus,
|
|
329
516
|
unregisterAgent,
|
|
517
|
+
normalizeWaitForMessageArgs,
|
|
518
|
+
WAIT_FOR_MESSAGE_DEFAULT_TIMEOUT_SECONDS,
|
|
519
|
+
WAIT_FOR_MESSAGE_MAX_TIMEOUT_SECONDS,
|
|
330
520
|
};
|
|
@@ -53,7 +53,7 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
name: "register_agent",
|
|
56
|
-
description: "Register an externally launched
|
|
56
|
+
description: "Register an externally launched Agent only when its shell has no wrapper-provided UFOO_SUBSCRIBER_ID.",
|
|
57
57
|
input_schema: {
|
|
58
58
|
type: "object",
|
|
59
59
|
required: ["project_root"],
|
|
@@ -132,6 +132,34 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
132
132
|
},
|
|
133
133
|
handler: handlePollInbox,
|
|
134
134
|
},
|
|
135
|
+
{
|
|
136
|
+
name: "wait_for_message",
|
|
137
|
+
description: "Keep a Codex App-compatible MCP tool call pending until the caller-owned bus queue receives messages after after_seq or the wait reaches its timeout.",
|
|
138
|
+
input_schema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
required: ["project_root", "subscriber"],
|
|
141
|
+
properties: {
|
|
142
|
+
project_root: { type: "string" },
|
|
143
|
+
subscriber: { type: "string" },
|
|
144
|
+
after_seq: {
|
|
145
|
+
type: "integer",
|
|
146
|
+
minimum: 0,
|
|
147
|
+
default: 0,
|
|
148
|
+
description: "Return only messages with a sequence greater than this cursor.",
|
|
149
|
+
},
|
|
150
|
+
timeout_seconds: {
|
|
151
|
+
type: "number",
|
|
152
|
+
minimum: 1,
|
|
153
|
+
maximum: 600,
|
|
154
|
+
default: 600,
|
|
155
|
+
description: "Keep the tool call pending for at most this many seconds.",
|
|
156
|
+
},
|
|
157
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
158
|
+
},
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
handler: handleWaitForMessage,
|
|
162
|
+
},
|
|
135
163
|
{
|
|
136
164
|
name: "report_agent_status",
|
|
137
165
|
description: "Queue an agent task status report through the project daemon report-control queue.",
|
|
@@ -416,6 +444,17 @@ async function handlePollInbox(ctx = {}, args = {}) {
|
|
|
416
444
|
return controlPlane.pollInbox(projectRoot, args);
|
|
417
445
|
}
|
|
418
446
|
|
|
447
|
+
async function handleWaitForMessage(ctx = {}, args = {}) {
|
|
448
|
+
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
449
|
+
return controlPlane.waitForMessage(projectRoot, args, {
|
|
450
|
+
signal: ctx.signal,
|
|
451
|
+
pollIntervalMs: ctx.waitPollIntervalMs,
|
|
452
|
+
heartbeatIntervalMs: ctx.waitHeartbeatIntervalMs,
|
|
453
|
+
now: ctx.waitNow,
|
|
454
|
+
sleep: ctx.waitSleep,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
419
458
|
async function handleReportAgentStatus(ctx = {}, args = {}) {
|
|
420
459
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
421
460
|
return controlPlane.reportAgentStatus(projectRoot, args);
|
|
@@ -467,10 +506,15 @@ class UfooMcpServer {
|
|
|
467
506
|
autoStart: options.autoStart !== false,
|
|
468
507
|
validateProjectRoot: options.validateProjectRoot !== false,
|
|
469
508
|
startTimeoutMs: options.startTimeoutMs,
|
|
509
|
+
waitPollIntervalMs: options.waitPollIntervalMs,
|
|
510
|
+
waitHeartbeatIntervalMs: options.waitHeartbeatIntervalMs,
|
|
511
|
+
waitNow: options.waitNow,
|
|
512
|
+
waitSleep: options.waitSleep,
|
|
470
513
|
};
|
|
471
514
|
this.initialized = false;
|
|
472
515
|
this.startup = null;
|
|
473
516
|
this.registeredSubscribers = [];
|
|
517
|
+
this.activeToolCalls = new Map();
|
|
474
518
|
}
|
|
475
519
|
|
|
476
520
|
async ensureStarted() {
|
|
@@ -503,6 +547,11 @@ class UfooMcpServer {
|
|
|
503
547
|
if (method === "notifications/initialized") {
|
|
504
548
|
this.initialized = true;
|
|
505
549
|
}
|
|
550
|
+
if (method === "notifications/cancelled") {
|
|
551
|
+
const requestId = params.requestId;
|
|
552
|
+
const active = this.activeToolCalls.get(requestId);
|
|
553
|
+
if (active) active.abort();
|
|
554
|
+
}
|
|
506
555
|
return null;
|
|
507
556
|
}
|
|
508
557
|
|
|
@@ -541,10 +590,23 @@ class UfooMcpServer {
|
|
|
541
590
|
if (!name) {
|
|
542
591
|
return createJsonRpcError(id, MCP_ERROR_CODES.INVALID_PARAMS, "tools/call requires params.name");
|
|
543
592
|
}
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
593
|
+
const abortController = new AbortController();
|
|
594
|
+
this.activeToolCalls.set(id, abortController);
|
|
595
|
+
let result;
|
|
596
|
+
try {
|
|
597
|
+
const runTool = () => invokeTool(name, args, {
|
|
598
|
+
...this.options,
|
|
599
|
+
toolCallId: id,
|
|
600
|
+
signal: abortController.signal,
|
|
601
|
+
});
|
|
602
|
+
// A long-lived wait must not hold the process-global console shim for
|
|
603
|
+
// up to ten minutes while unrelated MCP calls continue concurrently.
|
|
604
|
+
result = name === "wait_for_message"
|
|
605
|
+
? await runTool()
|
|
606
|
+
: await suppressConsoleToStderr(runTool);
|
|
607
|
+
} finally {
|
|
608
|
+
this.activeToolCalls.delete(id);
|
|
609
|
+
}
|
|
548
610
|
if (name === "register_agent" && result && result.subscriber && result.project_root) {
|
|
549
611
|
this.registeredSubscribers.push({
|
|
550
612
|
subscriber: result.subscriber,
|
|
@@ -570,6 +632,10 @@ class UfooMcpServer {
|
|
|
570
632
|
}
|
|
571
633
|
|
|
572
634
|
cleanup() {
|
|
635
|
+
for (const active of this.activeToolCalls.values()) {
|
|
636
|
+
active.abort();
|
|
637
|
+
}
|
|
638
|
+
this.activeToolCalls.clear();
|
|
573
639
|
for (const { subscriber, projectRoot } of this.registeredSubscribers) {
|
|
574
640
|
try {
|
|
575
641
|
controlPlane.unregisterAgent(projectRoot, { subscriber });
|
|
@@ -3,6 +3,9 @@ const { buildToolError, requireSubscriber, getEventBus } = require("./common");
|
|
|
3
3
|
async function ackBusHandler(ctx = {}, args = {}) {
|
|
4
4
|
const subscriber = requireSubscriber(ctx);
|
|
5
5
|
const requestedSubscriber = String(args.subscriber || subscriber).trim();
|
|
6
|
+
const rawThroughSeq = args.through_seq ?? args.throughSeq;
|
|
7
|
+
const hasThroughSeq = rawThroughSeq !== undefined && rawThroughSeq !== null;
|
|
8
|
+
const throughSeq = hasThroughSeq ? Number(rawThroughSeq) : null;
|
|
6
9
|
|
|
7
10
|
if (requestedSubscriber !== subscriber) {
|
|
8
11
|
throw buildToolError(
|
|
@@ -10,14 +13,23 @@ async function ackBusHandler(ctx = {}, args = {}) {
|
|
|
10
13
|
"ack_bus can only acknowledge the caller subscriber queue"
|
|
11
14
|
);
|
|
12
15
|
}
|
|
16
|
+
if (hasThroughSeq && (!Number.isInteger(throughSeq) || throughSeq <= 0)) {
|
|
17
|
+
throw buildToolError(
|
|
18
|
+
"invalid_arguments",
|
|
19
|
+
"ack_bus through_seq must be a positive integer"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
13
22
|
|
|
14
23
|
const eventBus = getEventBus(ctx);
|
|
15
|
-
const count =
|
|
24
|
+
const count = hasThroughSeq
|
|
25
|
+
? await eventBus.ackThrough(subscriber, throughSeq)
|
|
26
|
+
: await eventBus.ack(subscriber);
|
|
16
27
|
|
|
17
28
|
return {
|
|
18
29
|
ok: true,
|
|
19
30
|
subscriber,
|
|
20
31
|
acknowledged: count,
|
|
32
|
+
...(hasThroughSeq ? { through_seq: throughSeq } : {}),
|
|
21
33
|
};
|
|
22
34
|
}
|
|
23
35
|
|
|
@@ -228,6 +228,11 @@ const ACK_BUS_SCHEMA = Object.freeze({
|
|
|
228
228
|
type: "object",
|
|
229
229
|
properties: Object.freeze({
|
|
230
230
|
subscriber: Object.freeze({ type: "string" }),
|
|
231
|
+
through_seq: Object.freeze({
|
|
232
|
+
type: "integer",
|
|
233
|
+
minimum: 1,
|
|
234
|
+
description: "Acknowledge only messages up to this sequence, preserving later arrivals.",
|
|
235
|
+
}),
|
|
231
236
|
}),
|
|
232
237
|
additionalProperties: false,
|
|
233
238
|
}),
|
|
@@ -238,6 +243,7 @@ const ACK_BUS_SCHEMA = Object.freeze({
|
|
|
238
243
|
ok: Object.freeze({ type: "boolean" }),
|
|
239
244
|
subscriber: Object.freeze({ type: "string" }),
|
|
240
245
|
acknowledged: Object.freeze({ type: "integer" }),
|
|
246
|
+
through_seq: Object.freeze({ type: "integer" }),
|
|
241
247
|
}),
|
|
242
248
|
additionalProperties: false,
|
|
243
249
|
}),
|