u-foo 3.0.15 → 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.
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: ufoo-bus-poll
3
+ description: >-
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
+ ---
10
+
11
+ # ufoo bus poll
12
+
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
+
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.
19
+
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:
84
+
85
+ ```bash
86
+ ufoo bus poll "<mcp-subscriber-id>" --follow --interval 30
87
+ ```
88
+
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.
110
+
111
+ ## Handle delivered batches
112
+
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.
115
+
116
+ 1. Read each event's `data.message` or printed `Content.message`, then execute
117
+ actionable work.
118
+ 2. Acknowledge only after handling:
119
+
120
+ - Codex App: MCP `ack_bus` with the returned `last_seq` as `through_seq`.
121
+ - Cursor: run the exact command printed by the stream:
122
+
123
+ ```bash
124
+ ufoo bus ack "<subscriber-id>" --through <seq>
125
+ ```
126
+
127
+ Preserve the sequence boundary so later messages stay pending.
128
+ 3. Reply only with a requested result, answer, blocker, or fact the sender
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`.
132
+
133
+ Do not reply to greetings, thanks, or acknowledgement-only messages. After
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:
140
+
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.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "ufoo bus poll"
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 MCP-capable agents.
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,22 +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
- Agent hosts that cannot receive ufoo prompt injection can opt into a resident,
208
- queue-read-only bus stream:
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
- ufoo skills install ubus-poll --target /path/to/that/agent/skills
213
- ufoo bus poll "$UFOO_SUBSCRIBER_ID" --follow --interval 2
235
+ ufoo skills install ufoo-bus-poll --target /path/to/that/agent/skills
214
236
  ```
215
237
 
216
- Run the final command through that host's streaming background-task facility.
217
- It prints newly observed pending events but never claims or acknowledges them;
218
- the agent runs the printed `ufoo bus ack --through <seq>` command only after
219
- handling the emitted batch, preserving later arrivals. The fallback is not
220
- installed by postinstall or `skills install all`, and follow mode refuses Codex
221
- CLI, Claude Code CLI, Agy, Kimi, and native ucode subscriber types so their
222
- existing delivery paths remain untouched.
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.
223
261
 
224
262
  ### Context, Memory, History, Reports
225
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,供支持 MCP 的外部 Agent 接入。
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,21 +215,33 @@ ufoo -g
197
215
  发送消息前,先用 `/bus status` 查看真实 subscriber ID 或可解析昵称。
198
216
  Agent 应处理 pending work、回复发送方,并 ack 自己的队列。
199
217
 
200
- 无法接收 ufoo prompt 注入的 Agent host 可以显式启用常驻、队列只读的
201
- bus 消息流:
218
+ 没有 `UFOO_SUBSCRIBER_ID` 的外部 Agent 使用可选的 `ufoo-bus-poll` skill,
219
+ 由它选择宿主 App 对应的队列只读等待和自身唤醒方式:
202
220
 
203
221
  ```bash
204
222
  ufoo skills list --optional
205
- ufoo skills install ubus-poll --target /path/to/that/agent/skills
206
- ufoo bus poll "$UFOO_SUBSCRIBER_ID" --follow --interval 2
223
+ ufoo skills install ufoo-bus-poll --target /path/to/that/agent/skills
207
224
  ```
208
225
 
209
- 最后一条命令必须由该 host 的流式后台任务能力托管。它只输出新观察到的
210
- pending event,不 claim、不 ack;Agent 处理完输出批次后,再执行输出中
211
- 给出的 `ufoo bus ack --through <seq>`,以保留稍后到达的消息。
212
- 这个 fallback 不会被 postinstall `skills install all` 安装,而且
213
- follow 模式会拒绝 Codex CLI、Claude Code CLI、Agy、Kimi 和原生 ucode
214
- subscriber 类型,确保其现有投递链路不受影响。
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
+ 或其他类型无关。
215
245
 
216
246
  ### Context、Memory、History、Report
217
247
 
@@ -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 injected messages
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 another agent.
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 "$UFOO_SUBSCRIBER_ID"
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 "$UFOO_SUBSCRIBER_ID"
70
+ ufoo report done "<summary>" --agent "<subscriber-id>"
50
71
  ```
51
72
 
52
- After sending or broadcasting a message, continue the current task. Do not
53
- poll, invoke a bus-check skill, sleep, or wait for a reply; follow-up messages
54
- are injected automatically.
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
- Reuse an existing subscriber identity after initialization. Join the bus only
96
- when no current identity can be recovered.
117
+ Initialization does not choose or create an Agent identity. Establish identity
118
+ only through the delivery-mode branch above.
@@ -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
- identify or join a subscriber, inspect status, resolve targets, send results,
6
- and broadcast updates. Use when asked to handle bus messages or perform local
7
- agent-to-agent routing.
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
- Prefer `UFOO_SUBSCRIBER_ID`, then recover the current identity:
24
+ Use `UFOO_SUBSCRIBER_ID` as the only delivery-mode signal:
24
25
 
25
- ```bash
26
- ufoo bus whoami
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
- Run `ufoo bus join` only when neither source yields an identity. Rejoining can
30
- cause identity drift.
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
- Check messages only when this skill was invoked to do so:
41
+ If a wrapper-managed Agent was explicitly asked for a manual inbox check, run:
35
42
 
36
43
  ```bash
37
- ufoo bus check "$UFOO_SUBSCRIBER_ID"
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 "$UFOO_SUBSCRIBER_ID"
59
+ ufoo bus ack "<subscriber-id>"
48
60
  ```
49
61
 
50
62
  When a batch supplies a final sequence, prefer
51
- `ufoo bus ack "$UFOO_SUBSCRIBER_ID" --through <seq>` so later arrivals stay
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 "$UFOO_SUBSCRIBER_ID" <target>
79
+ ufoo bus resolve "<subscriber-id>" <target>
66
80
  ```
67
81
 
68
- Send to an exact subscriber ID, unique nickname, or agent type:
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. Follow-up messages are
79
- injected into the prompt/session automatically.
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 "$UFOO_SUBSCRIBER_ID"
87
- ufoo report progress "<detail>" --task <id> --agent "$UFOO_SUBSCRIBER_ID"
88
- ufoo report done "<summary>" --task <id> --agent "$UFOO_SUBSCRIBER_ID"
89
- ufoo report error "<reason>" --task <id> --agent "$UFOO_SUBSCRIBER_ID"
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: "Handle local ufoo bus messages and agent routing"
4
- default_prompt: "Use $ufoo-bus to check and handle pending event-bus work."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "3.0.15",
3
+ "version": "3.0.17",
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",
@@ -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:
@@ -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 an explicit fallback for agent hosts whose own background-task
424
- * output is their delivery mechanism. Built-in ufoo agent families keep
425
- * using their existing injection/internal-consumption paths.
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(`poll --follow is already running (pid=${existing})`);
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("poll --follow is already starting for this subscriber");
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 agent into a registered project bus.",
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 result = await suppressConsoleToStderr(() => invokeTool(name, args, {
545
- ...this.options,
546
- toolCallId: id,
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 = await eventBus.ack(subscriber);
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
  }),
@@ -1,76 +0,0 @@
1
- ---
2
- name: ubus-poll
3
- description: |
4
- Explicitly start a resident ufoo bus stream in an agent host that has been
5
- configured to deliver streaming background-task output. Install by name only.
6
- ---
7
-
8
- # /ubus-poll - Resident Bus Stream
9
-
10
- This is an opt-in session-start skill. Run it only in a host where a human has
11
- configured this fallback. Do not install or invoke it for Codex CLI, Claude
12
- Code CLI, Agy, Kimi, or native ucode; those runtimes already have their own
13
- ufoo delivery path.
14
-
15
- ## Start once per agent session
16
-
17
- Reuse the provisioned subscriber identity. Never create a second identity just
18
- for the poll process.
19
-
20
- ```bash
21
- SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-}"
22
- test -n "$SUBSCRIBER" || {
23
- echo "ubus-poll requires a provisioned UFOO_SUBSCRIBER_ID"
24
- exit 1
25
- }
26
- ```
27
-
28
- Identity provisioning is a host/human setup step. Do not guess an agent type,
29
- call bare `ufoo bus join`, or borrow the workspace's current subscriber.
30
-
31
- Use the agent host's **streaming background-task** facility to start:
32
-
33
- ```bash
34
- ufoo bus poll "$SUBSCRIBER" --follow --interval 2
35
- ```
36
-
37
- The command must remain owned by that facility. Do not use `nohup`, shell `&`,
38
- or an OS-detached daemon: those routes can put output in a log that never
39
- reaches the agent. The command rejects a second resident poll for the same
40
- subscriber.
41
-
42
- The poll is deliberately queue-read-only. It emits current pending events at
43
- startup, waits for that batch to be acknowledged, then emits the next pending
44
- batch. It does not ack, claim, inject, or clear messages itself.
45
-
46
- ## When background output arrives
47
-
48
- For every `[ufoo]<from:...>` event:
49
-
50
- 1. Read `Content.message` and execute actionable work.
51
- 2. After handling the emitted batch, run the exact `ack --through <seq>`
52
- command printed by the poll stream. For example:
53
-
54
- ```bash
55
- ufoo bus ack "$SUBSCRIBER" --through 42
56
- ```
57
-
58
- `--through` preserves any later message that was not in the displayed batch.
59
-
60
- 3. Reply to the sender only for a requested result, an answer, or information
61
- they need to continue:
62
-
63
- ```bash
64
- ufoo bus send "<sender-id>" "<substantive result>"
65
- ```
66
-
67
- Ack-only messages, greetings, and thanks need no reply.
68
-
69
- After sending, do not poll, sleep, or wait for a reply. Keep working; this
70
- resident stream will emit any follow-up.
71
-
72
- ## Host requirement
73
-
74
- This flow works only when the agent host forwards incremental output from a
75
- still-running background task into the agent session. If it only returns output
76
- after process exit, use an explicitly invoked `/ubus` instead.