u-foo 3.0.13 → 3.0.14
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/ubus-poll/SKILL.md +76 -0
- package/README.md +17 -0
- package/README.zh-CN.md +16 -0
- package/package.json +2 -1
- package/src/app/cli/busCoreCommands.js +112 -6
- package/src/app/cli/features/skills.js +22 -3
- package/src/app/cli/run.js +50 -5
- package/src/coordination/bus/index.js +108 -0
- package/src/coordination/bus/message.js +4 -0
- package/src/coordination/bus/poll.js +190 -0
- package/src/coordination/bus/queue.js +44 -1
|
@@ -0,0 +1,76 @@
|
|
|
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.
|
package/README.md
CHANGED
|
@@ -199,6 +199,23 @@ Use `/bus status` to find the real subscriber ID or resolvable nickname
|
|
|
199
199
|
before sending. Agents should handle pending work, reply to the sender, and
|
|
200
200
|
acknowledge their queue.
|
|
201
201
|
|
|
202
|
+
Agent hosts that cannot receive ufoo prompt injection can opt into a resident,
|
|
203
|
+
queue-read-only bus stream:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
ufoo skills list --optional
|
|
207
|
+
ufoo skills install ubus-poll --target /path/to/that/agent/skills
|
|
208
|
+
ufoo bus poll "$UFOO_SUBSCRIBER_ID" --follow --interval 2
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Run the final command through that host's streaming background-task facility.
|
|
212
|
+
It prints newly observed pending events but never claims or acknowledges them;
|
|
213
|
+
the agent runs the printed `ufoo bus ack --through <seq>` command only after
|
|
214
|
+
handling the emitted batch, preserving later arrivals. The fallback is not
|
|
215
|
+
installed by postinstall or `skills install all`, and follow mode refuses Codex
|
|
216
|
+
CLI, Claude Code CLI, Agy, Kimi, and native ucode subscriber types so their
|
|
217
|
+
existing delivery paths remain untouched.
|
|
218
|
+
|
|
202
219
|
### Context, Memory, History, Reports
|
|
203
220
|
|
|
204
221
|
Inside chat:
|
package/README.zh-CN.md
CHANGED
|
@@ -193,6 +193,22 @@ ufoo -g
|
|
|
193
193
|
发送消息前,先用 `/bus status` 查看真实 subscriber ID 或可解析昵称。
|
|
194
194
|
Agent 应处理 pending work、回复发送方,并 ack 自己的队列。
|
|
195
195
|
|
|
196
|
+
无法接收 ufoo prompt 注入的 Agent host 可以显式启用常驻、队列只读的
|
|
197
|
+
bus 消息流:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
ufoo skills list --optional
|
|
201
|
+
ufoo skills install ubus-poll --target /path/to/that/agent/skills
|
|
202
|
+
ufoo bus poll "$UFOO_SUBSCRIBER_ID" --follow --interval 2
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
最后一条命令必须由该 host 的流式后台任务能力托管。它只输出新观察到的
|
|
206
|
+
pending event,不 claim、不 ack;Agent 处理完输出批次后,再执行输出中
|
|
207
|
+
给出的 `ufoo bus ack --through <seq>`,以保留稍后到达的消息。
|
|
208
|
+
这个 fallback 不会被 postinstall 或 `skills install all` 安装,而且
|
|
209
|
+
follow 模式会拒绝 Codex CLI、Claude Code CLI、Agy、Kimi 和原生 ucode
|
|
210
|
+
的 subscriber 类型,确保其现有投递链路不受影响。
|
|
211
|
+
|
|
196
212
|
### Context、Memory、History、Report
|
|
197
213
|
|
|
198
214
|
在 chat 内:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u-foo",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.14",
|
|
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,7 @@
|
|
|
38
38
|
"scripts/",
|
|
39
39
|
"dist/tui/",
|
|
40
40
|
"SKILLS/",
|
|
41
|
+
"OPTIONAL_SKILLS/",
|
|
41
42
|
"LICENSE",
|
|
42
43
|
"README.md"
|
|
43
44
|
],
|
|
@@ -40,17 +40,106 @@ function parseSendArgs(cmdArgs = []) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function resolvePollSubscriber(cmdArgs = [], env = process.env) {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
let subscriber = "";
|
|
44
|
+
let autoAck = false;
|
|
45
|
+
let follow = false;
|
|
46
|
+
let intervalSeconds = 2;
|
|
47
|
+
let intervalWasSet = false;
|
|
48
|
+
|
|
49
|
+
for (let index = 0; index < cmdArgs.length; index += 1) {
|
|
50
|
+
const arg = String(cmdArgs[index] || "");
|
|
51
|
+
if (arg === "--ack" || arg === "--auto-ack") {
|
|
52
|
+
autoAck = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (arg === "--follow") {
|
|
56
|
+
follow = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === "--interval") {
|
|
60
|
+
const value = cmdArgs[index + 1];
|
|
61
|
+
if (value === undefined || String(value).startsWith("--")) {
|
|
62
|
+
throw new Error("poll --interval requires <seconds>");
|
|
63
|
+
}
|
|
64
|
+
intervalSeconds = Number(value);
|
|
65
|
+
intervalWasSet = true;
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith("--interval=")) {
|
|
70
|
+
intervalSeconds = Number(arg.slice("--interval=".length));
|
|
71
|
+
intervalWasSet = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (arg.startsWith("--")) {
|
|
75
|
+
throw new Error(`Unknown poll option: ${arg}`);
|
|
76
|
+
}
|
|
77
|
+
if (subscriber) {
|
|
78
|
+
throw new Error("poll accepts at most one [subscriber]");
|
|
79
|
+
}
|
|
80
|
+
subscriber = arg.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
subscriber = String(subscriber || env.UFOO_SUBSCRIBER_ID || "").trim();
|
|
45
84
|
if (!subscriber) {
|
|
46
85
|
throw new Error("poll requires [subscriber] or UFOO_SUBSCRIBER_ID");
|
|
47
86
|
}
|
|
87
|
+
if (follow && autoAck) {
|
|
88
|
+
throw new Error("poll --follow cannot be combined with --ack");
|
|
89
|
+
}
|
|
90
|
+
if (intervalWasSet && !follow) {
|
|
91
|
+
throw new Error("poll --interval requires --follow");
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds < 0.25) {
|
|
94
|
+
throw new Error("poll --interval must be at least 0.25 seconds");
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
return {
|
|
49
98
|
subscriber,
|
|
50
|
-
autoAck
|
|
99
|
+
autoAck,
|
|
100
|
+
follow,
|
|
101
|
+
intervalSeconds,
|
|
51
102
|
};
|
|
52
103
|
}
|
|
53
104
|
|
|
105
|
+
function resolveAckArgs(cmdArgs = []) {
|
|
106
|
+
let subscriber = "";
|
|
107
|
+
let throughSeq = null;
|
|
108
|
+
|
|
109
|
+
for (let index = 0; index < cmdArgs.length; index += 1) {
|
|
110
|
+
const arg = String(cmdArgs[index] || "");
|
|
111
|
+
if (arg === "--through") {
|
|
112
|
+
const value = cmdArgs[index + 1];
|
|
113
|
+
if (value === undefined || String(value).startsWith("--")) {
|
|
114
|
+
throw new Error("ack --through requires <seq>");
|
|
115
|
+
}
|
|
116
|
+
throughSeq = Number(value);
|
|
117
|
+
index += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg.startsWith("--through=")) {
|
|
121
|
+
throughSeq = Number(arg.slice("--through=".length));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (arg.startsWith("--")) {
|
|
125
|
+
throw new Error(`Unknown ack option: ${arg}`);
|
|
126
|
+
}
|
|
127
|
+
if (subscriber) {
|
|
128
|
+
throw new Error("ack accepts exactly one <subscriber>");
|
|
129
|
+
}
|
|
130
|
+
subscriber = arg.trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!subscriber) {
|
|
134
|
+
throw new Error("ack requires <subscriber>");
|
|
135
|
+
}
|
|
136
|
+
if (throughSeq !== null && (!Number.isInteger(throughSeq) || throughSeq <= 0)) {
|
|
137
|
+
throw new Error("ack --through requires a positive integer sequence");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { subscriber, throughSeq };
|
|
141
|
+
}
|
|
142
|
+
|
|
54
143
|
async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
55
144
|
switch (cmd) {
|
|
56
145
|
case "init":
|
|
@@ -91,11 +180,24 @@ async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
|
91
180
|
case "poll":
|
|
92
181
|
{
|
|
93
182
|
const parsed = resolvePollSubscriber(cmdArgs);
|
|
94
|
-
|
|
183
|
+
if (parsed.follow) {
|
|
184
|
+
await eventBus.poll(parsed.subscriber, {
|
|
185
|
+
intervalSeconds: parsed.intervalSeconds,
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
await eventBus.check(parsed.subscriber, parsed.autoAck);
|
|
189
|
+
}
|
|
95
190
|
}
|
|
96
191
|
return {};
|
|
97
192
|
case "ack":
|
|
98
|
-
|
|
193
|
+
{
|
|
194
|
+
const parsed = resolveAckArgs(cmdArgs);
|
|
195
|
+
if (parsed.throughSeq !== null) {
|
|
196
|
+
await eventBus.ackThrough(parsed.subscriber, parsed.throughSeq);
|
|
197
|
+
} else {
|
|
198
|
+
await eventBus.ack(parsed.subscriber);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
99
201
|
return {};
|
|
100
202
|
case "consume":
|
|
101
203
|
await eventBus.consume(cmdArgs[0], cmdArgs.includes("--from-beginning"));
|
|
@@ -117,4 +219,8 @@ async function runBusCoreCommand(eventBus, cmd, cmdArgs = []) {
|
|
|
117
219
|
}
|
|
118
220
|
}
|
|
119
221
|
|
|
120
|
-
module.exports = {
|
|
222
|
+
module.exports = {
|
|
223
|
+
resolveAckArgs,
|
|
224
|
+
resolvePollSubscriber,
|
|
225
|
+
runBusCoreCommand,
|
|
226
|
+
};
|
|
@@ -8,6 +8,7 @@ class SkillsManager {
|
|
|
8
8
|
constructor(repoRoot) {
|
|
9
9
|
this.repoRoot = repoRoot;
|
|
10
10
|
this.skillRoots = this.findSkillRoots();
|
|
11
|
+
this.optionalSkillRoots = this.findOptionalSkillRoots();
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -22,13 +23,31 @@ class SkillsManager {
|
|
|
22
23
|
return roots;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Optional skills are discoverable and explicitly installable, but are not
|
|
28
|
+
* included in default listings or `skills install all`.
|
|
29
|
+
*/
|
|
30
|
+
findOptionalSkillRoots() {
|
|
31
|
+
const roots = [];
|
|
32
|
+
const optionalSkills = path.join(this.repoRoot, "OPTIONAL_SKILLS");
|
|
33
|
+
if (fs.existsSync(optionalSkills)) {
|
|
34
|
+
roots.push(optionalSkills);
|
|
35
|
+
}
|
|
36
|
+
return roots;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/**
|
|
26
40
|
* 列出所有技能
|
|
27
41
|
*/
|
|
28
|
-
list() {
|
|
42
|
+
list(options = {}) {
|
|
29
43
|
const skills = new Set();
|
|
44
|
+
const roots = options.optionalOnly
|
|
45
|
+
? this.optionalSkillRoots
|
|
46
|
+
: (options.includeOptional
|
|
47
|
+
? [...this.skillRoots, ...this.optionalSkillRoots]
|
|
48
|
+
: this.skillRoots);
|
|
30
49
|
|
|
31
|
-
for (const root of
|
|
50
|
+
for (const root of roots) {
|
|
32
51
|
if (!fs.existsSync(root)) {
|
|
33
52
|
continue;
|
|
34
53
|
}
|
|
@@ -48,7 +67,7 @@ class SkillsManager {
|
|
|
48
67
|
* 查找技能路径
|
|
49
68
|
*/
|
|
50
69
|
findSkill(name) {
|
|
51
|
-
for (const root of this.skillRoots) {
|
|
70
|
+
for (const root of [...this.skillRoots, ...this.optionalSkillRoots]) {
|
|
52
71
|
const skillPath = path.join(root, name);
|
|
53
72
|
if (fs.existsSync(skillPath)) {
|
|
54
73
|
return skillPath;
|
package/src/app/cli/run.js
CHANGED
|
@@ -1102,11 +1102,12 @@ async function runCli(argv) {
|
|
|
1102
1102
|
skills
|
|
1103
1103
|
.command("list")
|
|
1104
1104
|
.description("List available skills")
|
|
1105
|
-
.
|
|
1105
|
+
.option("--optional", "List opt-in skills that are not installed by default")
|
|
1106
|
+
.action((opts) => {
|
|
1106
1107
|
const SkillsManager = require("./features/skills");
|
|
1107
1108
|
const repoRoot = getPackageRoot();
|
|
1108
1109
|
const manager = new SkillsManager(repoRoot);
|
|
1109
|
-
const skillsList = manager.list();
|
|
1110
|
+
const skillsList = manager.list({ optionalOnly: Boolean(opts.optional) });
|
|
1110
1111
|
skillsList.forEach((skill) => console.log(skill));
|
|
1111
1112
|
});
|
|
1112
1113
|
skills
|
|
@@ -1526,6 +1527,49 @@ async function runCli(argv) {
|
|
|
1526
1527
|
process.exitCode = 1;
|
|
1527
1528
|
});
|
|
1528
1529
|
});
|
|
1530
|
+
bus
|
|
1531
|
+
.command("poll")
|
|
1532
|
+
.description("Check once, or stream pending messages for an opt-in background task")
|
|
1533
|
+
.argument("[subscriber]", "Subscriber ID (defaults to UFOO_SUBSCRIBER_ID)")
|
|
1534
|
+
.option("--ack", "Acknowledge after a one-shot check")
|
|
1535
|
+
.option("--auto-ack", "Alias for --ack")
|
|
1536
|
+
.option("--follow", "Continuously emit newly observed pending events")
|
|
1537
|
+
.option("--interval <seconds>", "Follow interval in seconds")
|
|
1538
|
+
.action(async (subscriber, opts) => {
|
|
1539
|
+
const EventBus = require("../../coordination/bus");
|
|
1540
|
+
const eventBus = new EventBus(process.cwd());
|
|
1541
|
+
const args = [];
|
|
1542
|
+
if (subscriber) args.push(subscriber);
|
|
1543
|
+
if (opts.ack) args.push("--ack");
|
|
1544
|
+
if (opts.autoAck) args.push("--auto-ack");
|
|
1545
|
+
if (opts.follow) args.push("--follow");
|
|
1546
|
+
if (opts.interval !== undefined) args.push("--interval", String(opts.interval));
|
|
1547
|
+
|
|
1548
|
+
try {
|
|
1549
|
+
await runBusCoreCommand(eventBus, "poll", args);
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
console.error(err.message);
|
|
1552
|
+
process.exitCode = 1;
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
bus
|
|
1556
|
+
.command("ack")
|
|
1557
|
+
.description("Acknowledge pending messages")
|
|
1558
|
+
.argument("<subscriber>", "Subscriber ID")
|
|
1559
|
+
.option("--through <seq>", "Acknowledge only through this displayed sequence")
|
|
1560
|
+
.action(async (subscriber, opts) => {
|
|
1561
|
+
const EventBus = require("../../coordination/bus");
|
|
1562
|
+
const eventBus = new EventBus(process.cwd());
|
|
1563
|
+
const args = [subscriber];
|
|
1564
|
+
if (opts.through !== undefined) args.push("--through", String(opts.through));
|
|
1565
|
+
|
|
1566
|
+
try {
|
|
1567
|
+
await runBusCoreCommand(eventBus, "ack", args);
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
console.error(err.message);
|
|
1570
|
+
process.exitCode = 1;
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1529
1573
|
bus
|
|
1530
1574
|
.command("inject")
|
|
1531
1575
|
.description("Inject /bus into a Terminal.app tab by subscriber ID")
|
|
@@ -1695,7 +1739,7 @@ async function runCli(argv) {
|
|
|
1695
1739
|
console.log(" ufoo report <start|progress|done|error|list> [message] [--task <id>] [--agent <id>]");
|
|
1696
1740
|
console.log(" ufoo ucode [doctor|prepare|build] [--skip-install]");
|
|
1697
1741
|
console.log(" ufoo init [--targets <list>] [--project <dir>]");
|
|
1698
|
-
console.log(" ufoo skills list");
|
|
1742
|
+
console.log(" ufoo skills list [--optional]");
|
|
1699
1743
|
console.log(" ufoo skills install <name|all> [--target <dir> | --codex | --agents]");
|
|
1700
1744
|
console.log(" ufoo group templates [list|ls] [--json]");
|
|
1701
1745
|
console.log(" ufoo group template <list|show|validate|new> [target] [--from <builtin>] [--global] [--force] [--json]");
|
|
@@ -1715,7 +1759,8 @@ async function runCli(argv) {
|
|
|
1715
1759
|
console.log(" ufoo online send --nickname <name> --text <msg> [--channel <ch>] [--room <id>]");
|
|
1716
1760
|
console.log(" ufoo online inbox <nickname> [--clear] [--unread]");
|
|
1717
1761
|
console.log(" ufoo bus wake <target> [--reason <reason>] [--no-shake]");
|
|
1718
|
-
console.log(" ufoo bus poll [subscriber] [--ack]");
|
|
1762
|
+
console.log(" ufoo bus poll [subscriber] [--ack | --follow --interval <seconds>]");
|
|
1763
|
+
console.log(" ufoo bus ack <subscriber> [--through <seq>]");
|
|
1719
1764
|
console.log(" ufoo bus <args...> (JS bus implementation)");
|
|
1720
1765
|
console.log(" ufoo ctx <subcmd> ... (doctor|lint|decisions|sync)");
|
|
1721
1766
|
console.log(" ufoo history <build|show|prompt> [limit]");
|
|
@@ -2056,7 +2101,7 @@ async function runCli(argv) {
|
|
|
2056
2101
|
const sub = rest[0] || "";
|
|
2057
2102
|
|
|
2058
2103
|
if (sub === "list") {
|
|
2059
|
-
const skillsList = manager.list();
|
|
2104
|
+
const skillsList = manager.list({ optionalOnly: rest.includes("--optional") });
|
|
2060
2105
|
skillsList.forEach((skill) => console.log(skill));
|
|
2061
2106
|
return;
|
|
2062
2107
|
}
|
|
@@ -23,6 +23,12 @@ const MessageManager = require("./message");
|
|
|
23
23
|
const NicknameManager = require("./nickname");
|
|
24
24
|
const Injector = require("./inject");
|
|
25
25
|
const { BusStore } = require("./store");
|
|
26
|
+
const {
|
|
27
|
+
acquirePollLease,
|
|
28
|
+
assertFollowPollAllowed,
|
|
29
|
+
releasePollLease,
|
|
30
|
+
runPendingPoll,
|
|
31
|
+
} = require("./poll");
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* Event Bus - 项目级 Agent 事件总线
|
|
@@ -411,6 +417,90 @@ class EventBus {
|
|
|
411
417
|
return pending;
|
|
412
418
|
}
|
|
413
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Observe pending messages continuously without claiming or acknowledging.
|
|
422
|
+
*
|
|
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.
|
|
426
|
+
*/
|
|
427
|
+
async poll(subscriber, options = {}) {
|
|
428
|
+
this.ensureBus();
|
|
429
|
+
|
|
430
|
+
const target = String(subscriber || "").trim();
|
|
431
|
+
if (!target) {
|
|
432
|
+
throw new Error("poll --follow requires <subscriber-id>");
|
|
433
|
+
}
|
|
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
|
+
this.loadBusData();
|
|
440
|
+
const meta = this.subscriberManager.getSubscriber(target);
|
|
441
|
+
if (!meta) {
|
|
442
|
+
throw new Error(`poll --follow requires a joined subscriber: ${target}`);
|
|
443
|
+
}
|
|
444
|
+
assertFollowPollAllowed(target, meta);
|
|
445
|
+
|
|
446
|
+
const intervalSeconds = Number(options.intervalSeconds);
|
|
447
|
+
const intervalMs = Math.max(
|
|
448
|
+
250,
|
|
449
|
+
Number.isFinite(intervalSeconds) ? intervalSeconds * 1000 : 2000
|
|
450
|
+
);
|
|
451
|
+
const pollPidFile = path.join(
|
|
452
|
+
this.busDir,
|
|
453
|
+
"pids",
|
|
454
|
+
`poll-${subscriberToSafeName(target)}.pid`
|
|
455
|
+
);
|
|
456
|
+
const lease = acquirePollLease(pollPidFile, { isAlive: isPidAlive });
|
|
457
|
+
const cleanupLease = () => releasePollLease(lease);
|
|
458
|
+
process.once("exit", cleanupLease);
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// The resident poll process owns liveness for this explicitly opted-in
|
|
462
|
+
// subscriber. No notifier/injector state is touched.
|
|
463
|
+
meta.status = "active";
|
|
464
|
+
meta.pid = process.pid;
|
|
465
|
+
this.subscriberManager.updateLastSeen(target);
|
|
466
|
+
this.saveBusData();
|
|
467
|
+
|
|
468
|
+
console.log(
|
|
469
|
+
`[ufoo-poll]<subscriber:${target}> following every ${intervalMs / 1000}s`
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
return await runPendingPoll({
|
|
473
|
+
intervalMs,
|
|
474
|
+
signal: options.signal,
|
|
475
|
+
sleep: options.sleep,
|
|
476
|
+
maxIterations: options.maxIterations,
|
|
477
|
+
readPending: () => this.queueManager.peekPending(target),
|
|
478
|
+
onEvents: async (events) => {
|
|
479
|
+
console.log(`[ufoo-poll] ${events.length} new pending event(s)`);
|
|
480
|
+
for (const event of events) {
|
|
481
|
+
const publisherMeta = this.busData.agents?.[event.publisher];
|
|
482
|
+
const nick = publisherMeta?.nickname;
|
|
483
|
+
const fromLabel = nick ? `${event.publisher}(${nick})` : event.publisher;
|
|
484
|
+
console.log(`[ufoo]<from:${fromLabel || "unknown"}>`);
|
|
485
|
+
console.log(`Type: ${event.type}/${event.event}`);
|
|
486
|
+
console.log(`Content: ${JSON.stringify(event.data)}`);
|
|
487
|
+
}
|
|
488
|
+
const sequenced = events
|
|
489
|
+
.map((event) => Number(event && event.seq))
|
|
490
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
491
|
+
const throughSeq = sequenced.length === events.length
|
|
492
|
+
? Math.max(...sequenced)
|
|
493
|
+
: 0;
|
|
494
|
+
const ackSuffix = throughSeq > 0 ? ` --through ${throughSeq}` : "";
|
|
495
|
+
console.log(`After handling, run: ufoo bus ack ${target}${ackSuffix}`);
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
} finally {
|
|
499
|
+
process.removeListener("exit", cleanupLease);
|
|
500
|
+
cleanupLease();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
414
504
|
/**
|
|
415
505
|
* 确认消息
|
|
416
506
|
*/
|
|
@@ -429,6 +519,24 @@ class EventBus {
|
|
|
429
519
|
return count;
|
|
430
520
|
}
|
|
431
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Confirm only the displayed portion of a sequenced pending queue.
|
|
524
|
+
*/
|
|
525
|
+
async ackThrough(subscriber, throughSeq) {
|
|
526
|
+
this.ensureBus();
|
|
527
|
+
this.loadBusData();
|
|
528
|
+
|
|
529
|
+
const count = await this.messageManager.ackThrough(subscriber, throughSeq);
|
|
530
|
+
|
|
531
|
+
if (count > 0) {
|
|
532
|
+
logOk(`Acknowledged ${count} message(s) through seq=${throughSeq}`);
|
|
533
|
+
} else {
|
|
534
|
+
logOk(`No pending messages through seq=${throughSeq}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return count;
|
|
538
|
+
}
|
|
539
|
+
|
|
432
540
|
/**
|
|
433
541
|
* 消费事件
|
|
434
542
|
*/
|
|
@@ -529,6 +529,10 @@ class MessageManager {
|
|
|
529
529
|
return this.queueManager.ackPending(subscriber);
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
async ackThrough(subscriber, throughSeq) {
|
|
533
|
+
return this.queueManager.ackPendingThrough(subscriber, throughSeq);
|
|
534
|
+
}
|
|
535
|
+
|
|
532
536
|
/**
|
|
533
537
|
* 消费事件(从 offset 开始)
|
|
534
538
|
*/
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
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
|
+
function eventIdentity(event = {}) {
|
|
44
|
+
const seq = Number(event && event.seq);
|
|
45
|
+
if (Number.isFinite(seq) && seq > 0) {
|
|
46
|
+
return `seq:${seq}`;
|
|
47
|
+
}
|
|
48
|
+
return `event:${JSON.stringify(event || {})}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function enumerateEventKeys(events = []) {
|
|
52
|
+
const occurrences = new Map();
|
|
53
|
+
return events.map((event) => {
|
|
54
|
+
const identity = eventIdentity(event);
|
|
55
|
+
const occurrence = occurrences.get(identity) || 0;
|
|
56
|
+
occurrences.set(identity, occurrence + 1);
|
|
57
|
+
return {
|
|
58
|
+
event,
|
|
59
|
+
key: `${identity}#${occurrence}`,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultSleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultIsPidAlive(pid) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, 0);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return Boolean(err && err.code === "EPERM");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function acquirePollLease(pidFile, options = {}) {
|
|
78
|
+
const pid = Number(options.pid) || process.pid;
|
|
79
|
+
const isAlive = typeof options.isAlive === "function"
|
|
80
|
+
? options.isAlive
|
|
81
|
+
: defaultIsPidAlive;
|
|
82
|
+
|
|
83
|
+
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
84
|
+
|
|
85
|
+
if (fs.existsSync(pidFile)) {
|
|
86
|
+
let existing = 0;
|
|
87
|
+
try {
|
|
88
|
+
existing = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
|
89
|
+
} catch {
|
|
90
|
+
existing = 0;
|
|
91
|
+
}
|
|
92
|
+
if (Number.isFinite(existing) && existing > 0 && isAlive(existing)) {
|
|
93
|
+
throw new Error(`poll --follow is already running (pid=${existing})`);
|
|
94
|
+
}
|
|
95
|
+
fs.rmSync(pidFile, { force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = fs.openSync(pidFile, "wx");
|
|
101
|
+
fs.writeFileSync(fd, `${pid}\n`, "utf8");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err && err.code === "EEXIST") {
|
|
104
|
+
throw new Error("poll --follow is already starting for this subscriber");
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
} finally {
|
|
108
|
+
if (typeof fd === "number") fs.closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { pid, pidFile };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function releasePollLease(lease) {
|
|
115
|
+
if (!lease || !lease.pidFile) return false;
|
|
116
|
+
try {
|
|
117
|
+
const existing = Number.parseInt(
|
|
118
|
+
fs.readFileSync(lease.pidFile, "utf8").trim(),
|
|
119
|
+
10
|
|
120
|
+
);
|
|
121
|
+
if (existing !== lease.pid) return false;
|
|
122
|
+
fs.rmSync(lease.pidFile, { force: true });
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runPendingPoll(options = {}) {
|
|
130
|
+
const readPending = options.readPending;
|
|
131
|
+
const onEvents = options.onEvents;
|
|
132
|
+
const sleep = options.sleep || defaultSleep;
|
|
133
|
+
const signal = options.signal || null;
|
|
134
|
+
const intervalMs = Math.max(250, Number(options.intervalMs) || 2000);
|
|
135
|
+
const maxIterations = Number.isFinite(options.maxIterations)
|
|
136
|
+
? Math.max(0, Math.floor(options.maxIterations))
|
|
137
|
+
: Infinity;
|
|
138
|
+
|
|
139
|
+
if (typeof readPending !== "function") {
|
|
140
|
+
throw new Error("runPendingPoll requires readPending");
|
|
141
|
+
}
|
|
142
|
+
if (typeof onEvents !== "function") {
|
|
143
|
+
throw new Error("runPendingPoll requires onEvents");
|
|
144
|
+
}
|
|
145
|
+
if (maxIterations === 0) {
|
|
146
|
+
return { iterations: 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let inFlightKeys = new Set();
|
|
150
|
+
let iterations = 0;
|
|
151
|
+
|
|
152
|
+
while (!signal || !signal.aborted) {
|
|
153
|
+
// eslint-disable-next-line no-await-in-loop
|
|
154
|
+
const pending = await readPending();
|
|
155
|
+
const keyed = enumerateEventKeys(Array.isArray(pending) ? pending : []);
|
|
156
|
+
const currentKeys = new Set(keyed.map((entry) => entry.key));
|
|
157
|
+
const hasUnacknowledgedBatch = Array.from(inFlightKeys)
|
|
158
|
+
.some((key) => currentKeys.has(key));
|
|
159
|
+
|
|
160
|
+
if (!hasUnacknowledgedBatch) {
|
|
161
|
+
inFlightKeys = new Set();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (inFlightKeys.size === 0 && keyed.length > 0) {
|
|
165
|
+
const batch = keyed.map((entry) => entry.event);
|
|
166
|
+
// eslint-disable-next-line no-await-in-loop
|
|
167
|
+
await onEvents(batch);
|
|
168
|
+
inFlightKeys = currentKeys;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
iterations += 1;
|
|
172
|
+
if (iterations >= maxIterations || (signal && signal.aborted)) break;
|
|
173
|
+
|
|
174
|
+
// eslint-disable-next-line no-await-in-loop
|
|
175
|
+
await sleep(intervalMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { iterations };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
BUILTIN_DELIVERY_AGENT_TYPES,
|
|
183
|
+
acquirePollLease,
|
|
184
|
+
assertFollowPollAllowed,
|
|
185
|
+
enumerateEventKeys,
|
|
186
|
+
eventIdentity,
|
|
187
|
+
releasePollLease,
|
|
188
|
+
resolveSubscriberAgentType,
|
|
189
|
+
runPendingPoll,
|
|
190
|
+
};
|
|
@@ -5,7 +5,11 @@ const {
|
|
|
5
5
|
ensureDir,
|
|
6
6
|
truncateFile,
|
|
7
7
|
} = require("./utils");
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
DeliveryQueue,
|
|
10
|
+
positiveSeq,
|
|
11
|
+
stripQueueEnvelope,
|
|
12
|
+
} = require("./deliveryQueue");
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* 队列管理器
|
|
@@ -87,6 +91,15 @@ class QueueManager {
|
|
|
87
91
|
return this.getDeliveryQueue(subscriber).readPending().map(stripQueueEnvelope);
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Non-mutating pending read for opt-in background observers.
|
|
96
|
+
*
|
|
97
|
+
* Unlike readPending(), this deliberately does not recover stale claims.
|
|
98
|
+
*/
|
|
99
|
+
async peekPending(subscriber) {
|
|
100
|
+
return this.getDeliveryQueue(subscriber).readPendingRaw().map(stripQueueEnvelope);
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/**
|
|
91
104
|
* 追加待处理消息
|
|
92
105
|
*/
|
|
@@ -122,6 +135,36 @@ class QueueManager {
|
|
|
122
135
|
return count;
|
|
123
136
|
}
|
|
124
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Acknowledge only sequenced events up to and including throughSeq.
|
|
140
|
+
*
|
|
141
|
+
* Later arrivals remain pending, which prevents a background poll consumer
|
|
142
|
+
* from clearing messages that were not part of the emitted batch.
|
|
143
|
+
*/
|
|
144
|
+
async ackPendingThrough(subscriber, throughSeq) {
|
|
145
|
+
const limit = Number(throughSeq);
|
|
146
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
147
|
+
throw new Error("ack --through requires a positive sequence");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const deliveryQueue = this.getDeliveryQueue(subscriber);
|
|
151
|
+
let count = 0;
|
|
152
|
+
while (true) {
|
|
153
|
+
const claim = deliveryQueue.claimNext();
|
|
154
|
+
if (!claim) break;
|
|
155
|
+
|
|
156
|
+
const seq = positiveSeq(claim.event);
|
|
157
|
+
if (seq === 0 || seq > limit) {
|
|
158
|
+
deliveryQueue.restoreClaim(claim);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
deliveryQueue.completeClaim(claim);
|
|
163
|
+
count += 1;
|
|
164
|
+
}
|
|
165
|
+
return count;
|
|
166
|
+
}
|
|
167
|
+
|
|
125
168
|
/**
|
|
126
169
|
* 检查是否有待处理消息
|
|
127
170
|
*/
|