u-foo 3.0.13 → 3.0.15
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 +22 -0
- package/README.zh-CN.md +20 -0
- package/SKILLS/ufoo/SKILL.md +56 -179
- package/SKILLS/ufoo/agents/openai.yaml +4 -0
- package/SKILLS/ufoo-bus/SKILL.md +92 -0
- package/SKILLS/ufoo-bus/agents/openai.yaml +4 -0
- package/SKILLS/ufoo-context/SKILL.md +101 -0
- package/SKILLS/ufoo-context/agents/openai.yaml +4 -0
- package/SKILLS/ufoo-online/SKILL.md +43 -112
- package/SKILLS/ufoo-online/agents/openai.yaml +4 -0
- package/package.json +2 -1
- package/scripts/postinstall-skills.js +81 -0
- package/scripts/postinstall.js +8 -15
- package/src/app/cli/busCoreCommands.js +112 -6
- package/src/app/cli/features/doctor.js +4 -4
- 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
- package/src/coordination/bus/store.js +1 -1
- package/src/coordination/context/doctor.js +2 -2
- package/SKILLS/ubus/SKILL.md +0 -250
- package/SKILLS/uctx/SKILL.md +0 -156
- package/SKILLS/uinit/SKILL.md +0 -76
- package/SKILLS/ustatus/SKILL.md +0 -36
|
@@ -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
|
*/
|
|
@@ -95,7 +95,7 @@ class BusStore {
|
|
|
95
95
|
ensure() {
|
|
96
96
|
if (!fs.existsSync(this.busDir) || !fs.existsSync(this.paths.agentDir)) {
|
|
97
97
|
throw new Error(
|
|
98
|
-
"Event bus not initialized. Please run: ufoo
|
|
98
|
+
"Event bus not initialized. Please run: ufoo init --targets bus"
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -86,7 +86,7 @@ class ContextDoctor {
|
|
|
86
86
|
* Lint bundled context skill.
|
|
87
87
|
*/
|
|
88
88
|
lintProtocol() {
|
|
89
|
-
const repoSkill = path.join(this.projectRoot, "SKILLS", "
|
|
89
|
+
const repoSkill = path.join(this.projectRoot, "SKILLS", "ufoo-context", "SKILL.md");
|
|
90
90
|
|
|
91
91
|
if (!fs.existsSync(repoSkill)) {
|
|
92
92
|
console.log("No bundled context skill found (skipping protocol lint)");
|
|
@@ -94,7 +94,7 @@ class ContextDoctor {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
console.log(`Linting bundled context skill: ${repoSkill}`);
|
|
97
|
-
this.checkFile(repoSkill, "SKILLS/
|
|
97
|
+
this.checkFile(repoSkill, "SKILLS/ufoo-context/SKILL.md");
|
|
98
98
|
|
|
99
99
|
return !this.failed;
|
|
100
100
|
}
|
package/SKILLS/ubus/SKILL.md
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ubus
|
|
3
|
-
description: |
|
|
4
|
-
Check and handle pending event-bus messages when /ubus is explicitly invoked.
|
|
5
|
-
Use when: (1) asked to check messages, (2) view bus status, (3) use watch/listen/auto modes.
|
|
6
|
-
If not yet joined bus, will auto-join.
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
# /ubus - Check Event Bus Messages
|
|
10
|
-
|
|
11
|
-
Check and handle pending messages on the event bus when `/ubus` is explicitly
|
|
12
|
-
invoked.
|
|
13
|
-
|
|
14
|
-
## Arguments
|
|
15
|
-
|
|
16
|
-
- `/ubus` - Pull pending messages and show status
|
|
17
|
-
- `/ubus watch` - Start background auto-notification (title badge + bell + notification center)
|
|
18
|
-
- `/ubus stop` - Stop background auto-notification
|
|
19
|
-
- `/ubus listen` - Foreground continuous listener, print new messages (suitable for side terminal)
|
|
20
|
-
- `/ubus auto` - Unattended auto-execute (auto-inject `/ubus` and press Enter)
|
|
21
|
-
|
|
22
|
-
## Execution Flow
|
|
23
|
-
|
|
24
|
-
### 1. Check if .ufoo/bus exists
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
if [[ ! -d ".ufoo/bus" ]]; then
|
|
28
|
-
echo "Event bus not initialized, please run /uinit and select bus module"
|
|
29
|
-
exit
|
|
30
|
-
fi
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
### 2. Get or create subscriber ID
|
|
34
|
-
|
|
35
|
-
**IMPORTANT**: Always check for existing subscriber ID first to avoid creating duplicates.
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
# Reuse existing subscriber first (env -> whoami), join only if missing
|
|
39
|
-
SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"
|
|
40
|
-
if [ -n "$SUBSCRIBER" ]; then
|
|
41
|
-
echo "Using existing subscriber ID: $SUBSCRIBER"
|
|
42
|
-
else
|
|
43
|
-
# Not launched via uclaude/ucodex, need to join manually
|
|
44
|
-
SUBSCRIBER=$(ufoo bus join | tail -n 1)
|
|
45
|
-
echo "Joined event bus: $SUBSCRIBER"
|
|
46
|
-
# Example output: codex:0e293156 (nickname: codex-1)
|
|
47
|
-
fi
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
**Why this matters**:
|
|
51
|
-
- `uclaude`/`ucodex` automatically set `UFOO_SUBSCRIBER_ID` during launch
|
|
52
|
-
- `ufoo bus whoami` can recover current ID even when env is missing
|
|
53
|
-
- Re-joining may create identity drift and message routing issues
|
|
54
|
-
- Always reuse existing ID when available
|
|
55
|
-
|
|
56
|
-
To join with a custom nickname:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
ufoo bus join [session-id] [agent-type] "your-nickname"
|
|
60
|
-
# Example: ufoo bus join abc123 claude-code "architect"
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### 3. Handle arguments
|
|
64
|
-
|
|
65
|
-
If argument is `watch`, use **Bash tool's `run_in_background: true`** to start background notification:
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
# Title badge + bell + notification center (no accessibility permission needed)
|
|
69
|
-
ufoo bus alert "$SUBSCRIBER" 2 --notify --daemon
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
If argument is `listen`, foreground blocking listener (no background task tool needed):
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
ufoo bus listen "$SUBSCRIBER" --from-beginning
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
If argument is `auto`, use unattended auto-execute:
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
# Start daemon (background resident), auto-inject /ubus + Enter on new message
|
|
82
|
-
ufoo bus daemon --daemon
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
Tips:
|
|
86
|
-
- Need to use `uclaude`/`ucodex` wrapper to start Claude Code/Codex (auto-records tty)
|
|
87
|
-
- Terminal.app needs Accessibility permission (for keyboard input injection)
|
|
88
|
-
|
|
89
|
-
If argument is `stop`, stop background notification:
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
ufoo bus alert "$SUBSCRIBER" --stop
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
### 4. Check pending events
|
|
96
|
-
|
|
97
|
-
```bash
|
|
98
|
-
ufoo bus check "$SUBSCRIBER"
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
The system automatically prefixes each message with `[ufoo]<from:id(nickname)>` to identify the sender. You do not need to add this prefix yourself.
|
|
102
|
-
|
|
103
|
-
If pending events exist, output looks like:
|
|
104
|
-
|
|
105
|
-
```
|
|
106
|
-
[ufoo]<from:claude-code:abc123(architect)>
|
|
107
|
-
Type: message/targeted/message
|
|
108
|
-
Content: {"message":"review src/main.ts","injection_mode":"immediate"}
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
- The sender ID and nickname are in the `[ufoo]<from:...>` line — use the ID to reply
|
|
112
|
-
- The actual task is in `Content.message`
|
|
113
|
-
|
|
114
|
-
### 5. IMPORTANT: Acknowledge messages after handling
|
|
115
|
-
|
|
116
|
-
After you have read and processed the messages, you MUST acknowledge them to prevent repeated notifications:
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
ufoo bus ack "$SUBSCRIBER"
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
**This is critical** - if you don't ack, the runtime may retry delivery or keep
|
|
123
|
-
the event pending.
|
|
124
|
-
|
|
125
|
-
**Default behavior is ack-only, no reply.** If there's nothing to do (no actionable task, no question to answer, no follow-up the sender genuinely needs), just ack and stop. Silence is a valid response — see "Handling Received Messages" below for when a reply IS warranted.
|
|
126
|
-
|
|
127
|
-
### 6. Routing Override
|
|
128
|
-
|
|
129
|
-
If the message explicitly instructs you to report to a specific PM/DEV/TEST ID, **send the result to that ID instead of the publisher**.
|
|
130
|
-
|
|
131
|
-
### 5. Show bus status
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
ufoo bus status
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Output (now includes nicknames):
|
|
138
|
-
|
|
139
|
-
```
|
|
140
|
-
=== Event Bus Status ===
|
|
141
|
-
My identity: claude-code:xyz789
|
|
142
|
-
Online agents: 2
|
|
143
|
-
- claude-code:abc123 (architect)
|
|
144
|
-
- claude-code:xyz789 (dev-lead)
|
|
145
|
-
Recent events: 5
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Managing Nicknames
|
|
149
|
-
|
|
150
|
-
### View and Change Nicknames
|
|
151
|
-
|
|
152
|
-
```bash
|
|
153
|
-
# Change an agent's nickname
|
|
154
|
-
ufoo bus rename <subscriber-id> "new-nickname"
|
|
155
|
-
# Example: ufoo bus rename claude-code:47b1d525 "backend-dev"
|
|
156
|
-
|
|
157
|
-
# Nickname alias command
|
|
158
|
-
ufoo bus nick <subscriber-id> "new-nickname"
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
**Important Notes:**
|
|
162
|
-
- Nicknames must be globally unique
|
|
163
|
-
- Cannot change nickname during join (use `rename` command instead)
|
|
164
|
-
- Re-joining with same subscriber ID will reuse existing nickname
|
|
165
|
-
- Auto-generated nicknames: `codex-1`, `codex-2`, `claude-1`, `claude-2`, etc.
|
|
166
|
-
|
|
167
|
-
## Handling Received Messages
|
|
168
|
-
|
|
169
|
-
When receiving targeted messages, the default flow is **execute → ack → stop**.
|
|
170
|
-
Replies are the exception, not the default.
|
|
171
|
-
|
|
172
|
-
1. **Understand request** — Read message content.
|
|
173
|
-
2. **Execute task** — If the message delegates a task, do it.
|
|
174
|
-
3. **`ufoo bus ack "$SUBSCRIBER"`** — Always ack, even when not replying.
|
|
175
|
-
4. **Reply ONLY when substantive.** Send `ufoo bus send` to the sender only if at least one of the following is true:
|
|
176
|
-
- The sender asked a question → reply with the answer.
|
|
177
|
-
- The sender delegated a task → reply with the result / artifact / status.
|
|
178
|
-
- You discovered something the sender needs to proceed → reply with that fact.
|
|
179
|
-
|
|
180
|
-
```bash
|
|
181
|
-
# Use this only when the criteria above are met.
|
|
182
|
-
ufoo bus send "<sender-id>" "<substantive-reply>"
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
### Anti-pattern: greet / ack loops
|
|
186
|
-
|
|
187
|
-
If the inbound message is itself just a greeting, an acknowledgment, or a
|
|
188
|
-
pleasantry, **do not reply**. Acking is enough. A bare-acknowledgment reply
|
|
189
|
-
will be auto-injected on the other side, triggering them to reply in kind,
|
|
190
|
-
and the two of you will ping-pong forever.
|
|
191
|
-
|
|
192
|
-
| Inbound | Reply? |
|
|
193
|
-
|---|---|
|
|
194
|
-
| `👋` / `hi` / `hello` / `你好` | ❌ ack only |
|
|
195
|
-
| `👍` / `ok` / `收到` / `thanks` / `noted` | ❌ ack only |
|
|
196
|
-
| `已完成 / done / finished` (without a result the sender asked for) | ❌ ack only |
|
|
197
|
-
| `请把 src/foo.ts 改成 ...` (task) | ✅ reply with result |
|
|
198
|
-
| `这个 bug 的根因是什么?` (question) | ✅ reply with answer |
|
|
199
|
-
| `我帮你找到了 X,需要你做 Y` (request) | ✅ reply with status |
|
|
200
|
-
|
|
201
|
-
When in doubt: ack and stop. If the sender genuinely needs something from you,
|
|
202
|
-
they will follow up with a concrete question or task.
|
|
203
|
-
|
|
204
|
-
## Sending Messages
|
|
205
|
-
|
|
206
|
-
After sending a message, do not run `/ubus`, poll, sleep, or wait for a reply.
|
|
207
|
-
Continue the current task. Any follow-up message will be automatically injected
|
|
208
|
-
into your prompt/session.
|
|
209
|
-
|
|
210
|
-
### Smart Routing (when you don't know the target ID)
|
|
211
|
-
|
|
212
|
-
If the user says "notify codex to do X" without specifying an ID, use smart routing:
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
# Step 1: Find candidates
|
|
216
|
-
ufoo bus resolve "$SUBSCRIBER" codex
|
|
217
|
-
|
|
218
|
-
# Output shows:
|
|
219
|
-
# - If only 1 codex: directly shows the ID
|
|
220
|
-
# - If multiple: shows each with nickname and message history
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Based on the output:
|
|
224
|
-
- **Single match**: Use that ID directly
|
|
225
|
-
- **Multiple matches**: Analyze the message history to find the right target
|
|
226
|
-
- Look for context clues in previous conversations
|
|
227
|
-
- If still unclear, ask the user which one, or send to all of that type
|
|
228
|
-
|
|
229
|
-
### Direct Send
|
|
230
|
-
|
|
231
|
-
```bash
|
|
232
|
-
# Send to specific Agent by full ID
|
|
233
|
-
ufoo bus send "claude-code:abc123" "message content"
|
|
234
|
-
|
|
235
|
-
# Send to specific Agent by nickname (NEW!)
|
|
236
|
-
ufoo bus send "architect" "message content"
|
|
237
|
-
ufoo bus send "backend-dev" "message content"
|
|
238
|
-
|
|
239
|
-
# Send to all Agents of same type
|
|
240
|
-
ufoo bus send "codex" "message content"
|
|
241
|
-
|
|
242
|
-
# Broadcast to everyone
|
|
243
|
-
ufoo bus broadcast "message content"
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
**Target Resolution Priority:**
|
|
247
|
-
1. Exact subscriber ID (e.g., `claude-code:abc123`)
|
|
248
|
-
2. Nickname match (e.g., `architect` → resolves to subscriber ID)
|
|
249
|
-
3. Agent type (e.g., `codex` → all codex agents)
|
|
250
|
-
4. Wildcard (`*` → all agents)
|