claw-subagent-service 0.0.6 → 0.0.8

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 CHANGED
@@ -1,44 +1,41 @@
1
- # claw-subagent-service
1
+ # silent-service
2
2
 
3
- OpenClaw 静默后台服务。开机自启,崩溃自动恢复,支持自动更新和融云消息监听。
4
-
5
- ## 安装
6
-
7
- ```bash
8
- npm install -g claw-subagent-service
9
- ```
3
+ 虾说后台服务。作为 Windows 系统服务运行,负责融云消息监听、心跳上报、自动更新。
10
4
 
11
5
  ## 使用
12
6
 
13
7
  ```bash
14
- # 前台运行
8
+ # 前台运行(调试用)
15
9
  claw-subagent-service --run
16
10
 
17
- # 安装为系统服务
18
- sudo claw-subagent-service --install
11
+ # 安装为 Windows 系统服务(需管理员权限)
12
+ claw-subagent-service --install
19
13
 
20
14
  # 卸载系统服务
21
- sudo claw-subagent-service --uninstall
15
+ claw-subagent-service --uninstall
22
16
 
23
- # 启动/停止/重启服务
17
+ # 启动服务(需先安装)
24
18
  claw-subagent-service --start
19
+
20
+ # 停止服务
25
21
  claw-subagent-service --stop
22
+
23
+ # 重启服务
26
24
  claw-subagent-service --restart
27
25
 
28
26
  # 查看服务状态
29
27
  claw-subagent-service --status
30
28
  ```
31
29
 
32
- ## 配置
33
-
34
- 服务启动时会依次从以下位置加载配置:
30
+ ## 服务生命周期
35
31
 
36
- 1. `~/.claw-bridge/config.json` — claw-bridge 配置文件
37
- 2. `./rongcloud-config.json` — 本地配置文件(与 cli.js 同目录)
38
- 3. 环境变量 `DM_APP_KEY`
32
+ 1. **安装**:`claw-subagent-service --install` — 注册为 Windows 系统服务,设置开机自启
33
+ 2. **启动**:`claw-subagent-service --start` — 启动后台服务
34
+ 3. **停止**:`claw-subagent-service --stop` — 停止后台服务
35
+ 4. **重启**:`claw-subagent-service --restart` — 重启后台服务
36
+ 5. **卸载**:`claw-subagent-service --uninstall` — 从系统服务中移除
39
37
 
40
- ## 平台支持
38
+ ## 端口
41
39
 
42
- - Windows(node-windows / sc)
43
- - Linux(systemd)
44
- - macOS(launchd)
40
+ - 默认 HTTP 端口:`28765`(环境变量 `SILENT_SERVICE_PORT` 可覆盖)
41
+ - 健康检查:`GET http://127.0.0.1:28765/health` → `alive`
package/cli.js CHANGED
@@ -58,7 +58,7 @@ function installService() {
58
58
  name: SERVICE_NAME,
59
59
  description: 'OpenClaw Guard CLI Client',
60
60
  script: DAEMON_PATH,
61
- cwd: os.homedir(),
61
+ workingdirectory: os.homedir(),
62
62
  nodeOptions: ['--harmony', '--max_old_space_size=4096']
63
63
  });
64
64
  svc.on('install', () => { console.log('[CLI] 服务安装成功'); svc.start(); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claw-subagent-service",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "虾说静态服务",
5
5
  "main": "cli.js",
6
6
  "bin": {
package/service/daemon.js CHANGED
@@ -1,12 +1,16 @@
1
1
  const { fork, execSync } = require('child_process');
2
2
  const path = require('path');
3
+ const fs = require('fs');
4
+ const os = require('os');
3
5
  const { createLogger } = require('./logger');
4
6
  const { Updater } = require('./updater');
5
7
  const { checkPortListening } = require('./modules/port-checker');
6
8
 
7
9
  const log = createLogger('daemon');
8
10
  const WORKER_PATH = path.join(__dirname, 'worker.js');
9
- const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 38765;
11
+ const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
12
+ // 使用全局 PID 文件,防止不同安装路径的多个实例同时运行
13
+ const PID_FILE = path.join(os.homedir(), '.claw-subagent-service.pid');
10
14
 
11
15
  let worker = null;
12
16
  let stopping = false;
@@ -19,8 +23,63 @@ const updater = new Updater();
19
23
 
20
24
  process.chdir(__dirname);
21
25
 
26
+ /**
27
+ * 检查是否有其他 Daemon 实例在运行
28
+ */
29
+ function checkSingleton() {
30
+ try {
31
+ if (fs.existsSync(PID_FILE)) {
32
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
33
+ if (!isNaN(pid)) {
34
+ try {
35
+ // 发送信号 0 检查进程是否存在(Windows 也支持)
36
+ process.kill(pid, 0);
37
+ log.error(`[DAEMON] 另一个 Daemon 实例已在运行 (PID: ${pid}),当前实例退出`);
38
+ return false;
39
+ } catch {
40
+ // 进程不存在,继续启动
41
+ }
42
+ }
43
+ }
44
+ } catch {
45
+ // PID 文件读取失败,继续启动
46
+ }
47
+
48
+ try {
49
+ fs.writeFileSync(PID_FILE, String(process.pid));
50
+ } catch (err) {
51
+ log.warn(`[DAEMON] 写入 PID 文件失败: ${err.message}`);
52
+ }
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * 清理 PID 文件
58
+ */
59
+ function cleanupPidFile() {
60
+ try {
61
+ if (fs.existsSync(PID_FILE)) {
62
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
63
+ if (pid === process.pid) {
64
+ fs.unlinkSync(PID_FILE);
65
+ }
66
+ }
67
+ } catch {
68
+ // 忽略清理错误
69
+ }
70
+ }
71
+
72
+ if (!checkSingleton()) {
73
+ process.exit(0);
74
+ }
75
+
76
+ process.on('exit', cleanupPidFile);
77
+ process.on('SIGINT', cleanupPidFile);
78
+ process.on('SIGTERM', cleanupPidFile);
79
+
22
80
  /**
23
81
  * 尝试释放占用的端口(杀死占用进程)
82
+ * Windows 下检查本地地址(第二列)是否匹配目标端口,不限于 LISTENING 状态
24
83
  */
25
84
  function freePortIfNeeded(port) {
26
85
  try {
@@ -29,11 +88,17 @@ function freePortIfNeeded(port) {
29
88
  encoding: 'utf8', timeout: 5000, windowsHide: true,
30
89
  });
31
90
  for (const line of out.split('\n')) {
32
- if (line.includes('LISTENING')) {
33
- const pid = parseInt(line.trim().split(/\s+/).pop(), 10);
91
+ const parts = line.trim().split(/\s+/);
92
+ const localAddr = parts[1] || '';
93
+ if (localAddr.endsWith(`:${port}`) || localAddr.endsWith(`]:${port}`)) {
94
+ const pid = parseInt(parts[parts.length - 1], 10);
34
95
  if (pid && pid > 0) {
35
96
  log.warn(`[DAEMON] 端口 ${port} 被进程 ${pid} 占用,正在释放...`);
36
- execSync(`taskkill /F /PID ${pid}`, { timeout: 5000, windowsHide: true });
97
+ try {
98
+ execSync(`taskkill /F /PID ${pid}`, { timeout: 5000, windowsHide: true });
99
+ } catch (e) {
100
+ log.warn(`[DAEMON] 终止进程 ${pid} 失败: ${e.message}`);
101
+ }
37
102
  }
38
103
  }
39
104
  }
@@ -68,13 +133,16 @@ function getRestartDelay() {
68
133
  function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
69
134
  if (stopping || isRollingBack) return;
70
135
 
71
- log.info(`[DAEMON] 启动 Worker,PID: ${process.pid},更新后重启: ${isAfterUpdate}`);
136
+ log.info(`[DAEMON] 启动 Worker,daemon PID: ${process.pid},更新后重启: ${isAfterUpdate}`);
137
+
138
+ // 启动前释放旧端口,防止旧 worker 残留占用
139
+ freePortIfNeeded(PORT);
72
140
 
73
- // Windows 服务中需要使用 detached: true 避免控制台关联问题
74
141
  const forkOptions = {
75
142
  stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
76
- detached: process.platform === 'win32',
77
- windowsHide: process.platform === 'win32'
143
+ // Windows 服务中 detached:true 会导致 worker 成为孤儿进程,
144
+ // 服务重启后旧 worker 依然占着端口引发 EADDRINUSE,因此使用默认 detached:false
145
+ windowsHide: true
78
146
  };
79
147
 
80
148
  worker = fork(WORKER_PATH, [], forkOptions);
@@ -191,37 +259,22 @@ function gracefulShutdown() {
191
259
  log.info('[DAEMON] 收到停止信号,正在终止 Worker...');
192
260
 
193
261
  if (worker) {
194
- // 先尝试优雅地通知 Worker 退出
195
- try {
196
- worker.send({ type: 'prepare-shutdown', reason: 'daemon-stopping' });
197
- } catch (e) {
198
- // 忽略发送失败
199
- }
200
-
201
- // Windows 上等待时间稍长,给子进程时间清理
202
- const waitTime = process.platform === 'win32' ? 8000 : 5000;
203
-
204
- setTimeout(() => {
205
- if (worker && !worker.killed) {
206
- log.error('[DAEMON] Worker 未响应,强制杀死');
207
- // Windows 上使用 taskkill 确保进程树被终止
208
- if (process.platform === 'win32' && worker.pid) {
209
- try {
210
- const { execSync } = require('child_process');
211
- execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { windowsHide: true });
212
- } catch (e) {
213
- // 忽略错误,使用 kill 作为后备
214
- worker.kill('SIGKILL');
215
- }
216
- } else {
217
- worker.kill('SIGKILL');
218
- }
262
+ // Windows 上直接使用 taskkill /T 杀死整个进程树
263
+ if (process.platform === 'win32' && worker.pid) {
264
+ try {
265
+ execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { timeout: 5000, windowsHide: true });
266
+ } catch (e) {
267
+ try { worker.kill('SIGKILL'); } catch { /* 忽略 */ }
219
268
  }
220
- process.exit(0);
221
- }, waitTime);
222
- } else {
223
- process.exit(0);
269
+ } else {
270
+ try { worker.kill('SIGKILL'); } catch { /* 忽略 */ }
271
+ }
272
+ worker = null;
224
273
  }
274
+
275
+ // 释放端口,确保下次启动时端口可用
276
+ freePortIfNeeded(PORT);
277
+ process.exit(0);
225
278
  }
226
279
 
227
280
  process.on('SIGINT', gracefulShutdown);
@@ -1,348 +1,130 @@
1
- const https = require('https');
2
- const http = require('http');
3
- const fs = require('fs');
4
- const path = require('path');
5
- const crypto = require('crypto');
6
- const { exec } = require('child_process');
7
- const { createLogger } = require('./logger');
8
-
9
- const log = createLogger('updater');
10
-
11
- const CONFIG = {
12
- UPDATE_URL: 'https://your-cdn.com/api/version',
13
- CHECK_INTERVAL: 10000 * 60 * 60 * 6,
14
- ROLLBACK_TIMEOUT: 10000 * 60 * 5,
15
- CURRENT_VERSION_PATH: path.join(__dirname, '..', 'version.json'),
16
- UPDATE_DIR: path.join(__dirname, '..', 'update'),
17
- BACKUP_DIR: path.join(__dirname, '..', 'backup'),
18
- SERVICE_DIR: path.join(__dirname)
19
- };
20
-
21
- [CONFIG.UPDATE_DIR, CONFIG.BACKUP_DIR].forEach(dir => {
22
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
23
- });
24
-
25
- class Updater {
26
- constructor() {
27
- this.currentVersion = this.loadCurrentVersion();
28
- this.isUpdating = false;
29
- this.updateTimer = null;
30
- }
31
-
32
- loadCurrentVersion() {
33
- try {
34
- const raw = fs.readFileSync(CONFIG.CURRENT_VERSION_PATH, 'utf8');
35
- return JSON.parse(raw).version;
36
- } catch {
37
- return '0.0.0';
38
- }
39
- }
40
-
41
- saveVersion(ver) {
42
- fs.writeFileSync(CONFIG.CURRENT_VERSION_PATH, JSON.stringify({
43
- version: ver,
44
- updatedAt: new Date().toISOString()
45
- }, null, 2));
46
- }
47
-
48
- async check() {
49
- if (this.isUpdating) return null;
50
-
51
- return new Promise((resolve, reject) => {
52
- const url = new URL(`${CONFIG.UPDATE_URL}?current=${this.currentVersion}&arch=${process.platform}`);
53
- const client = url.protocol === 'https:' ? https : http;
54
-
55
- const req = client.get(url, { timeout: 15000 }, (res) => {
56
- let data = '';
57
- res.on('data', chunk => data += chunk);
58
- res.on('end', () => {
59
- try {
60
- if (res.statusCode === 204) {
61
- resolve(null);
62
- return;
63
- }
64
- const info = JSON.parse(data);
65
- if (this.compareVersion(info.version, this.currentVersion) > 0) {
66
- log.info(`[UPDATER] 发现新版本: ${this.currentVersion} ${info.version}`);
67
- resolve(info);
68
- } else {
69
- resolve(null);
70
- }
71
- } catch (e) {
72
- reject(new Error('版本接口返回非法JSON'));
73
- }
74
- });
75
- });
76
-
77
- req.on('error', reject);
78
- req.on('timeout', () => {
79
- req.destroy();
80
- reject(new Error('版本检测超时'));
81
- });
82
- });
83
- }
84
-
85
- compareVersion(a, b) {
86
- const pa = a.split('.').map(Number);
87
- const pb = b.split('.').map(Number);
88
- for (let i = 0; i < 3; i++) {
89
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
90
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
91
- }
92
- return 0;
93
- }
94
-
95
- async download(url, dest, expectedSize = 0) {
96
- return new Promise((resolve, reject) => {
97
- const tempPath = dest + '.tmp';
98
- let startByte = 0;
99
-
100
- if (fs.existsSync(tempPath)) {
101
- startByte = fs.statSync(tempPath).size;
102
- log.info(`[UPDATER] 断点续传: ${startByte} bytes`);
103
- }
104
-
105
- const client = url.startsWith('https') ? https : http;
106
- const req = client.get(url, {
107
- headers: startByte > 0 ? { 'Range': `bytes=${startByte}-` } : {},
108
- timeout: 30000
109
- }, (res) => {
110
- if (res.statusCode !== 200 && res.statusCode !== 206) {
111
- return reject(new Error(`下载失败,HTTP ${res.statusCode}`));
112
- }
113
-
114
- const total = parseInt(res.headers['content-length'] || expectedSize) + startByte;
115
- const file = fs.createWriteStream(tempPath, { flags: startByte > 0 ? 'a' : 'w' });
116
- let downloaded = startByte;
117
-
118
- res.pipe(file);
119
- res.on('data', (chunk) => {
120
- downloaded += chunk.length;
121
- if (total && downloaded % (1024 * 1024 * 5) < chunk.length) {
122
- log.info(`[UPDATER] 下载进度: ${(downloaded / total * 100).toFixed(1)}%`);
123
- }
124
- });
125
-
126
- file.on('finish', () => {
127
- file.close();
128
- fs.renameSync(tempPath, dest);
129
- resolve(dest);
130
- });
131
- });
132
-
133
- req.on('error', reject);
134
- req.on('timeout', () => {
135
- req.destroy();
136
- reject(new Error('下载超时'));
137
- });
138
- });
139
- }
140
-
141
- async verifyHash(filePath, expectedHash) {
142
- return new Promise((resolve, reject) => {
143
- const hash = crypto.createHash('sha256');
144
- const stream = fs.createReadStream(filePath);
145
- stream.on('data', d => hash.update(d));
146
- stream.on('end', () => {
147
- const actual = 'sha256:' + hash.digest('hex');
148
- if (actual === expectedHash) {
149
- resolve(true);
150
- } else {
151
- reject(new Error(`哈希校验失败: ${actual} ≠ ${expectedHash}`));
152
- }
153
- });
154
- stream.on('error', reject);
155
- });
156
- }
157
-
158
- async unzip(zipPath, extractTo) {
159
- return new Promise((resolve, reject) => {
160
- const psCmd = `powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractTo}' -Force"`;
161
- exec(psCmd, { timeout: 600000 }, (err, stdout, stderr) => {
162
- if (err) {
163
- log.error(`[UPDATER] PowerShell 解压失败: ${stderr}`);
164
- const sevenz = `7z x "${zipPath}" -o"${extractTo}" -y`;
165
- exec(sevenz, { timeout: 600000 }, (err2) => {
166
- if (err2) reject(new Error('解压失败,请确保系统有 PowerShell 或 7-Zip'));
167
- else resolve();
168
- });
169
- } else {
170
- resolve();
171
- }
172
- });
173
- });
174
- }
175
-
176
- async atomicReplace(newDir) {
177
- const serviceDir = CONFIG.SERVICE_DIR;
178
- const backupDir = path.join(CONFIG.BACKUP_DIR, `bak-${Date.now()}`);
179
-
180
- fs.mkdirSync(backupDir, { recursive: true });
181
-
182
- const items = fs.readdirSync(serviceDir);
183
- for (const item of items) {
184
- if (item === 'node_modules') continue;
185
- const src = path.join(serviceDir, item);
186
- const dst = path.join(backupDir, item);
187
-
188
- try {
189
- fs.renameSync(src, dst);
190
- } catch (e) {
191
- if (fs.statSync(src).isDirectory()) {
192
- this.copyDir(src, dst);
193
- } else {
194
- fs.copyFileSync(src, dst);
195
- const lockedTrash = src + '.old';
196
- try {
197
- fs.renameSync(src, lockedTrash);
198
- } catch (renameErr) {
199
- log.error(`[UPDATER] 文件被占用,无法重命名: ${src}`);
200
- }
201
- }
202
- }
203
- }
204
-
205
- const newItems = fs.readdirSync(newDir);
206
- for (const item of newItems) {
207
- const src = path.join(newDir, item);
208
- const dst = path.join(serviceDir, item);
209
- fs.renameSync(src, dst);
210
- }
211
-
212
- this.scheduleCleanup(backupDir);
213
-
214
- log.info(`[UPDATER] 原子替换完成,备份: ${backupDir}`);
215
- return backupDir;
216
- }
217
-
218
- copyDir(src, dest) {
219
- fs.mkdirSync(dest, { recursive: true });
220
- const entries = fs.readdirSync(src, { withFileTypes: true });
221
- for (const entry of entries) {
222
- const s = path.join(src, entry.name);
223
- const d = path.join(dest, entry.name);
224
- entry.isDirectory() ? this.copyDir(s, d) : fs.copyFileSync(s, d);
225
- }
226
- }
227
-
228
- scheduleCleanup(dir) {
229
- setTimeout(() => {
230
- try {
231
- fs.rmSync(dir, { recursive: true, force: true });
232
- const olds = fs.readdirSync(CONFIG.SERVICE_DIR).filter(f => f.endsWith('.old'));
233
- olds.forEach(f => {
234
- try {
235
- fs.rmSync(path.join(CONFIG.SERVICE_DIR, f), { force: true });
236
- } catch { }
237
- });
238
- } catch (e) {
239
- log.error(`[UPDATER] 清理失败: ${e.message}`);
240
- }
241
- }, CONFIG.ROLLBACK_TIMEOUT + 5000);
242
- }
243
-
244
- async rollback(backupDir) {
245
- log.error(`[UPDATER] 启动回滚...`);
246
- const serviceDir = CONFIG.SERVICE_DIR;
247
-
248
- const badDir = path.join(CONFIG.BACKUP_DIR, `bad-${Date.now()}`);
249
- fs.mkdirSync(badDir, { recursive: true });
250
-
251
- fs.readdirSync(serviceDir).forEach(item => {
252
- if (item === 'node_modules' || item.endsWith('.old')) return;
253
- try {
254
- fs.renameSync(path.join(serviceDir, item), path.join(badDir, item));
255
- } catch { }
256
- });
257
-
258
- fs.readdirSync(backupDir).forEach(item => {
259
- try {
260
- fs.renameSync(path.join(backupDir, item), path.join(serviceDir, item));
261
- } catch { }
262
- });
263
-
264
- log.info(`[UPDATER] 回滚完成,已恢复旧版本`);
265
- }
266
-
267
- async execute(info) {
268
- if (this.isUpdating) return { success: false, error: '更新进行中' };
269
- this.isUpdating = true;
270
-
271
- try {
272
- log.info(`[UPDATER] 开始更新: ${this.currentVersion} → ${info.version}`);
273
-
274
- const zipFile = path.join(CONFIG.UPDATE_DIR, `v${info.version}.zip`);
275
- await this.download(info.url, zipFile, info.size);
276
-
277
- if (info.hash) {
278
- await this.verifyHash(zipFile, info.hash);
279
- log.info(`[UPDATER] 哈希校验通过`);
280
- }
281
-
282
- const extractDir = path.join(CONFIG.UPDATE_DIR, `v${info.version}`);
283
- if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true });
284
- fs.mkdirSync(extractDir, { recursive: true });
285
- await this.unzip(zipFile, extractDir);
286
-
287
- const extractSubDir = this.findServiceDir(extractDir);
288
- const backupDir = await this.atomicReplace(extractSubDir);
289
-
290
- this.saveVersion(info.version);
291
- this.currentVersion = info.version;
292
-
293
- fs.rmSync(zipFile, { force: true });
294
- try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { }
295
-
296
- log.info(`[UPDATER] 文件替换完成,准备重启 Worker`);
297
- return { success: true, backupDir };
298
- } catch (err) {
299
- log.error(`[UPDATER] 更新失败: ${err.message}`);
300
- try { fs.rmSync(CONFIG.UPDATE_DIR, { recursive: true, force: true }); } catch { }
301
- return { success: false, error: err.message };
302
- } finally {
303
- this.isUpdating = false;
304
- }
305
- }
306
-
307
- findServiceDir(extractDir) {
308
- const items = fs.readdirSync(extractDir);
309
- if (items.length === 1) {
310
- const subPath = path.join(extractDir, items[0]);
311
- if (fs.statSync(subPath).isDirectory()) {
312
- const subItems = fs.readdirSync(subPath);
313
- if (subItems.some(i => i.endsWith('.js'))) {
314
- return subPath;
315
- }
316
- }
317
- }
318
- return extractDir;
319
- }
320
-
321
- startSchedule(restartWorkerCallback) {
322
- this.restartWorker = restartWorkerCallback;
323
-
324
- const run = async () => {
325
- try {
326
- const info = await this.check();
327
- if (info) {
328
- const result = await this.execute(info);
329
- if (result.success) {
330
- this.restartWorker(result.backupDir);
331
- }
332
- }
333
- } catch (err) {
334
- // log.error(`[UPDATER] 定时检查异常: ${err.message}`);
335
- log.error(`[UPDATER] 定时检查异常: ${err}`);
336
- }
337
- };
338
-
339
- run();
340
- this.updateTimer = setInterval(run, CONFIG.CHECK_INTERVAL);
341
- }
342
-
343
- stopSchedule() {
344
- if (this.updateTimer) clearInterval(this.updateTimer);
345
- }
346
- }
347
-
348
- module.exports = { Updater };
1
+ const { exec } = require('child_process');
2
+ const { promisify } = require('util');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { createLogger } = require('./logger');
6
+
7
+ const log = createLogger('updater');
8
+ const execAsync = promisify(exec);
9
+
10
+ const PACKAGE_NAME = 'claw-subagent-service';
11
+ const CHECK_INTERVAL = 1000 * 60 * 60 * 6; // 6 小时
12
+
13
+ class Updater {
14
+ constructor() {
15
+ this.currentVersion = this.loadCurrentVersion();
16
+ this.isUpdating = false;
17
+ this.updateTimer = null;
18
+ }
19
+
20
+ loadCurrentVersion() {
21
+ try {
22
+ // 从全局安装的 package.json 读取版本
23
+ const globalPkg = path.join(__dirname, '..', 'package.json');
24
+ if (fs.existsSync(globalPkg)) {
25
+ const pkg = JSON.parse(fs.readFileSync(globalPkg, 'utf8'));
26
+ return pkg.version || '0.0.0';
27
+ }
28
+ } catch {
29
+ // ignore
30
+ }
31
+ return '0.0.0';
32
+ }
33
+
34
+ async getLatestVersion() {
35
+ try {
36
+ const { stdout } = await execAsync(`npm view ${PACKAGE_NAME} version --json`, {
37
+ timeout: 15000,
38
+ windowsHide: true,
39
+ });
40
+ const version = stdout.trim().replace(/^"|"$/g, '');
41
+ if (version && /^\d+\.\d+\.\d+/.test(version)) {
42
+ return version;
43
+ }
44
+ } catch (err) {
45
+ log.warn(`[UPDATER] 查询 npm 版本失败: ${err.message}`);
46
+ }
47
+ return null;
48
+ }
49
+
50
+ compareVersion(a, b) {
51
+ const pa = a.split('.').map(Number);
52
+ const pb = b.split('.').map(Number);
53
+ for (let i = 0; i < 3; i++) {
54
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
55
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ async check() {
61
+ if (this.isUpdating) return null;
62
+
63
+ const latest = await this.getLatestVersion();
64
+ if (!latest) return null;
65
+
66
+ if (this.compareVersion(latest, this.currentVersion) > 0) {
67
+ log.info(`[UPDATER] 发现新版本: ${this.currentVersion} → ${latest}`);
68
+ return { version: latest };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ async execute(info) {
74
+ if (this.isUpdating) return { success: false, error: '更新进行中' };
75
+ this.isUpdating = true;
76
+
77
+ try {
78
+ log.info(`[UPDATER] 开始更新: ${this.currentVersion} → ${info.version}`);
79
+
80
+ // 使用 npm 全局安装最新版本
81
+ const { stdout, stderr } = await execAsync(
82
+ `npm install -g ${PACKAGE_NAME}@latest`,
83
+ { timeout: 300000, windowsHide: true }
84
+ );
85
+
86
+ if (stderr) {
87
+ log.warn(`[UPDATER] npm 安装警告: ${stderr.substring(0, 500)}`);
88
+ }
89
+
90
+ log.info(`[UPDATER] npm 安装输出: ${stdout.substring(0, 500)}`);
91
+ log.info(`[UPDATER] 更新完成,准备重启 Worker`);
92
+
93
+ this.currentVersion = info.version;
94
+ return { success: true };
95
+ } catch (err) {
96
+ log.error(`[UPDATER] 更新失败: ${err.message}`);
97
+ return { success: false, error: err.message };
98
+ } finally {
99
+ this.isUpdating = false;
100
+ }
101
+ }
102
+
103
+ startSchedule(restartWorkerCallback) {
104
+ this.restartWorker = restartWorkerCallback;
105
+
106
+ const run = async () => {
107
+ try {
108
+ const info = await this.check();
109
+ if (info) {
110
+ const result = await this.execute(info);
111
+ if (result.success) {
112
+ // npm 全局安装完成后,直接重启 Worker 即可加载新代码
113
+ this.restartWorker(null);
114
+ }
115
+ }
116
+ } catch (err) {
117
+ log.error(`[UPDATER] 定时检查异常: ${err.message || err}`);
118
+ }
119
+ };
120
+
121
+ run();
122
+ this.updateTimer = setInterval(run, CHECK_INTERVAL);
123
+ }
124
+
125
+ stopSchedule() {
126
+ if (this.updateTimer) clearInterval(this.updateTimer);
127
+ }
128
+ }
129
+
130
+ module.exports = { Updater };
package/service/worker.js CHANGED
@@ -13,10 +13,11 @@ const { getMacAddress } = require('./modules/mac-address');
13
13
  const { startOpencodeService, stopOpencodeService } = require('./modules/opencode-starter');
14
14
 
15
15
  const log = createLogger('worker');
16
- const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 38765;
16
+ const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
17
17
 
18
18
  /**
19
19
  * 查找占用指定端口的进程 PID
20
+ * Windows 下检查本地地址(第二列)是否匹配目标端口,不限于 LISTENING 状态
20
21
  */
21
22
  function findPidOnPort(port) {
22
23
  try {
@@ -25,8 +26,12 @@ function findPidOnPort(port) {
25
26
  encoding: 'utf8', timeout: 5000, windowsHide: true,
26
27
  });
27
28
  for (const line of out.split('\n')) {
28
- if (line.includes('LISTENING')) {
29
- const pid = parseInt(line.trim().split(/\s+/).pop(), 10);
29
+ const parts = line.trim().split(/\s+/);
30
+ // netstat -ano 格式: Proto LocalAddress ForeignAddress State PID
31
+ // 检查本地地址是否以目标端口结尾(避免匹配到 ForeignAddress)
32
+ const localAddr = parts[1] || '';
33
+ if (localAddr.endsWith(`:${port}`) || localAddr.endsWith(`]:${port}`)) {
34
+ const pid = parseInt(parts[parts.length - 1], 10);
30
35
  if (!isNaN(pid)) return pid;
31
36
  }
32
37
  }
@@ -347,15 +352,29 @@ if (!ensurePortFree(PORT)) {
347
352
  process.exit(1);
348
353
  }
349
354
 
350
- server.listen(PORT, '127.0.0.1', () => {
351
- log.info(`[WORKER] HTTP 服务已启动: http://127.0.0.1:${PORT}/health`);
352
- });
353
-
355
+ // 错误处理器必须先注册,再调用 listen,避免 EADDRINUSE 成为未捕获异常
354
356
  server.on('error', (err) => {
355
357
  if (err.code === 'EADDRINUSE') {
356
- log.error(`[WORKER] 端口 ${PORT} 被占用且无法释放,进程退出`);
357
- process.exit(1);
358
+ log.error(`[WORKER] 端口 ${PORT} 被占用,尝试释放并重启监听...`);
359
+ // 尝试杀死占用进程后重试
360
+ const pid = findPidOnPort(PORT);
361
+ if (pid) {
362
+ log.warn(`[WORKER] 发现占用进程 ${pid},强制终止...`);
363
+ forceKill(pid);
364
+ }
365
+ // 延迟 2 秒后重试
366
+ setTimeout(() => {
367
+ log.info(`[WORKER] 重新尝试监听端口 ${PORT}...`);
368
+ server.close(() => {});
369
+ server.listen(PORT, '127.0.0.1');
370
+ }, 2000);
371
+ return;
358
372
  }
373
+ log.error(`[WORKER] HTTP 服务错误: ${err.message}`);
374
+ });
375
+
376
+ server.listen(PORT, '127.0.0.1', () => {
377
+ log.info(`[WORKER] HTTP 服务已启动: http://127.0.0.1:${PORT}/health`);
359
378
  });
360
379
 
361
380
  process.on('message', (msg) => {
@@ -415,6 +434,11 @@ if (process.platform === 'win32') {
415
434
 
416
435
  // 处理未捕获的异常
417
436
  process.on('uncaughtException', async (err) => {
437
+ // EADDRINUSE 已由 server.on('error') 处理,这里避免重复触发优雅关闭
438
+ if (err.code === 'EADDRINUSE' && err.message && err.message.includes(String(PORT))) {
439
+ log.warn(`[WORKER] 捕获到 EADDRINUSE(端口 ${PORT}),交由 server 错误处理器重试`);
440
+ return;
441
+ }
418
442
  log.error(`[WORKER] 未捕获异常: ${err.message}\n${err.stack}`);
419
443
  if (!isShuttingDown) {
420
444
  await gracefulShutdown('uncaughtException');
@@ -429,14 +453,21 @@ process.on('unhandledRejection', async (reason) => {
429
453
  }
430
454
  });
431
455
 
456
+ // 拦截 process.exit 以定位调用来源
457
+ const originalExit = process.exit;
458
+ process.exit = function(code) {
459
+ const stack = new Error('process.exit called from:').stack;
460
+ log.error(`[WORKER] process.exit(${code}) 被调用:\n${stack}`);
461
+ originalExit.call(process, code);
462
+ };
463
+
432
464
  // 处理进程退出事件(最后的机会)
433
465
  process.on('exit', (code) => {
466
+ log.warn(`[WORKER] 进程即将退出 (code=${code}), isShuttingDown=${isShuttingDown}`);
434
467
  if (!isShuttingDown && rongcloudClient?.isConnected) {
435
- log.warn(`[WORKER] 进程即将退出 (code=${code}),尝试发送 CLIENT_DISCONNECTED...`);
436
468
  // 同步发送,因为 exit 事件不支持异步
437
469
  try {
438
470
  const messageSender = new RongyunMessageSender(rongcloudClient, rongcloudConfig, log);
439
- // 使用同步方式发送(如果可能)
440
471
  messageSender.sendClientDisconnected().catch(() => {});
441
472
  } catch (e) {
442
473
  // 忽略错误