openclaw-agent-dashboard 1.0.23 → 1.0.25

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.
@@ -4,7 +4,7 @@
4
4
  """
5
5
  import threading
6
6
  import time
7
- from typing import Dict, Any, List, Set
7
+ from typing import Dict, Any, List, Set, Optional
8
8
 
9
9
 
10
10
  class ChangeTracker:
package/index.js CHANGED
@@ -9,11 +9,14 @@
9
9
  * 2. ~/.openclaw-agent-dashboard/config.json(或 OPENCLAW_AGENT_DASHBOARD_DATA/config.json)
10
10
  * 3. openclaw.json 中 plugins.entries.openclaw-agent-dashboard.config.port
11
11
  * 4. 默认 38271
12
+ *
13
+ * 启动前:若首选端口被占用且响应为本插件的 /api/version,则在 Unix 上 SIGTERM 释放端口(避免重启后落到 38272)。
12
14
  */
13
15
  const path = require('path');
14
16
  const os = require('os');
15
17
  const fs = require('fs');
16
18
  const net = require('net');
19
+ const http = require('http');
17
20
  const { spawn, execFileSync } = require('child_process');
18
21
 
19
22
  let dashboardProcess = null;
@@ -145,6 +148,112 @@ async function findAvailablePort(basePort, maxAttempts = 10) {
145
148
  return basePort;
146
149
  }
147
150
 
151
+ /** 与 Python 端 VersionInfo.name 一致(来自 openclaw.plugin.json / package.json) */
152
+ function getExpectedDashboardApiName() {
153
+ try {
154
+ const manifestPath = path.join(__dirname, 'openclaw.plugin.json');
155
+ if (fs.existsSync(manifestPath)) {
156
+ const j = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
157
+ if (j && typeof j.name === 'string' && j.name.trim()) return j.name.trim();
158
+ if (j && typeof j.id === 'string' && j.id.trim()) return j.id.trim();
159
+ }
160
+ } catch (_) {}
161
+ return 'openclaw-agent-dashboard';
162
+ }
163
+
164
+ /**
165
+ * 探测本机 port 上是否为当前插件的 Dashboard(FastAPI /api/version)
166
+ * @returns {Promise<boolean>}
167
+ */
168
+ function probeOurDashboardListening(port) {
169
+ const expected = getExpectedDashboardApiName();
170
+ return new Promise((resolve) => {
171
+ const req = http.get(
172
+ `http://127.0.0.1:${port}/api/version`,
173
+ { timeout: 2500 },
174
+ (res) => {
175
+ let raw = '';
176
+ res.on('data', (c) => { raw += c; });
177
+ res.on('end', () => {
178
+ try {
179
+ const data = JSON.parse(raw);
180
+ resolve(typeof data.name === 'string' && data.name.trim() === expected);
181
+ } catch (_) {
182
+ resolve(false);
183
+ }
184
+ });
185
+ }
186
+ );
187
+ req.on('error', () => resolve(false));
188
+ req.on('timeout', () => {
189
+ req.destroy();
190
+ resolve(false);
191
+ });
192
+ });
193
+ }
194
+
195
+ /**
196
+ * 获取在 port 上 LISTEN 的进程 PID(Unix)。Windows 暂不支持,返回空数组。
197
+ * @returns {number[]}
198
+ */
199
+ function getListenPidsOnPort(port) {
200
+ if (process.platform === 'win32') {
201
+ return [];
202
+ }
203
+ const tryLsof = (args) => {
204
+ try {
205
+ const out = execFileSync('lsof', args, { encoding: 'utf8', timeout: 5000 });
206
+ return out
207
+ .trim()
208
+ .split(/\s+/)
209
+ .filter(Boolean)
210
+ .map((s) => parseInt(s, 10))
211
+ .filter((n) => !Number.isNaN(n) && n > 0);
212
+ } catch (_) {
213
+ return [];
214
+ }
215
+ };
216
+ let pids = tryLsof(['-iTCP:' + port, '-sTCP:LISTEN', '-t']);
217
+ if (pids.length === 0) {
218
+ pids = tryLsof(['-ti', ':' + port]);
219
+ }
220
+ return [...new Set(pids)];
221
+ }
222
+
223
+ function sleepMs(ms) {
224
+ return new Promise((r) => setTimeout(r, ms));
225
+ }
226
+
227
+ /**
228
+ * Gateway 重启后旧 Dashboard 子进程常仍占用首选端口,导致新实例落到 38272。
229
+ * 若占用者为本插件的 Dashboard,则 SIGTERM 释放端口后再启动(仅 Unix;Windows 行为不变)。
230
+ */
231
+ async function reclaimStaleOurDashboardPort(port) {
232
+ if (await isPortAvailable(port)) return;
233
+ const ours = await probeOurDashboardListening(port);
234
+ if (!ours) {
235
+ return;
236
+ }
237
+ const pids = getListenPidsOnPort(port);
238
+ if (pids.length === 0) {
239
+ console.log(
240
+ `[OpenClaw-Dashboard] 端口 ${port} 上检测到本插件 Dashboard,但无法解析占用进程(可安装 lsof),仍尝试后续端口`
241
+ );
242
+ return;
243
+ }
244
+ console.log(
245
+ `[OpenClaw-Dashboard] 端口 ${port} 被上一实例 Dashboard 占用 (PID: ${pids.join(', ')}),正在结束以便固定端口`
246
+ );
247
+ for (const pid of pids) {
248
+ try {
249
+ process.kill(pid, 'SIGTERM');
250
+ } catch (e) {
251
+ console.warn(`[OpenClaw-Dashboard] 无法向 PID ${pid} 发送 SIGTERM:`, e.message || e);
252
+ }
253
+ }
254
+ await sleepMs(900);
255
+ }
256
+
148
257
  function startDashboard(config = {}) {
149
258
  // 仅在 Gateway 进程内自动启动(OPENCLAW_GATEWAY_PORT 由 Gateway 设置)
150
259
  // CLI 命令(status、health 等)加载插件时不会设置此变量,故不启动
@@ -173,9 +282,10 @@ function startDashboard(config = {}) {
173
282
  return;
174
283
  }
175
284
 
176
- const portPromise = isExplicitPort
177
- ? Promise.resolve(basePort)
178
- : findAvailablePort(basePort);
285
+ const portPromise = (async () => {
286
+ await reclaimStaleOurDashboardPort(basePort);
287
+ return isExplicitPort ? basePort : await findAvailablePort(basePort);
288
+ })();
179
289
 
180
290
  portPromise.then(async (port) => {
181
291
  if (dashboardProcess) return;
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-agent-dashboard",
3
3
  "name": "OpenClaw Agent Dashboard",
4
4
  "description": "多 Agent 可视化看板 - 状态、任务、API、工作流、协作流程",
5
- "version": "1.0.23",
5
+ "version": "1.0.25",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-agent-dashboard",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "多 Agent 可视化看板 - OpenClaw 插件",
5
5
  "main": "index.js",
6
6
  "openclaw": {
@@ -280,9 +280,9 @@ async function installWithPipUser(reqFile, silent) {
280
280
  * @param {object} options
281
281
  * @param {boolean} [options.verbose]
282
282
  * @param {boolean} [options.venvOnly]
283
- * @returns {boolean}
283
+ * @returns {Promise<boolean>}
284
284
  */
285
- function installPythonDeps(pluginDir, options = {}) {
285
+ async function installPythonDeps(pluginDir, options = {}) {
286
286
  const { verbose = false, venvOnly = false } = options;
287
287
  const reqFile = path.join(pluginDir, 'dashboard', 'requirements.txt');
288
288
  const venvDir = path.join(pluginDir, 'dashboard', '.venv');
@@ -309,7 +309,7 @@ function installPythonDeps(pluginDir, options = {}) {
309
309
 
310
310
  // 策略 2: pip --user 兜底
311
311
  if (!success && !venvOnly) {
312
- const result = installWithPipUser(reqFile, silent);
312
+ const result = await installWithPipUser(reqFile, silent);
313
313
  if (result.success) {
314
314
  success = true;
315
315
  method = result.method;
@@ -403,7 +403,7 @@ function printPythonDepsHelp(reqFile) {
403
403
  // 主函数
404
404
  // ============================================
405
405
 
406
- function main() {
406
+ async function main() {
407
407
  const { pluginDir, verbose, venvOnly } = parseArgs();
408
408
 
409
409
  // 检查参数
@@ -426,8 +426,11 @@ function main() {
426
426
  }
427
427
 
428
428
  // 执行安装
429
- const success = installPythonDeps(pluginDir, { verbose, venvOnly });
429
+ const success = await installPythonDeps(pluginDir, { verbose, venvOnly });
430
430
  process.exit(success ? 0 : 1);
431
431
  }
432
432
 
433
- main();
433
+ main().catch((err) => {
434
+ logError(err && err.message ? err.message : String(err));
435
+ process.exit(1);
436
+ });