u-foo 1.0.3 → 1.0.6
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/README.md +67 -8
- package/README.zh-CN.md +9 -7
- package/SKILLS/ufoo/SKILL.md +117 -0
- package/SKILLS/uinit/SKILL.md +73 -0
- package/SKILLS/ustatus/SKILL.md +36 -0
- package/bin/uclaude.js +13 -0
- package/bin/ucodex.js +13 -0
- package/bin/ufoo +9 -31
- package/bin/ufoo.js +13 -0
- package/modules/AGENTS.template.md +15 -7
- package/modules/bus/README.md +28 -23
- package/modules/bus/SKILLS/ubus/SKILL.md +18 -8
- package/modules/context/README.md +18 -40
- package/modules/context/SKILLS/uctx/SKILL.md +61 -1
- package/package.json +16 -4
- package/scripts/.archived/bash-to-js-migration/README.md +46 -0
- package/scripts/.archived/bash-to-js-migration/banner.sh +89 -0
- package/scripts/{bus-inject.sh → .archived/bash-to-js-migration/bus-inject.sh} +35 -3
- package/scripts/{bus.sh → .archived/bash-to-js-migration/bus.sh} +3 -1
- package/scripts/banner.sh +2 -89
- package/scripts/postinstall.js +59 -0
- package/src/agent/cliRunner.js +33 -5
- package/src/agent/internalRunner.js +78 -51
- package/src/agent/launcher.js +702 -0
- package/src/agent/notifier.js +200 -0
- package/src/agent/ptyRunner.js +377 -0
- package/src/agent/ptyWrapper.js +354 -0
- package/src/agent/readyDetector.js +159 -0
- package/src/agent/ufooAgent.js +37 -42
- package/src/bus/API_DESIGN.md +204 -0
- package/src/bus/activate.js +156 -0
- package/src/bus/daemon.js +308 -0
- package/src/bus/index.js +785 -0
- package/src/bus/inject.js +285 -0
- package/src/bus/message.js +302 -0
- package/src/bus/nickname.js +86 -0
- package/src/bus/queue.js +131 -0
- package/src/bus/shake.js +26 -0
- package/src/bus/subscriber.js +296 -0
- package/src/bus/utils.js +357 -0
- package/src/chat/index.js +1842 -249
- package/src/cli.js +658 -95
- package/src/config.js +9 -2
- package/src/context/decisions.js +314 -0
- package/src/context/doctor.js +183 -0
- package/src/context/index.js +38 -0
- package/src/daemon/index.js +749 -94
- package/src/daemon/ops.js +395 -51
- package/src/daemon/providerSessions.js +291 -0
- package/src/daemon/run.js +34 -1
- package/src/daemon/status.js +24 -7
- package/src/doctor/index.js +50 -0
- package/src/init/index.js +264 -0
- package/src/skills/index.js +159 -0
- package/src/status/index.js +252 -0
- package/src/terminal/detect.js +64 -0
- package/src/terminal/index.js +8 -0
- package/src/terminal/iterm2.js +126 -0
- package/src/ufoo/agentsStore.js +41 -0
- package/src/ufoo/paths.js +46 -0
- package/src/utils/banner.js +73 -0
- package/bin/uclaude +0 -65
- package/bin/ucodex +0 -65
- package/modules/bus/scripts/bus-alert.sh +0 -185
- package/modules/bus/scripts/bus-listen.sh +0 -117
- package/modules/context/ASSUMPTIONS.md +0 -7
- package/modules/context/CONSTRAINTS.md +0 -7
- package/modules/context/CONTEXT-STRUCTURE.md +0 -49
- package/modules/context/DECISION-PROTOCOL.md +0 -62
- package/modules/context/HANDOFF.md +0 -33
- package/modules/context/RULES.md +0 -15
- package/modules/context/SKILLS/README.md +0 -14
- package/modules/context/SYSTEM.md +0 -18
- package/modules/context/TEMPLATES/assumptions.md +0 -4
- package/modules/context/TEMPLATES/constraints.md +0 -4
- package/modules/context/TEMPLATES/decision.md +0 -16
- package/modules/context/TEMPLATES/project-context-readme.md +0 -6
- package/modules/context/TEMPLATES/system.md +0 -3
- package/modules/context/TEMPLATES/terminology.md +0 -4
- package/modules/context/TERMINOLOGY.md +0 -10
- /package/scripts/{bus-alert.sh → .archived/bash-to-js-migration/bus-alert.sh} +0 -0
- /package/scripts/{bus-autotrigger.sh → .archived/bash-to-js-migration/bus-autotrigger.sh} +0 -0
- /package/scripts/{bus-daemon.sh → .archived/bash-to-js-migration/bus-daemon.sh} +0 -0
- /package/scripts/{bus-listen.sh → .archived/bash-to-js-migration/bus-listen.sh} +0 -0
- /package/scripts/{context-decisions.sh → .archived/bash-to-js-migration/context-decisions.sh} +0 -0
- /package/scripts/{context-doctor.sh → .archived/bash-to-js-migration/context-doctor.sh} +0 -0
- /package/scripts/{context-lint.sh → .archived/bash-to-js-migration/context-lint.sh} +0 -0
- /package/scripts/{doctor.sh → .archived/bash-to-js-migration/doctor.sh} +0 -0
- /package/scripts/{init.sh → .archived/bash-to-js-migration/init.sh} +0 -0
- /package/scripts/{skills.sh → .archived/bash-to-js-migration/skills.sh} +0 -0
- /package/scripts/{status.sh → .archived/bash-to-js-migration/status.sh} +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Event Bus JavaScript API 设计
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
已将 Event Bus 从 bash 迁移到 JavaScript,核心能力由 `src/bus` 模块提供。
|
|
6
|
+
|
|
7
|
+
## 核心类设计
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
class EventBus {
|
|
11
|
+
constructor(projectRoot);
|
|
12
|
+
|
|
13
|
+
// 初始化
|
|
14
|
+
async init();
|
|
15
|
+
|
|
16
|
+
// 订阅者管理
|
|
17
|
+
async join(sessionId, agentType, nickname);
|
|
18
|
+
async leave(subscriber);
|
|
19
|
+
async rename(subscriber, nickname);
|
|
20
|
+
async whoami(); // 获取当前订阅者 ID
|
|
21
|
+
|
|
22
|
+
// 消息发送
|
|
23
|
+
async send(target, message, publisher);
|
|
24
|
+
async broadcast(message, publisher);
|
|
25
|
+
|
|
26
|
+
// 消息接收
|
|
27
|
+
async check(subscriber, autoAck);
|
|
28
|
+
async ack(subscriber);
|
|
29
|
+
async consume(subscriber, fromBeginning);
|
|
30
|
+
|
|
31
|
+
// 查询与路由
|
|
32
|
+
async status();
|
|
33
|
+
async resolve(myId, targetType);
|
|
34
|
+
|
|
35
|
+
// 后台监听
|
|
36
|
+
async alert(subscriber, interval, options);
|
|
37
|
+
async listen(subscriber, options);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 文件结构
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
src/bus/
|
|
45
|
+
├── index.js # EventBus 主类
|
|
46
|
+
├── subscriber.js # 订阅者管理
|
|
47
|
+
├── message.js # 消息发送/接收
|
|
48
|
+
├── queue.js # 队列管理(offset, pending)
|
|
49
|
+
├── nickname.js # 昵称解析
|
|
50
|
+
├── daemon.js # bus daemon(自动注入 /ubus)
|
|
51
|
+
├── utils.js # 工具函数
|
|
52
|
+
└── API_DESIGN.md # 本文件
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 数据结构
|
|
56
|
+
|
|
57
|
+
### .ufoo/agent/all-agents.json
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"schema_version": 1,
|
|
61
|
+
"created_at": "2026-01-29T...",
|
|
62
|
+
"agents": {
|
|
63
|
+
"claude-code:abc123": {
|
|
64
|
+
"agent_type": "claude-code",
|
|
65
|
+
"nickname": "architect",
|
|
66
|
+
"status": "active",
|
|
67
|
+
"joined_at": "...",
|
|
68
|
+
"last_seen": "...",
|
|
69
|
+
"pid": 12345,
|
|
70
|
+
"tty": "/dev/ttys001"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### events/YYYY-MM-DD.jsonl
|
|
77
|
+
```json
|
|
78
|
+
{"seq":1,"timestamp":"...","type":"message/targeted","event":"message","publisher":"...","target":"...","data":{...}}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### queues/{subscriber}/pending.jsonl
|
|
82
|
+
```json
|
|
83
|
+
{"seq":1,"timestamp":"...","type":"message/targeted","event":"message","publisher":"...","data":{...}}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### queues/{subscriber}/offset
|
|
87
|
+
```
|
|
88
|
+
5
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 关键功能实现
|
|
92
|
+
|
|
93
|
+
### 1. 消息路由(支持昵称)
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
async resolveTarget(target) {
|
|
97
|
+
// 优先级:
|
|
98
|
+
// 1. 精确订阅者 ID (claude-code:abc123)
|
|
99
|
+
// 2. 昵称匹配 (architect -> claude-code:abc123)
|
|
100
|
+
// 3. 代理类型 (codex -> 所有 codex 代理)
|
|
101
|
+
// 4. 通配符 (* -> 所有代理)
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 2. 队列管理
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
class QueueManager {
|
|
109
|
+
async getOffset(subscriber);
|
|
110
|
+
async setOffset(subscriber, seq);
|
|
111
|
+
async appendPending(subscriber, event);
|
|
112
|
+
async readPending(subscriber);
|
|
113
|
+
async clearPending(subscriber);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 3. 序号生成(全局唯一)
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
async getNextSeq() {
|
|
121
|
+
// 读取所有 events/*.jsonl 文件的最后一行
|
|
122
|
+
// 返回 max(seq) + 1
|
|
123
|
+
// 保证全局唯一、单调递增
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 4. 昵称冲突检测
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
async ensureUniqueNickname(nickname, excludeSubscriber) {
|
|
131
|
+
// 检查 all-agents.json 中是否已存在该昵称
|
|
132
|
+
// 返回是否唯一
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 向后兼容性
|
|
137
|
+
|
|
138
|
+
### CLI 接口保持不变
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
ufoo bus init
|
|
142
|
+
ufoo bus join [session] [type] [nick]
|
|
143
|
+
ufoo bus send <target> <message>
|
|
144
|
+
# ... 所有命令保持原样
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 环境变量支持
|
|
148
|
+
|
|
149
|
+
- `AI_BUS_PUBLISHER` - 发送者 ID
|
|
150
|
+
- `CLAUDE_SESSION_ID` / `CODEX_SESSION_ID` - 会话 ID
|
|
151
|
+
- `UFOO_NICKNAME` - 昵称
|
|
152
|
+
|
|
153
|
+
## 错误处理
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
class BusError extends Error {
|
|
157
|
+
constructor(code, message) {
|
|
158
|
+
super(message);
|
|
159
|
+
this.code = code;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 错误码:
|
|
164
|
+
// BUS_NOT_INITIALIZED
|
|
165
|
+
// SUBSCRIBER_NOT_FOUND
|
|
166
|
+
// NICKNAME_CONFLICT
|
|
167
|
+
// INVALID_TARGET
|
|
168
|
+
// ...
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 测试策略
|
|
172
|
+
|
|
173
|
+
### 单元测试
|
|
174
|
+
- 每个模块独立测试
|
|
175
|
+
- Mock 文件系统操作
|
|
176
|
+
|
|
177
|
+
### 集成测试
|
|
178
|
+
- 完整消息流测试(send -> check -> ack)
|
|
179
|
+
- 多订阅者场景
|
|
180
|
+
- 昵称冲突处理
|
|
181
|
+
|
|
182
|
+
### 性能测试
|
|
183
|
+
- 消息发送延迟 < 50ms
|
|
184
|
+
- 序号生成 < 10ms
|
|
185
|
+
- 状态查询 < 100ms
|
|
186
|
+
|
|
187
|
+
## 迁移检查清单
|
|
188
|
+
|
|
189
|
+
- [ ] init 命令
|
|
190
|
+
- [ ] join/leave 命令
|
|
191
|
+
- [ ] send/broadcast 命令
|
|
192
|
+
- [ ] check/ack 命令
|
|
193
|
+
- [ ] status 命令
|
|
194
|
+
- [ ] resolve 命令
|
|
195
|
+
- [ ] rename 命令(支持昵称)
|
|
196
|
+
- [ ] consume 命令
|
|
197
|
+
- [ ] alert 后台监听
|
|
198
|
+
- [ ] listen 前台监听
|
|
199
|
+
- [ ] autotrigger 自动触发
|
|
200
|
+
- [ ] 昵称解析(send 支持昵称)
|
|
201
|
+
- [ ] 全局序号唯一性
|
|
202
|
+
- [ ] 文件并发安全
|
|
203
|
+
- [ ] 错误处理和日志
|
|
204
|
+
- [ ] CLI 向后兼容
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { getUfooPaths } = require("../ufoo/paths");
|
|
3
|
+
const { spawn, spawnSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 激活指定 agent 的终端
|
|
7
|
+
*
|
|
8
|
+
* 支持的方式:
|
|
9
|
+
* - tmux: 使用 tmux select-pane 激活
|
|
10
|
+
* - terminal: 使用 AppleScript 通过 tty 定位并激活 Terminal.app 的 tab/window
|
|
11
|
+
* - internal: 不支持自动激活(由 chat PTY view 处理)
|
|
12
|
+
*/
|
|
13
|
+
class AgentActivator {
|
|
14
|
+
constructor(projectRoot) {
|
|
15
|
+
this.projectRoot = projectRoot;
|
|
16
|
+
const paths = getUfooPaths(projectRoot);
|
|
17
|
+
this.agentsFile = paths.agentsFile;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 获取 agent 信息
|
|
22
|
+
*/
|
|
23
|
+
getAgentInfo(agentId) {
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.existsSync(this.agentsFile)) {
|
|
26
|
+
throw new Error("Bus not initialized");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const busData = JSON.parse(fs.readFileSync(this.agentsFile, "utf8"));
|
|
30
|
+
const meta = busData.agents?.[agentId];
|
|
31
|
+
|
|
32
|
+
if (!meta) {
|
|
33
|
+
throw new Error(`Agent not found: ${agentId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
id: agentId,
|
|
38
|
+
nickname: meta.nickname || "",
|
|
39
|
+
tty: meta.tty || "",
|
|
40
|
+
tmux_pane: meta.tmux_pane || "",
|
|
41
|
+
launch_mode: meta.launch_mode || "",
|
|
42
|
+
};
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(`Failed to get agent info: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 激活 tmux pane
|
|
50
|
+
*/
|
|
51
|
+
activateTmuxPane(paneId) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
// 首先检查 pane 是否存在
|
|
54
|
+
const checkProc = spawn("tmux", ["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
55
|
+
let output = "";
|
|
56
|
+
|
|
57
|
+
checkProc.stdout.on("data", (data) => {
|
|
58
|
+
output += data.toString();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
checkProc.on("close", (code) => {
|
|
62
|
+
if (code !== 0) {
|
|
63
|
+
reject(new Error("tmux is not running"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const panes = output.trim().split("\n");
|
|
68
|
+
if (!panes.includes(paneId)) {
|
|
69
|
+
reject(new Error(`tmux pane not found: ${paneId}`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 激活 pane(选择 window 和 pane)
|
|
74
|
+
const selectProc = spawn("tmux", ["select-pane", "-t", paneId]);
|
|
75
|
+
|
|
76
|
+
selectProc.on("close", (selectCode) => {
|
|
77
|
+
if (selectCode === 0) {
|
|
78
|
+
resolve();
|
|
79
|
+
} else {
|
|
80
|
+
reject(new Error("Failed to select tmux pane"));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
selectProc.on("error", reject);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
checkProc.on("error", reject);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 通过 tty 激活 Terminal.app 中对应的 tab/window
|
|
93
|
+
*/
|
|
94
|
+
activateTerminalByTty(ttyPath) {
|
|
95
|
+
if (process.platform !== "darwin") {
|
|
96
|
+
throw new Error("Terminal activation is only supported on macOS");
|
|
97
|
+
}
|
|
98
|
+
if (!ttyPath) {
|
|
99
|
+
throw new Error("Cannot activate: tty path required");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const script = `
|
|
103
|
+
tell application "Terminal"
|
|
104
|
+
repeat with w in windows
|
|
105
|
+
repeat with t in tabs of w
|
|
106
|
+
if tty of t is "${ttyPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" then
|
|
107
|
+
set selected tab of w to t
|
|
108
|
+
set index of w to 1
|
|
109
|
+
activate
|
|
110
|
+
return "ok"
|
|
111
|
+
end if
|
|
112
|
+
end repeat
|
|
113
|
+
end repeat
|
|
114
|
+
return "not found"
|
|
115
|
+
end tell`;
|
|
116
|
+
|
|
117
|
+
const result = spawnSync("osascript", ["-e", script], {
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result.status !== 0) {
|
|
123
|
+
throw new Error(`AppleScript failed: ${(result.stderr || "").trim()}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const output = (result.stdout || "").trim();
|
|
127
|
+
if (output === "not found") {
|
|
128
|
+
throw new Error(`Terminal tab not found for tty: ${ttyPath}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 激活 agent 的终端
|
|
134
|
+
*/
|
|
135
|
+
async activate(agentId) {
|
|
136
|
+
const info = this.getAgentInfo(agentId);
|
|
137
|
+
|
|
138
|
+
if (info.launch_mode === "internal" || info.launch_mode === "internal-pty") {
|
|
139
|
+
throw new Error("Internal mode agents cannot be activated (no terminal window)");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (info.launch_mode === "tmux" && info.tmux_pane) {
|
|
143
|
+
await this.activateTmuxPane(info.tmux_pane);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (info.launch_mode === "terminal" && info.tty) {
|
|
148
|
+
this.activateTerminalByTty(info.tty);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new Error("Cannot activate: missing tty or tmux_pane for agent");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = AgentActivator;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { readJSON, writeJSON, isPidAlive, isAgentPidAlive, ensureDir, safeNameToSubscriber, subscriberToSafeName } = require("./utils");
|
|
4
|
+
const Injector = require("./inject");
|
|
5
|
+
const QueueManager = require("./queue");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bus Daemon - 监控消息并自动注入命令
|
|
9
|
+
*/
|
|
10
|
+
class BusDaemon {
|
|
11
|
+
constructor(busDir, agentsFile, daemonDir, interval = 2000) {
|
|
12
|
+
this.busDir = busDir;
|
|
13
|
+
this.agentsFile = agentsFile;
|
|
14
|
+
this.interval = interval;
|
|
15
|
+
this.daemonDir = daemonDir;
|
|
16
|
+
this.pidFile = path.join(this.daemonDir, "daemon.pid");
|
|
17
|
+
this.logFile = path.join(this.daemonDir, "daemon.log");
|
|
18
|
+
this.countsDir = path.join(this.daemonDir, "counts", `${process.pid}`);
|
|
19
|
+
this.running = false;
|
|
20
|
+
this.cleanupCounter = 0;
|
|
21
|
+
this.cleanupInterval = 5; // 每 5 个周期清理一次
|
|
22
|
+
|
|
23
|
+
this.queueManager = new QueueManager(busDir);
|
|
24
|
+
this.injector = new Injector(busDir, agentsFile);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 检查 daemon 是否正在运行
|
|
29
|
+
*/
|
|
30
|
+
isRunning() {
|
|
31
|
+
if (!fs.existsSync(this.pidFile)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pid = parseInt(fs.readFileSync(this.pidFile, "utf8").trim(), 10);
|
|
36
|
+
return isPidAlive(pid);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 获取运行中的 daemon PID
|
|
41
|
+
*/
|
|
42
|
+
getRunningPid() {
|
|
43
|
+
if (!fs.existsSync(this.pidFile)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pid = parseInt(fs.readFileSync(this.pidFile, "utf8").trim(), 10);
|
|
48
|
+
return isPidAlive(pid) ? pid : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 启动 daemon
|
|
53
|
+
*/
|
|
54
|
+
async start(background = false) {
|
|
55
|
+
// 检查是否已经在运行
|
|
56
|
+
if (this.isRunning()) {
|
|
57
|
+
const pid = this.getRunningPid();
|
|
58
|
+
console.log(`[daemon] Already running (pid=${pid})`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
ensureDir(this.daemonDir);
|
|
62
|
+
ensureDir(path.join(this.daemonDir, "counts"));
|
|
63
|
+
|
|
64
|
+
if (background) {
|
|
65
|
+
// 后台模式:spawn 独立进程
|
|
66
|
+
const { spawn } = require("child_process");
|
|
67
|
+
const logStream = fs.openSync(this.logFile, "a");
|
|
68
|
+
|
|
69
|
+
const child = spawn(
|
|
70
|
+
process.execPath,
|
|
71
|
+
[
|
|
72
|
+
path.join(__dirname, "..", "..", "bin", "ufoo.js"),
|
|
73
|
+
"bus",
|
|
74
|
+
"daemon",
|
|
75
|
+
"--interval",
|
|
76
|
+
String(this.interval / 1000),
|
|
77
|
+
],
|
|
78
|
+
{
|
|
79
|
+
detached: true,
|
|
80
|
+
stdio: ["ignore", logStream, logStream],
|
|
81
|
+
cwd: process.cwd(),
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
child.unref();
|
|
86
|
+
|
|
87
|
+
// 等待 PID 文件创建
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
89
|
+
|
|
90
|
+
const pid = this.getRunningPid();
|
|
91
|
+
console.log(`[daemon] Started in background (pid=${pid}, log: ${this.logFile})`);
|
|
92
|
+
} else {
|
|
93
|
+
// 前台模式
|
|
94
|
+
this.run();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 停止 daemon
|
|
100
|
+
*/
|
|
101
|
+
stop() {
|
|
102
|
+
const pid = this.getRunningPid();
|
|
103
|
+
if (!pid) {
|
|
104
|
+
console.log("[daemon] Not running");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, "SIGTERM");
|
|
110
|
+
console.log(`[daemon] Stopped (pid=${pid})`);
|
|
111
|
+
if (fs.existsSync(this.pidFile)) {
|
|
112
|
+
fs.unlinkSync(this.pidFile);
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[daemon] Failed to stop: ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 显示 daemon 状态
|
|
121
|
+
*/
|
|
122
|
+
status() {
|
|
123
|
+
const pid = this.getRunningPid();
|
|
124
|
+
if (pid) {
|
|
125
|
+
console.log(`[daemon] Running (pid=${pid})`);
|
|
126
|
+
} else {
|
|
127
|
+
console.log("[daemon] Not running");
|
|
128
|
+
// 清理过时的 PID 文件
|
|
129
|
+
if (fs.existsSync(this.pidFile)) {
|
|
130
|
+
fs.unlinkSync(this.pidFile);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 运行 daemon(前台)
|
|
137
|
+
*/
|
|
138
|
+
run() {
|
|
139
|
+
// 记录 PID
|
|
140
|
+
ensureDir(path.dirname(this.pidFile));
|
|
141
|
+
fs.writeFileSync(this.pidFile, `${process.pid}\n`, "utf8");
|
|
142
|
+
|
|
143
|
+
// 设置清理钩子
|
|
144
|
+
const cleanup = () => {
|
|
145
|
+
this.running = false;
|
|
146
|
+
if (fs.existsSync(this.pidFile)) {
|
|
147
|
+
fs.unlinkSync(this.pidFile);
|
|
148
|
+
}
|
|
149
|
+
if (fs.existsSync(this.countsDir)) {
|
|
150
|
+
fs.rmSync(this.countsDir, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
process.on("SIGTERM", cleanup);
|
|
155
|
+
process.on("SIGINT", cleanup);
|
|
156
|
+
process.on("exit", cleanup);
|
|
157
|
+
|
|
158
|
+
// 创建计数目录
|
|
159
|
+
ensureDir(this.countsDir);
|
|
160
|
+
|
|
161
|
+
console.log(`[daemon] Started (pid=${process.pid}, interval=${this.interval / 1000}s)`);
|
|
162
|
+
console.log(`[daemon] Watching: ${this.busDir}/queues/*/pending.jsonl`);
|
|
163
|
+
|
|
164
|
+
this.running = true;
|
|
165
|
+
this.watchLoop();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 主监控循环
|
|
170
|
+
*/
|
|
171
|
+
async watchLoop() {
|
|
172
|
+
while (this.running) {
|
|
173
|
+
try {
|
|
174
|
+
// 定期清理死掉的 agent
|
|
175
|
+
this.cleanupCounter++;
|
|
176
|
+
if (this.cleanupCounter >= this.cleanupInterval) {
|
|
177
|
+
await this.cleanupDeadAgents();
|
|
178
|
+
this.cleanupCounter = 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 检查所有订阅者的队列
|
|
182
|
+
await this.checkQueues();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error(`[daemon] Error: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 等待下一个周期
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, this.interval));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 检查所有队列
|
|
194
|
+
*/
|
|
195
|
+
async checkQueues() {
|
|
196
|
+
const queuesDir = path.join(this.busDir, "queues");
|
|
197
|
+
if (!fs.existsSync(queuesDir)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const subscribers = fs.readdirSync(queuesDir);
|
|
202
|
+
|
|
203
|
+
for (const safeName of subscribers) {
|
|
204
|
+
const pendingFile = path.join(queuesDir, safeName, "pending.jsonl");
|
|
205
|
+
if (!fs.existsSync(pendingFile)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 获取当前消息数
|
|
210
|
+
let count = 0;
|
|
211
|
+
if (fs.statSync(pendingFile).size > 0) {
|
|
212
|
+
const content = fs.readFileSync(pendingFile, "utf8").trim();
|
|
213
|
+
count = content ? content.split("\n").length : 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 获取上次的消息数
|
|
217
|
+
const lastCount = this.getLastCount(safeName);
|
|
218
|
+
|
|
219
|
+
// 如果有新消息,注入命令
|
|
220
|
+
if (count > lastCount) {
|
|
221
|
+
const subscriber = safeNameToSubscriber(safeName);
|
|
222
|
+
const now = new Date().toISOString().split("T")[1].slice(0, 8);
|
|
223
|
+
console.log(`[daemon] ${now} New message for ${subscriber} (${lastCount} -> ${count})`);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await this.injector.inject(subscriber);
|
|
227
|
+
console.log(`[daemon] Injected /bus into ${subscriber}`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(`[daemon] Failed to inject: ${err.message}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 更新计数
|
|
234
|
+
this.setLastCount(safeName, count);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 获取上次的消息计数
|
|
240
|
+
*/
|
|
241
|
+
getLastCount(safeName) {
|
|
242
|
+
const countFile = path.join(this.countsDir, safeName);
|
|
243
|
+
if (!fs.existsSync(countFile)) {
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
const content = fs.readFileSync(countFile, "utf8").trim();
|
|
247
|
+
return parseInt(content, 10) || 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 设置消息计数
|
|
252
|
+
*/
|
|
253
|
+
setLastCount(safeName, count) {
|
|
254
|
+
const countFile = path.join(this.countsDir, safeName);
|
|
255
|
+
ensureDir(path.dirname(countFile));
|
|
256
|
+
fs.writeFileSync(countFile, `${count}\n`, "utf8");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 清理死掉的 agent
|
|
261
|
+
*/
|
|
262
|
+
async cleanupDeadAgents() {
|
|
263
|
+
const agentsFile = this.agentsFile;
|
|
264
|
+
if (!fs.existsSync(agentsFile)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const busData = readJSON(agentsFile);
|
|
269
|
+
if (!busData || !busData.agents) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let changed = false;
|
|
274
|
+
|
|
275
|
+
for (const [subscriber, meta] of Object.entries(busData.agents)) {
|
|
276
|
+
if (meta.status !== "active") {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 检查 PID 是否仍然存活
|
|
281
|
+
if (meta.pid && !isAgentPidAlive(meta.pid)) {
|
|
282
|
+
const now = new Date().toISOString().split("T")[1].slice(0, 8);
|
|
283
|
+
console.log(`[daemon] ${now} Agent ${subscriber} (pid=${meta.pid}) is dead, marking inactive`);
|
|
284
|
+
|
|
285
|
+
meta.status = "inactive";
|
|
286
|
+
changed = true;
|
|
287
|
+
|
|
288
|
+
// 清理队列目录和 offset
|
|
289
|
+
const safeName = subscriberToSafeName(subscriber);
|
|
290
|
+
const queueDir = path.join(this.busDir, "queues", safeName);
|
|
291
|
+
const offsetFile = path.join(this.busDir, "offsets", `${safeName}.offset`);
|
|
292
|
+
|
|
293
|
+
if (fs.existsSync(queueDir)) {
|
|
294
|
+
fs.rmSync(queueDir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
if (fs.existsSync(offsetFile)) {
|
|
297
|
+
fs.unlinkSync(offsetFile);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (changed) {
|
|
303
|
+
writeJSON(agentsFile, busData);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = BusDaemon;
|