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.
@@ -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.13",
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
- const positionals = cmdArgs.filter((arg) => typeof arg === "string" && !arg.startsWith("--"));
44
- const subscriber = String(positionals[0] || env.UFOO_SUBSCRIBER_ID || "").trim();
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: cmdArgs.includes("--ack") || cmdArgs.includes("--auto-ack"),
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
- await eventBus.check(parsed.subscriber, parsed.autoAck);
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
- await eventBus.ack(cmdArgs[0]);
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 = { runBusCoreCommand, resolvePollSubscriber };
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 this.skillRoots) {
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;
@@ -1102,11 +1102,12 @@ async function runCli(argv) {
1102
1102
  skills
1103
1103
  .command("list")
1104
1104
  .description("List available skills")
1105
- .action(() => {
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 { DeliveryQueue, stripQueueEnvelope } = require("./deliveryQueue");
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
  */